Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 253 additions & 0 deletions backend/__tests__/service/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const express = require('express');
const mongoose = require('mongoose');
const User = require('../../models/User');
const Pod = require('../../models/Pod');
const DeviceAuthorization = require('../../models/DeviceAuthorization');
const { hashDeviceCredential } = require('../../services/deviceAuthorizationService');
const authRoutes = require('../../routes/auth');
const {
setupMongoDb,
Expand Down Expand Up @@ -410,6 +412,257 @@ describe('Auth Routes Integration Tests', () => {
});
});

describe('CLI device authorization', () => {
const createVerifiedUser = async () => {
const user = new User({
username: 'device-owner',
email: 'device-owner@example.com',
password: 'Password123!',
verified: true,
});
await user.save();
return user;
};

const startAuthorization = async () => request(app)
.post('/api/auth/device/start')
.send({ clientName: 'commonly-cli', clientVersion: '0.1.26', hostname: 'sam-laptop' })
.expect(201);

it('declares a zero-delay TTL reaper in addition to endpoint expiry checks', () => {
const expiryIndex = DeviceAuthorization.schema.indexes()
.find(([keys]) => Object.prototype.hasOwnProperty.call(keys, 'expiresAt'));
expect(expiryIndex).toBeDefined();
expect(expiryIndex[1]).toEqual(expect.objectContaining({ expireAfterSeconds: 0 }));
});

it('hands an approved token to exactly one poller and persists only its digest', async () => {
const user = await createVerifiedUser();
const browserToken = generateTestToken(user._id);
const start = await startAuthorization();

expect(start.body).toMatchObject({
verifyUrl: 'http://localhost:3000/cli/authorize',
expiresIn: 600,
interval: 5,
});
expect(start.body.deviceCode).toBeTruthy();
expect(start.body.userCode).toMatch(/^[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}$/);

const storedRequest = await DeviceAuthorization.findOne();
expect(storedRequest.deviceCodeHash).not.toBe(start.body.deviceCode);
expect(storedRequest.userCodeHash).not.toBe(start.body.userCode.replace('-', ''));

await request(app)
.post('/api/auth/device/poll')
.send({ deviceCode: start.body.deviceCode })
.expect(200)
.expect({ status: 'authorization_pending' });

const confirmation = await request(app)
.post('/api/auth/device/authorize')
.set('Authorization', `Bearer ${browserToken}`)
.send({ userCode: start.body.userCode })
.expect(200);
expect(confirmation.body).toMatchObject({
status: 'pending',
request: { hostname: 'sam-laptop', clientName: 'commonly-cli', clientVersion: '0.1.26' },
});

await request(app)
.post('/api/auth/device/authorize')
.set('Authorization', `Bearer ${browserToken}`)
.send({ userCode: start.body.userCode, decision: 'authorize' })
.expect(200)
.expect({ status: 'authorized' });

const granted = await request(app)
.post('/api/auth/device/poll')
.send({ deviceCode: start.body.deviceCode })
.expect(200);
expect(granted.body).toMatchObject({ username: 'device-owner', userId: user._id.toString() });
expect(granted.body.token).toMatch(/^cm_[a-f0-9]{64}$/);

// The transient handoff is consumed atomically; a duplicate poll never
// returns a second copy of the bearer.
await request(app)
.post('/api/auth/device/poll')
.send({ deviceCode: start.body.deviceCode })
.expect(200)
.expect({ status: 'already_used' });

const persistedUser = await User.findById(user._id).select('deviceTokens');
expect(persistedUser.deviceTokens).toHaveLength(1);
expect(persistedUser.deviceTokens[0].label).toBe('sam-laptop · commonly-cli');
expect(persistedUser.deviceTokens[0].tokenHash).not.toBe(granted.body.token);
expect(JSON.stringify(persistedUser)).not.toContain(granted.body.token);

// It is a normal user bearer until explicitly revoked.
await request(app)
.get('/api/auth/user')
.set('Authorization', `Bearer ${granted.body.token}`)
.expect(200)
.expect((response) => expect(response.body.deviceTokens).toBeUndefined());

const devices = await request(app)
.get('/api/auth/devices')
.set('Authorization', `Bearer ${browserToken}`)
.expect(200);
expect(devices.body.devices).toEqual([expect.objectContaining({
label: 'sam-laptop · commonly-cli',
})]);
expect(devices.body.devices[0].tokenHash).toBeUndefined();
expect(JSON.stringify(devices.body)).not.toContain(granted.body.token);

await request(app)
.delete(`/api/auth/devices/${devices.body.devices[0].id}`)
.set('Authorization', `Bearer ${browserToken}`)
.expect(200);
await request(app)
.get('/api/auth/user')
.set('Authorization', `Bearer ${granted.body.token}`)
.expect(401);

await request(app)
.delete('/api/auth/devices/not-a-device-id')
.set('Authorization', `Bearer ${browserToken}`)
.expect(404);
});

it('requires a browser JWT for credential management and cannot mint a successor from a device token', async () => {
const user = await createVerifiedUser();
const browserToken = generateTestToken(user._id);
const first = await startAuthorization();

await request(app)
.post('/api/auth/device/authorize')
.set('Authorization', `Bearer ${browserToken}`)
.send({ userCode: first.body.userCode, decision: 'authorize' })
.expect(200);
const firstGrant = await request(app)
.post('/api/auth/device/poll')
.send({ deviceCode: first.body.deviceCode })
.expect(200);

const successor = await startAuthorization();
await request(app)
.post('/api/auth/device/authorize')
.set('Authorization', `Bearer ${firstGrant.body.token}`)
.send({ userCode: successor.body.userCode, decision: 'authorize' })
.expect(403);
await request(app)
.post('/api/auth/device/poll')
.send({ deviceCode: successor.body.deviceCode })
.expect(200)
.expect({ status: 'authorization_pending' });

// A device bearer is deliberately long-lived, not refreshable. It must
// not be able to launder itself into a JWT or revoke/list the account's
// other device credentials if it is stolen.
await request(app)
.post('/api/auth/refresh')
.set('Authorization', `Bearer ${firstGrant.body.token}`)
.expect(403);
await request(app)
.get('/api/auth/devices')
.set('Authorization', `Bearer ${firstGrant.body.token}`)
.expect(403);

const persistedUser = await User.findById(user._id).select('deviceTokens');
expect(persistedUser.deviceTokens).toHaveLength(1);
await request(app)
.delete(`/api/auth/devices/${persistedUser.deviceTokens[0]._id}`)
.set('Authorization', `Bearer ${firstGrant.body.token}`)
.expect(403);
await request(app)
.post('/api/auth/api-token/generate')
.set('Authorization', `Bearer ${firstGrant.body.token}`)
.expect(403);
await request(app)
.get('/api/auth/api-token')
.set('Authorization', `Bearer ${firstGrant.body.token}`)
.expect(403);
await request(app)
.delete('/api/auth/api-token')
.set('Authorization', `Bearer ${firstGrant.body.token}`)
.expect(403);

await request(app)
.post('/api/auth/refresh')
.set('Authorization', `Bearer ${browserToken}`)
.expect(200)
.expect((response) => expect(response.body.token).toBeTruthy());
await request(app)
.post('/api/auth/api-token/generate')
.set('Authorization', `Bearer ${browserToken}`)
.expect(200)
.expect((response) => expect(response.body.apiToken).toBeTruthy());
});

it('returns slow_down, denied, and expired terminal states without minting a token', async () => {
const user = await createVerifiedUser();
const browserToken = generateTestToken(user._id);

const pending = await startAuthorization();
await request(app).post('/api/auth/device/poll').send({ deviceCode: pending.body.deviceCode }).expect(200);
await request(app)
.post('/api/auth/device/poll')
.send({ deviceCode: pending.body.deviceCode })
.expect(200)
.expect({ status: 'slow_down' });

const denied = await startAuthorization();
await request(app)
.post('/api/auth/device/authorize')
.set('Authorization', `Bearer ${browserToken}`)
.send({ userCode: denied.body.userCode, decision: 'deny' })
.expect(200)
.expect({ status: 'denied' });
await request(app)
.post('/api/auth/device/poll')
.send({ deviceCode: denied.body.deviceCode })
.expect(200)
.expect({ status: 'denied' });

const expired = await startAuthorization();
await DeviceAuthorization.updateOne(
{ deviceCodeHash: hashDeviceCredential(expired.body.deviceCode) },
{ $set: { expiresAt: new Date(Date.now() - 1000) } },
);
await request(app)
.post('/api/auth/device/poll')
.send({ deviceCode: expired.body.deviceCode })
.expect(200)
.expect({ status: 'expired' });
expect((await User.findById(user._id).select('deviceTokens')).deviceTokens).toHaveLength(0);
});

it('does not mint a bearer when an approved terminal abandons the flow', async () => {
const user = await createVerifiedUser();
const browserToken = generateTestToken(user._id);
const started = await startAuthorization();

await request(app)
.post('/api/auth/device/authorize')
.set('Authorization', `Bearer ${browserToken}`)
.send({ userCode: started.body.userCode, decision: 'authorize' })
.expect(200)
.expect({ status: 'authorized' });

await DeviceAuthorization.updateOne(
{ deviceCodeHash: hashDeviceCredential(started.body.deviceCode) },
{ $set: { expiresAt: new Date(Date.now() - 1000) } },
);
await request(app)
.post('/api/auth/device/poll')
.send({ deviceCode: started.body.deviceCode })
.expect(200)
.expect({ status: 'expired' });

expect((await User.findById(user._id).select('deviceTokens')).deviceTokens).toHaveLength(0);
});
});

describe('PUT /api/auth/profile', () => {
it('should update user profile with valid token', async () => {
// Create a user
Expand Down
8 changes: 4 additions & 4 deletions backend/controllers/authController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,7 @@ exports.login = async (req: any, res: any) => {
// 🔄 Refresh Token — issue a new 7d token from a still-valid token
exports.refresh = async (req: any, res: any) => {
try {
const user = await User.findById(req.userId).select('-password');
const user = await User.findById(req.userId).select('-password -deviceTokens');
if (!user) return res.status(404).json({ error: 'User not found' });

const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, {
Expand All @@ -705,7 +705,7 @@ exports.refresh = async (req: any, res: any) => {
// New method to get user profile
exports.getProfile = async (req: any, res: any) => {
try {
const user = await User.findById(req.userId).select('-password');
const user = await User.findById(req.userId).select('-password -deviceTokens');
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
} catch (err: any) {
Expand All @@ -716,7 +716,7 @@ exports.getProfile = async (req: any, res: any) => {
// Get current user information
exports.getCurrentUser = async (req: any, res: any) => {
try {
const user = await User.findById(req.userId).select('-password');
const user = await User.findById(req.userId).select('-password -deviceTokens');
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
Expand Down Expand Up @@ -747,7 +747,7 @@ exports.updateProfile = async (req: any, res: any) => {
await AgentIdentityService.syncUserToPostgreSQL(user);

// Return the updated user without the password
const updatedUser = await User.findById(userId).select('-password');
const updatedUser = await User.findById(userId).select('-password -deviceTokens');
res.json(updatedUser);
} catch (err: any) {
res.status(500).json({ error: err.message });
Expand Down
1 change: 1 addition & 0 deletions backend/controllers/userController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const SECRET_USER_FIELDS = [
'password',
'apiToken',
'agentRuntimeTokens',
'deviceTokens',
'digestUnsubscribeToken',
];

Expand Down
36 changes: 34 additions & 2 deletions backend/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';
import User from '../models/User';
import { hashDeviceCredential } from '../services/deviceAuthorizationService';

// User.lastActive was only ever set at account creation, which made every
// activity-based metric (returned D1/D7 in /api/admin/analytics/funnel,
Expand Down Expand Up @@ -45,10 +46,29 @@ export default async function auth(req: Request, res: Response, next: NextFuncti

if (token.startsWith('cm_')) {
try {
const user = await User.findOne({ apiToken: token }).select(
// `apiToken` is the historical single plaintext bearer. Device-login
// bearers share its public cm_ prefix but are stored only as a hash so a
// database read cannot impersonate a CLI session.
let user: any = await User.findOne({ apiToken: token }).select(
'_id username email role apiTokenScopes apiTokenCreatedAt banned',
);

let isDeviceToken = false;
const deviceTokenHash = hashDeviceCredential(token);
if (!user) {
user = await User.findOne({
deviceTokens: {
$elemMatch: {
tokenHash: deviceTokenHash,
// `{ $in: [null] }` deliberately covers both an older array
// entry with no field and a current entry with explicit null.
revokedAt: { $in: [null] },
},
},
}).select('_id username email role banned');
isDeviceToken = Boolean(user);
}

if (!user) return res.status(401).json({ msg: 'Invalid API token' });
if ((user as unknown as { banned?: boolean }).banned) {
return res.status(403).json({ msg: 'This account has been suspended.' });
Expand All @@ -61,9 +81,20 @@ export default async function auth(req: Request, res: Response, next: NextFuncti
email: user.email,
role: user.role,
};
req.authType = 'apiToken';
req.authType = isDeviceToken ? 'deviceToken' : 'apiToken';
req.apiTokenScopes = user.apiTokenScopes || [];
req.apiTokenCreatedAt = user.apiTokenCreatedAt || null;
if (isDeviceToken) {
// A usage stamp is an audit convenience, not an auth dependency: the
// request stays valid if this best-effort write loses a race.
void User.updateOne(
{
_id: user._id,
deviceTokens: { $elemMatch: { tokenHash: deviceTokenHash, revokedAt: { $in: [null] } } },
},
{ $set: { 'deviceTokens.$.lastUsedAt': new Date() } },
).catch(() => undefined);
}
touchLastActive(user._id.toString());
return next();
} catch (err: unknown) {
Expand All @@ -86,6 +117,7 @@ export default async function auth(req: Request, res: Response, next: NextFuncti

req.userId = id;
req.user = { id };
req.authType = 'jwt';
touchLastActive(id);
next();
} catch (err: unknown) {
Expand Down
Loading
Loading