diff --git a/backend/server.ts b/backend/server.ts index 84746103..b1c1fcfc 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -55,6 +55,7 @@ import { createIpWhitelistGate, } from './services/shared/ipWhitelistService'; import { serverSessionService } from './services/auth/serverSessionService'; +import { sessionTokenService } from './services/auth/domain/SessionTokenService'; feat/stellar-wave-issues import { startPaymentExpirySweeper, type PaymentExpirySweeper } from './services/payment/jobs/paymentExpirySweeper'; @@ -472,6 +473,8 @@ export async function startServer(options: StartServerOptions = {}): Promise { serverSessionService.sweepExpiredSessions(); + // Drop refresh-token families past their absolute lifetime (#1776). + sessionTokenService.sweep(); }, 5 * 60 * 1000); sessionSweepTimer.unref(); diff --git a/backend/server/createApiServer.ts b/backend/server/createApiServer.ts index f262ed54..10c1074a 100644 --- a/backend/server/createApiServer.ts +++ b/backend/server/createApiServer.ts @@ -39,6 +39,7 @@ import { createLoggerFor } from '../services/shared/logging'; import { createTracerFromEnv } from '../services/shared/tracing'; import { createRequestObservability } from '../shared/middleware/requestObservability'; import { createApiKeyRevocationRouter, createPasskeyRouter } from '../services/auth/router/authRouter'; +import { createSessionRouter } from '../services/auth/router/sessionRouter'; import { serveOpenApiSpec, serveSwaggerUI } from '../services/shared/swaggerUI'; // ── Zapier integration ──────────────────────────────────────────────────────── @@ -230,6 +231,11 @@ export function createApiServer(options: CreateApiServerOptions = {}): Express { '/api/v1/stripe/billing/webhook', '/api/v1/paddle/webhook', '/api/v1/webhooks', + // OAuth endpoints for non-browser clients (#1776, #1779). They carry no + // ambient credentials: the refresh token / device code is in the body, + // so a cross-site request cannot ride on a victim's cookies. + '/api/v1/auth/oauth/token', + '/api/v1/auth/oauth/device_authorization', ], }) as unknown as express.RequestHandler, ); @@ -286,6 +292,10 @@ export function createApiServer(options: CreateApiServerOptions = {}): Express { // Batch execution is served by `createSubscriptionOpsRouter()` above, which // already exposes POST /subscriptions/batch and GET /subscriptions/batch/:runId. app.use('/api/v1/auth', createPasskeyRouter()); + // Password fallback, refresh-token rotation, session listing / revocation and + // the OAuth device flow (#1776-#1779). Shares the session + token singletons + // with the passkey router above. + app.use('/api/v1/auth', createSessionRouter()); app.use('/api/v1/api-keys', createApiKeyRevocationRouter()); // ── Zapier integration ───────────────────────────────────────────────────── diff --git a/backend/services/auth/__tests__/DeviceAuthorizationService.test.ts b/backend/services/auth/__tests__/DeviceAuthorizationService.test.ts new file mode 100644 index 00000000..c7bf413d --- /dev/null +++ b/backend/services/auth/__tests__/DeviceAuthorizationService.test.ts @@ -0,0 +1,240 @@ +import { + DeviceAuthorizationService, + OAuthError, + normalizeUserCode, +} from '../domain/DeviceAuthorizationService'; +import { AuthErrorCode } from '../errors'; + +const CLIENTS = { + cli: { + name: 'SubTrackr CLI', + allowedScopes: ['profile:read', 'subscriptions:read', 'subscriptions:write', 'sessions:read'], + defaultScopes: ['profile:read'], + }, + tv: { name: 'TV app', allowedScopes: ['subscriptions:read'] }, + evil: { name: 'Misconfigured', allowedScopes: ['*', 'auth:manage', 'profile:read'] }, +}; + +function oauthError(fn: () => unknown): string | undefined { + try { + fn(); + } catch (err) { + if (err instanceof OAuthError) return err.error; + return (err as { code?: string }).code; + } + return undefined; +} + +describe('DeviceAuthorizationService (RFC 8628)', () => { + let clock: number; + let service: DeviceAuthorizationService; + + beforeEach(() => { + clock = 1_700_000_000_000; + service = new DeviceAuthorizationService( + { clients: CLIENTS, verificationUri: 'https://app.subtrackr.test/device' }, + () => clock + ); + }); + + describe('device authorization request', () => { + it('issues device_code, user_code, verification URIs, expiry and interval', () => { + const res = service.requestAuthorization({ clientId: 'cli', scope: 'subscriptions:read' }); + expect(res.device_code).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(res.user_code).toMatch(/^[BCDFGHJKLMNPQRSTVWXZ]{4}-[BCDFGHJKLMNPQRSTVWXZ]{4}$/); + expect(res.verification_uri).toBe('https://app.subtrackr.test/device'); + expect(res.verification_uri_complete).toBe( + `https://app.subtrackr.test/device?user_code=${res.user_code}` + ); + expect(res.expires_in).toBe(600); + expect(res.interval).toBe(5); + }); + + it('never stores the codes in plaintext', () => { + const res = service.requestAuthorization({ clientId: 'cli' }); + const state = JSON.stringify([ + ...(service as unknown as { grants: Map }).grants.entries(), + ...(service as unknown as { userCodes: Map }).userCodes.entries(), + ]); + expect(state).not.toContain(res.device_code); + expect(state).not.toContain(normalizeUserCode(res.user_code)); + }); + + it('rejects unknown clients and scopes the client may not request', () => { + expect(oauthError(() => service.requestAuthorization({ clientId: 'nope' }))).toBe( + 'invalid_client' + ); + expect( + oauthError(() => service.requestAuthorization({ clientId: 'tv', scope: 'sessions:read' })) + ).toBe('invalid_scope'); + // `*` and auth:manage are never delegable, even if a client is misconfigured. + expect(oauthError(() => service.requestAuthorization({ clientId: 'evil', scope: '*' }))).toBe( + 'invalid_scope' + ); + expect( + oauthError(() => service.requestAuthorization({ clientId: 'evil', scope: 'auth:manage' })) + ).toBe('invalid_scope'); + }); + + it('falls back to the client default scopes', () => { + const res = service.requestAuthorization({ clientId: 'cli' }); + expect(service.describe(res.user_code, 'u1').scopes).toEqual(['profile:read']); + }); + }); + + describe('polling', () => { + it('walks through authorization_pending → slow_down → tokens exactly once', () => { + const res = service.requestAuthorization({ + clientId: 'cli', + scope: 'subscriptions:read sessions:read', + }); + const poll = () => service.poll({ clientId: 'cli', deviceCode: res.device_code }); + + expect(oauthError(poll)).toBe('authorization_pending'); + clock += 1000; + expect(oauthError(poll)).toBe('slow_down'); + expect(service.getInterval(res.device_code)).toBe(10); + clock += 10_000; + expect(oauthError(poll)).toBe('authorization_pending'); + + service.approve({ userCode: res.user_code, approverId: 'u1', approverScopes: ['*'] }); + clock += 10_000; + expect(poll()).toEqual({ + userId: 'u1', + clientId: 'cli', + scopes: ['subscriptions:read', 'sessions:read'], + }); + clock += 10_000; + expect(oauthError(poll)).toBe('invalid_grant'); + }); + + it('returns access_denied when the user denies', () => { + const res = service.requestAuthorization({ clientId: 'cli' }); + service.deny({ userCode: res.user_code, approverId: 'u1' }); + expect(oauthError(() => service.poll({ clientId: 'cli', deviceCode: res.device_code }))).toBe( + 'access_denied' + ); + expect(oauthError(() => service.poll({ clientId: 'cli', deviceCode: res.device_code }))).toBe( + 'invalid_grant' + ); + }); + + it('returns expired_token once the codes lapse, even if approved too late', () => { + const res = service.requestAuthorization({ clientId: 'cli' }); + clock += 10 * 60 * 1000 + 1; + expect( + oauthError(() => + service.approve({ userCode: res.user_code, approverId: 'u1', approverScopes: ['*'] }) + ) + ).toBe(AuthErrorCode.USER_CODE_INVALID); + expect(oauthError(() => service.poll({ clientId: 'cli', deviceCode: res.device_code }))).toBe( + 'expired_token' + ); + }); + + it('rejects device codes presented by another client or unknown codes', () => { + const res = service.requestAuthorization({ clientId: 'cli' }); + expect(oauthError(() => service.poll({ clientId: 'tv', deviceCode: res.device_code }))).toBe( + 'invalid_grant' + ); + expect(oauthError(() => service.poll({ clientId: 'cli', deviceCode: 'guess' }))).toBe( + 'invalid_grant' + ); + expect( + oauthError(() => service.poll({ clientId: 'ghost', deviceCode: res.device_code })) + ).toBe('invalid_client'); + expect(oauthError(() => service.poll({ clientId: 'cli' }))).toBe('invalid_request'); + }); + }); + + describe('approval', () => { + it('accepts the user code case-insensitively and without the dash', () => { + const res = service.requestAuthorization({ clientId: 'cli' }); + const typed = res.user_code.replace('-', ' ').toLowerCase(); + expect(service.describe(typed, 'u1')).toMatchObject({ + clientId: 'cli', + clientName: 'SubTrackr CLI', + }); + }); + + it('makes user codes single-use', () => { + const res = service.requestAuthorization({ clientId: 'cli' }); + service.approve({ userCode: res.user_code, approverId: 'u1', approverScopes: ['*'] }); + expect( + oauthError(() => + service.approve({ userCode: res.user_code, approverId: 'u2', approverScopes: ['*'] }) + ) + ).toBe(AuthErrorCode.USER_CODE_INVALID); + expect(oauthError(() => service.deny({ userCode: res.user_code, approverId: 'u1' }))).toBe( + AuthErrorCode.USER_CODE_INVALID + ); + }); + + it('lets the user narrow the requested scopes but never widen them', () => { + const res = service.requestAuthorization({ + clientId: 'cli', + scope: 'subscriptions:read subscriptions:write', + }); + expect( + oauthError(() => + service.approve({ + userCode: res.user_code, + approverId: 'u1', + approverScopes: ['*'], + scopes: ['subscriptions:read', 'sessions:read'], + }) + ) + ).toBe(AuthErrorCode.USER_CODE_INVALID); + + const approved = service.approve({ + userCode: res.user_code, + approverId: 'u1', + approverScopes: ['*'], + scopes: ['subscriptions:read'], + }); + expect(approved.grantedScopes).toEqual(['subscriptions:read']); + expect(service.poll({ clientId: 'cli', deviceCode: res.device_code }).scopes).toEqual([ + 'subscriptions:read', + ]); + }); + + it('refuses to grant scopes the approver does not hold', () => { + const res = service.requestAuthorization({ clientId: 'cli', scope: 'subscriptions:write' }); + expect( + oauthError(() => + service.approve({ + userCode: res.user_code, + approverId: 'u1', + approverScopes: ['subscriptions:read'], + }) + ) + ).toBe(AuthErrorCode.INSUFFICIENT_SCOPE); + // The failed attempt did not consume the code. + expect(service.describe(res.user_code, 'u1').scopes).toEqual(['subscriptions:write']); + }); + + it('rate limits repeated wrong user codes per approver', () => { + const res = service.requestAuthorization({ clientId: 'cli' }); + for (let i = 0; i < 10; i++) { + expect(oauthError(() => service.describe('BBBB-BBBB', 'brute'))).toBe( + AuthErrorCode.USER_CODE_INVALID + ); + } + expect(oauthError(() => service.describe(res.user_code, 'brute'))).toBe( + AuthErrorCode.USER_CODE_ATTEMPTS_EXCEEDED + ); + // Other approvers are unaffected. + expect(service.describe(res.user_code, 'u1').clientId).toBe('cli'); + clock += 15 * 60 * 1000 + 1; + const fresh = service.requestAuthorization({ clientId: 'cli' }); + expect(service.describe(fresh.user_code, 'brute').clientId).toBe('cli'); + }); + }); + + it('sweeps grants long past expiry', () => { + service.requestAuthorization({ clientId: 'cli' }); + expect(service.sweep()).toBe(0); + clock += 21 * 60 * 1000; + expect(service.sweep()).toBe(1); + }); +}); diff --git a/backend/services/auth/__tests__/PasswordCredentialService.test.ts b/backend/services/auth/__tests__/PasswordCredentialService.test.ts new file mode 100644 index 00000000..7849e947 --- /dev/null +++ b/backend/services/auth/__tests__/PasswordCredentialService.test.ts @@ -0,0 +1,105 @@ +import { PasswordCredentialService } from '../domain/PasswordCredentialService'; +import { AuthErrorCode } from '../errors'; + +// Minimal KDF cost so the suite stays fast; production uses DEFAULT_PASSWORD_POLICY. +const FAST_KDF = { memoryCost: 16, timeCost: 1, parallelism: 1, keyLen: 32 }; +const GOOD = 'correct horse battery staple'; + +function codeOf(fn: () => unknown): string | undefined { + try { + fn(); + } catch (err) { + return (err as { code?: string }).code; + } + return undefined; +} + +describe('PasswordCredentialService', () => { + let clock: number; + let service: PasswordCredentialService; + + beforeEach(() => { + clock = 1_700_000_000_000; + service = new PasswordCredentialService({ kdfParams: FAST_KDF }, () => clock); + }); + + it('stores a hash, not the password, and authenticates with it', () => { + service.setPassword('u1', GOOD); + expect(service.hasPassword('u1')).toBe(true); + expect( + JSON.stringify( + (service as unknown as { passwords: Map }).passwords.get('u1') + ) + ).not.toContain(GOOD); + expect(() => service.authenticate('u1', GOOD)).not.toThrow(); + }); + + it('gives the same error for a wrong password and an unknown user', () => { + service.setPassword('u1', GOOD); + expect(codeOf(() => service.authenticate('u1', 'wrong password!!'))).toBe( + AuthErrorCode.INVALID_CREDENTIALS + ); + expect(codeOf(() => service.authenticate('ghost', GOOD))).toBe( + AuthErrorCode.INVALID_CREDENTIALS + ); + expect(codeOf(() => service.authenticate(undefined, GOOD))).toBe( + AuthErrorCode.INVALID_CREDENTIALS + ); + }); + + it('enforces the password policy', () => { + expect(codeOf(() => service.setPassword('u1', 'short'))).toBe( + AuthErrorCode.PASSWORD_POLICY_VIOLATION + ); + expect(codeOf(() => service.setPassword('u1', 'x'.repeat(257)))).toBe( + AuthErrorCode.PASSWORD_POLICY_VIOLATION + ); + expect(codeOf(() => service.setPassword('u1', 42))).toBe( + AuthErrorCode.PASSWORD_POLICY_VIOLATION + ); + }); + + it('requires the current password to change an existing one', () => { + service.setPassword('u1', GOOD); + expect(codeOf(() => service.setPassword('u1', 'a brand new password'))).toBe( + AuthErrorCode.INVALID_CREDENTIALS + ); + expect( + codeOf(() => service.setPassword('u1', 'a brand new password', 'not the password')) + ).toBe(AuthErrorCode.INVALID_CREDENTIALS); + service.setPassword('u1', 'a brand new password', GOOD); + expect(() => service.authenticate('u1', 'a brand new password')).not.toThrow(); + expect(codeOf(() => service.authenticate('u1', GOOD))).toBe(AuthErrorCode.INVALID_CREDENTIALS); + }); + + it('locks the account after repeated failures, even for the right password', () => { + service.setPassword('u1', GOOD); + for (let i = 0; i < 4; i++) { + expect(codeOf(() => service.authenticate('u1', 'wrong password!!'))).toBe( + AuthErrorCode.INVALID_CREDENTIALS + ); + } + expect(codeOf(() => service.authenticate('u1', 'wrong password!!'))).toBe( + AuthErrorCode.ACCOUNT_LOCKED + ); + expect(codeOf(() => service.authenticate('u1', GOOD))).toBe(AuthErrorCode.ACCOUNT_LOCKED); + + clock += 15 * 60 * 1000 + 1; + expect(() => service.authenticate('u1', GOOD)).not.toThrow(); + }); + + it('forgets old failures outside the window and resets on success', () => { + service.setPassword('u1', GOOD); + for (let i = 0; i < 4; i++) codeOf(() => service.authenticate('u1', 'wrong password!!')); + clock += 16 * 60 * 1000; + expect(codeOf(() => service.authenticate('u1', 'wrong password!!'))).toBe( + AuthErrorCode.INVALID_CREDENTIALS + ); + service.authenticate('u1', GOOD); + for (let i = 0; i < 4; i++) { + expect(codeOf(() => service.authenticate('u1', 'wrong password!!'))).toBe( + AuthErrorCode.INVALID_CREDENTIALS + ); + } + }); +}); diff --git a/backend/services/auth/__tests__/SessionTokenService.test.ts b/backend/services/auth/__tests__/SessionTokenService.test.ts new file mode 100644 index 00000000..00c5f605 --- /dev/null +++ b/backend/services/auth/__tests__/SessionTokenService.test.ts @@ -0,0 +1,290 @@ +import { ServerSessionService } from '../serverSessionService'; +import { SessionTokenService, hashRefreshToken } from '../domain/SessionTokenService'; +import { AuthErrorCode } from '../errors'; +import { eventBus } from '../../shared/events'; + +function req(userAgent = 'jest-device', ip = '10.0.0.1') { + return { + headers: { 'user-agent': userAgent, 'x-forwarded-for': ip }, + socket: {}, + } as never; +} + +function codeOf(fn: () => unknown): string | undefined { + try { + fn(); + } catch (err) { + return (err as { code?: string }).code; + } + return undefined; +} + +describe('SessionTokenService', () => { + let sessions: ServerSessionService; + let tokens: SessionTokenService; + let clock: number; + + beforeEach(() => { + clock = 1_700_000_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => clock); + sessions = new ServerSessionService(); + tokens = new SessionTokenService({ sessions }); + }); + + afterEach(() => { + tokens.dispose(); + jest.restoreAllMocks(); + }); + + describe('issue', () => { + it('creates a session with a refresh-token family and stores only the hash', () => { + const issued = tokens.issue({ userId: 'u1', req: req(), authMethod: 'password' }); + + expect(issued.accessToken).toMatch(/^[a-f0-9]{64}$/); + expect(issued.refreshToken).toMatch(/^rt_[A-Za-z0-9_-]{43}$/); + expect(issued.scopes).toEqual(['*']); + expect(issued.session.metadata).toMatchObject({ authMethod: 'password' }); + expect(issued.family.currentTokenHash).toBe(hashRefreshToken(issued.refreshToken)); + expect(JSON.stringify(issued.family)).not.toContain(issued.refreshToken); + expect(sessions.validateSession(issued.accessToken).valid).toBe(true); + }); + + it('limits device-flow sessions to the granted scopes', () => { + const issued = tokens.issue({ + userId: 'u1', + req: req(), + authMethod: 'device_code', + scopes: ['subscriptions:read'], + clientId: 'subtrackr-cli', + }); + expect(issued.session.scopes).toEqual(['subscriptions:read']); + expect(issued.family.clientId).toBe('subtrackr-cli'); + }); + }); + + describe('rotation on use', () => { + it('returns a new refresh token and a new access token, invalidating the old pair', () => { + const first = tokens.issue({ userId: 'u1', req: req(), authMethod: 'password' }); + clock += 60_000; + const second = tokens.refresh(first.refreshToken, req()); + + expect(second.refreshToken).not.toBe(first.refreshToken); + expect(second.accessToken).not.toBe(first.accessToken); + expect(second.session.id).toBe(first.session.id); + expect(second.family.generation).toBe(2); + expect(sessions.validateSession(first.accessToken).valid).toBe(false); + expect(sessions.validateSession(second.accessToken).valid).toBe(true); + + const third = tokens.refresh(second.refreshToken, req()); + expect(third.family.generation).toBe(3); + }); + + it('revives a session whose access token expired while the refresh token is valid', () => { + const first = tokens.issue({ userId: 'u1', req: req(), authMethod: 'password' }); + clock += 2 * 60 * 60 * 1000; // access TTL is 30 min + expect(sessions.validateSession(first.accessToken).valid).toBe(false); + + const next = tokens.refresh(first.refreshToken, req()); + expect(sessions.validateSession(next.accessToken).valid).toBe(true); + expect(next.expiresIn).toBe(30 * 60); + }); + + it('preserves scopes across rotation', () => { + const first = tokens.issue({ + userId: 'u1', + req: req(), + authMethod: 'device_code', + scopes: ['sessions:read'], + }); + expect(tokens.refresh(first.refreshToken, req()).scopes).toEqual(['sessions:read']); + }); + + it('refuses rotation from a different device without consuming the token', () => { + const first = tokens.issue({ userId: 'u1', req: req('laptop'), authMethod: 'password' }); + expect(codeOf(() => tokens.refresh(first.refreshToken, req('attacker')))).toBe( + AuthErrorCode.REFRESH_TOKEN_INVALID + ); + // The legitimate device can still rotate. + expect(tokens.refresh(first.refreshToken, req('laptop')).family.generation).toBe(2); + }); + }); + + describe('reuse detection', () => { + it('revokes the whole family and the session when a rotated token is replayed', async () => { + const published: string[] = []; + jest.spyOn(eventBus, 'publish').mockImplementation(async (event) => { + published.push(event.name); + }); + + const first = tokens.issue({ userId: 'u1', req: req(), authMethod: 'passkey' }); + const second = tokens.refresh(first.refreshToken, req()); + + // Attacker (or a confused client) replays the already-rotated token. + expect(codeOf(() => tokens.refresh(first.refreshToken, req()))).toBe( + AuthErrorCode.REFRESH_TOKEN_REUSED + ); + + // Everything in the family is dead, including the newest tokens. + expect(sessions.validateSession(second.accessToken).reason).toBe('session_revoked'); + expect(codeOf(() => tokens.refresh(second.refreshToken, req()))).toBe( + AuthErrorCode.REFRESH_TOKEN_INVALID + ); + expect(tokens.getFamilyForSession(first.session.id)).toMatchObject({ + status: 'revoked', + revokedReason: 'reuse_detected', + }); + expect(tokens.getAuditLog({ userId: 'u1' }).map((e) => e.action)).toEqual( + expect.arrayContaining(['issued', 'rotated', 'reuse_detected', 'revoked']) + ); + expect(published).toContain('auth.refresh_token_reuse_detected'); + }); + + it('does not affect other sessions of the same user', () => { + const a = tokens.issue({ userId: 'u1', req: req(), authMethod: 'password' }); + const b = tokens.issue({ userId: 'u1', req: req(), authMethod: 'password' }); + tokens.refresh(a.refreshToken, req()); + codeOf(() => tokens.refresh(a.refreshToken, req())); + + expect(sessions.validateSession(b.accessToken).valid).toBe(true); + expect(tokens.refresh(b.refreshToken, req()).family.generation).toBe(2); + }); + + it('keeps the revocation when an event subscriber fails', async () => { + jest.spyOn(eventBus, 'publish').mockRejectedValue(new Error('bus down')); + const first = tokens.issue({ userId: 'u1', req: req(), authMethod: 'password' }); + const second = tokens.refresh(first.refreshToken, req()); + expect(codeOf(() => tokens.refresh(first.refreshToken, req()))).toBe( + AuthErrorCode.REFRESH_TOKEN_REUSED + ); + await Promise.resolve(); + expect(sessions.validateSession(second.accessToken).valid).toBe(false); + }); + }); + + describe('rejection', () => { + it('rejects malformed and unknown tokens', () => { + expect(codeOf(() => tokens.refresh('nope', req()))).toBe(AuthErrorCode.REFRESH_TOKEN_INVALID); + expect(codeOf(() => tokens.refresh('rt_unknown', req()))).toBe( + AuthErrorCode.REFRESH_TOKEN_INVALID + ); + }); + + it('expires idle refresh tokens and caps the family lifetime', () => { + const idle = tokens.issue({ userId: 'u1', req: req(), authMethod: 'password' }); + clock += 8 * 24 * 60 * 60 * 1000; + expect(codeOf(() => tokens.refresh(idle.refreshToken, req()))).toBe( + AuthErrorCode.REFRESH_TOKEN_INVALID + ); + + const active = tokens.issue({ userId: 'u2', req: req(), authMethod: 'password' }); + let current = active.refreshToken; + for (let day = 0; day < 29; day++) { + clock += 24 * 60 * 60 * 1000; + current = tokens.refresh(current, req()).refreshToken; + } + clock += 2 * 24 * 60 * 60 * 1000; // past the 30-day absolute cap + expect(codeOf(() => tokens.refresh(current, req()))).toBe( + AuthErrorCode.REFRESH_TOKEN_INVALID + ); + }); + + it('rejects the refresh token once the session is revoked by any path', () => { + const viaLogout = tokens.issue({ userId: 'u1', req: req(), authMethod: 'password' }); + sessions.revokeSession(viaLogout.accessToken, 'user_logout'); + expect(codeOf(() => tokens.refresh(viaLogout.refreshToken, req()))).toBe( + AuthErrorCode.REFRESH_TOKEN_INVALID + ); + + const viaRevokeAll = tokens.issue({ userId: 'u2', req: req(), authMethod: 'password' }); + sessions.revokeAllSessions('u2'); + expect(codeOf(() => tokens.refresh(viaRevokeAll.refreshToken, req()))).toBe( + AuthErrorCode.REFRESH_TOKEN_INVALID + ); + }); + + it('revokes an idle session whose access token already expired', () => { + const issued = tokens.issue({ userId: 'u1', req: req(), authMethod: 'password' }); + clock += 60 * 60 * 1000; + sessions.validateSession(issued.accessToken); // marks the access token expired + expect(tokens.revokeSession('u1', issued.session.id)).toBe(true); + expect(codeOf(() => tokens.refresh(issued.refreshToken, req()))).toBe( + AuthErrorCode.REFRESH_TOKEN_INVALID + ); + }); + }); + + describe('listing and revocation', () => { + it('lists exactly the sessions that can still be used, newest first', () => { + const a = tokens.issue({ userId: 'u1', req: req('phone'), authMethod: 'passkey' }); + clock += 1000; + const b = tokens.issue({ + userId: 'u1', + req: req('cli'), + authMethod: 'device_code', + scopes: ['sessions:read'], + clientId: 'subtrackr-cli', + }); + clock += 1000; + const c = tokens.issue({ userId: 'u1', req: req('laptop'), authMethod: 'password' }); + tokens.issue({ userId: 'someone-else', req: req(), authMethod: 'password' }); + tokens.revokeSession('u1', c.session.id); + + const list = tokens.listSessions('u1', a.session.id); + expect(list.map((s) => s.id)).toEqual([b.session.id, a.session.id]); + expect(list[0]).toMatchObject({ + authMethod: 'device_code', + clientId: 'subtrackr-cli', + scopes: ['sessions:read'], + current: false, + }); + expect(list[1]).toMatchObject({ authMethod: 'passkey', current: true }); + expect(JSON.stringify(list)).not.toContain(a.accessToken); + }); + + it('keeps listing a session whose access token lapsed but whose refresh token is live', () => { + const issued = tokens.issue({ userId: 'u1', req: req(), authMethod: 'password' }); + clock += 60 * 60 * 1000; + expect(tokens.listSessions('u1').map((s) => s.id)).toEqual([issued.session.id]); + clock += 8 * 24 * 60 * 60 * 1000; + expect(tokens.listSessions('u1')).toEqual([]); + }); + + it("does not let a user revoke someone else's session", () => { + const victim = tokens.issue({ userId: 'victim', req: req(), authMethod: 'password' }); + expect(tokens.revokeSession('attacker', victim.session.id)).toBe(false); + expect(sessions.validateSession(victim.accessToken).valid).toBe(true); + }); + + it('revokes every other session', () => { + const keep = tokens.issue({ userId: 'u1', req: req(), authMethod: 'password' }); + const other1 = tokens.issue({ userId: 'u1', req: req(), authMethod: 'password' }); + const other2 = tokens.issue({ userId: 'u1', req: req(), authMethod: 'passkey' }); + expect(tokens.revokeOtherSessions('u1', keep.session.id)).toBe(2); + expect(sessions.validateSession(keep.accessToken).valid).toBe(true); + expect(sessions.validateSession(other1.accessToken).valid).toBe(false); + expect(codeOf(() => tokens.refresh(other2.refreshToken, req()))).toBe( + AuthErrorCode.REFRESH_TOKEN_INVALID + ); + }); + + it('revokes the family of a session evicted by the concurrent-session limit', () => { + const limited = new ServerSessionService({ maxConcurrentSessions: 1 }); + const limitedTokens = new SessionTokenService({ sessions: limited }); + const first = limitedTokens.issue({ userId: 'u1', req: req(), authMethod: 'password' }); + limitedTokens.issue({ userId: 'u1', req: req(), authMethod: 'password' }); + expect(codeOf(() => limitedTokens.refresh(first.refreshToken, req()))).toBe( + AuthErrorCode.REFRESH_TOKEN_INVALID + ); + limitedTokens.dispose(); + }); + }); + + it('sweeps dead families after their absolute lifetime', () => { + const issued = tokens.issue({ userId: 'u1', req: req(), authMethod: 'password' }); + tokens.revokeSession('u1', issued.session.id); + expect(tokens.sweep()).toBe(0); + clock += 31 * 24 * 60 * 60 * 1000; + expect(tokens.sweep()).toBe(1); + expect(tokens.getFamilyForSession(issued.session.id)).toBeUndefined(); + }); +}); diff --git a/backend/services/auth/__tests__/authRouter.test.ts b/backend/services/auth/__tests__/authRouter.test.ts index 454d3a67..ab62c99f 100644 --- a/backend/services/auth/__tests__/authRouter.test.ts +++ b/backend/services/auth/__tests__/authRouter.test.ts @@ -70,9 +70,11 @@ describe('auth routers (HTTP)', () => { describe('passkeys', () => { it('registers a passkey for a signed-in user and logs in with it', async () => { + // fetch() sends `accept-language: *`, which is part of the device + // fingerprint sessions are bound to (#1766). const bootstrap = sessions.createSession({ userId: 'user-1', - req: { headers: { 'user-agent': 'jest' }, socket: {} } as never, + req: { headers: { 'user-agent': 'jest', 'accept-language': '*' }, socket: {} } as never, }); const auth = { 'x-session-token': bootstrap.token, 'user-agent': 'jest' }; const authenticator = new SoftwareAuthenticator('ES256'); diff --git a/backend/services/auth/__tests__/sessionRouter.test.ts b/backend/services/auth/__tests__/sessionRouter.test.ts new file mode 100644 index 00000000..e12ca327 --- /dev/null +++ b/backend/services/auth/__tests__/sessionRouter.test.ts @@ -0,0 +1,525 @@ +import express from 'express'; +import type { AddressInfo } from 'net'; +import type { Server } from 'http'; +import { ServerSessionService } from '../serverSessionService'; +import { SessionTokenService } from '../domain/SessionTokenService'; +import { PasswordCredentialService } from '../domain/PasswordCredentialService'; +import { + DeviceAuthorizationService, + DEVICE_CODE_GRANT_TYPE, +} from '../domain/DeviceAuthorizationService'; +import { PasskeyService } from '../domain/PasskeyService'; +import { PasskeyController } from '../controller/passkeyController'; +import { createPasskeyRouter } from '../router/authRouter'; +import { createSessionRouter } from '../router/sessionRouter'; +import { SoftwareAuthenticator } from './helpers/softwareAuthenticator'; + +const ORIGIN = 'http://localhost:8081'; +const PASSWORD = 'correct horse battery staple'; +// Every request comes from the same "device" so session device binding holds. +const DEVICE_HEADERS = { 'user-agent': 'jest-http', 'accept-language': 'en' }; + +describe('session, token and device-flow routes (HTTP)', () => { + let server: Server; + let baseUrl: string; + let sessions: ServerSessionService; + let tokens: SessionTokenService; + let passwords: PasswordCredentialService; + let deviceFlow: DeviceAuthorizationService; + let deviceClock: number; + + beforeEach(async () => { + sessions = new ServerSessionService(); + tokens = new SessionTokenService({ sessions }); + passwords = new PasswordCredentialService({ + kdfParams: { memoryCost: 16, timeCost: 1, parallelism: 1, keyLen: 32 }, + }); + deviceClock = Date.now(); + deviceFlow = new DeviceAuthorizationService( + { + verificationUri: 'https://app.subtrackr.test/device', + clients: { + 'subtrackr-cli': { + name: 'SubTrackr CLI', + allowedScopes: ['profile:read', 'subscriptions:read', 'sessions:read'], + }, + }, + }, + () => deviceClock + ); + + const app = express(); + app.use(express.json()); + app.use( + '/api/v1/auth', + createPasskeyRouter({ + controller: new PasskeyController( + new PasskeyService({ rpId: 'localhost', origins: [ORIGIN] }) + ), + tokens, + }) + ); + app.use('/api/v1/auth', createSessionRouter({ tokens, passwords, deviceFlow })); + + server = await new Promise((resolve) => { + const s = app.listen(0, () => resolve(s)); + }); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterEach(async () => { + tokens.dispose(); + await new Promise((resolve) => server.close(resolve)); + }); + + async function call( + method: string, + path: string, + body?: unknown, + headers: Record = {} + ) { + const res = await fetch(`${baseUrl}${path}`, { + method, + headers: { 'content-type': 'application/json', ...DEVICE_HEADERS, ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { status: res.status, headers: res.headers, body: (await res.json()) as any }; + } + + /** RFC 6749 clients POST application/x-www-form-urlencoded. */ + async function form(path: string, fields: Record) { + const res = await fetch(`${baseUrl}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', ...DEVICE_HEADERS }, + body: new URLSearchParams(fields).toString(), + }); + return { status: res.status, headers: res.headers, body: (await res.json()) as any }; + } + + const bearer = (token: string) => ({ authorization: `Bearer ${token}` }); + + /** An interactive session for `userId`, as if they had already signed in. */ + function signedIn(userId: string) { + return tokens.issue({ + userId, + req: { headers: DEVICE_HEADERS, socket: {} } as never, + authMethod: 'password', + }); + } + + async function passwordLogin(userId: string, password = PASSWORD) { + return call('POST', '/api/v1/auth/login/password', { userId, password }); + } + + describe('password fallback (#1778)', () => { + it('falls back to the password when passkey sign-in fails and issues the same token pair', async () => { + const bootstrap = signedIn('ada'); + const set = await call( + 'PUT', + '/api/v1/auth/password', + { newPassword: PASSWORD }, + bearer(bootstrap.accessToken) + ); + expect(set.status).toBe(200); + + // Passkey attempt with an authenticator the server does not know. + const options = await call('POST', '/api/v1/auth/passkeys/login/options', {}); + const passkey = await call('POST', '/api/v1/auth/passkeys/login/verify', { + response: new SoftwareAuthenticator('ES256').assert(options.body.data), + }); + expect(passkey.status).toBe(404); + expect(passkey.headers.get('x-session-token')).toBeNull(); + + // Fallback: password. + const login = await passwordLogin('ada'); + expect(login.status).toBe(200); + expect(login.headers.get('cache-control')).toBe('no-store'); + expect(login.headers.get('x-session-token')).toMatch(/^[a-f0-9]{64}$/); + expect(login.headers.get('x-refresh-token')).toMatch(/^rt_/); + expect(login.body.data.session.scopes).toEqual(['*']); + expect(JSON.stringify(login.body)).not.toContain(login.headers.get('x-refresh-token')); + + const list = await call( + 'GET', + '/api/v1/auth/sessions', + undefined, + bearer(login.headers.get('x-session-token') as string) + ); + expect(list.body.data.find((s: any) => s.current)).toMatchObject({ authMethod: 'password' }); + }); + + it('rejects wrong passwords and unknown users identically, then locks out', async () => { + passwords.setPassword('ada', PASSWORD); + const wrong = await passwordLogin('ada', 'not the password'); + const unknown = await passwordLogin('nobody'); + expect(wrong.status).toBe(401); + expect(unknown.status).toBe(401); + expect(wrong.body.error).toEqual(unknown.body.error); + expect(wrong.headers.get('x-session-token')).toBeNull(); + + for (let i = 0; i < 3; i++) await passwordLogin('ada', 'not the password'); + const locked = await passwordLogin('ada', 'not the password'); + expect(locked.status).toBe(429); + expect(locked.body.error.code).toBe('AUTH_ACCOUNT_LOCKED'); + expect(Number(locked.headers.get('retry-after'))).toBeGreaterThan(0); + }); + + it('requires the auth:manage scope to set a password', async () => { + const unauthenticated = await call('PUT', '/api/v1/auth/password', { newPassword: PASSWORD }); + expect(unauthenticated.status).toBe(401); + expect(unauthenticated.headers.get('www-authenticate')).toContain('invalid_token'); + }); + }); + + describe('passkey login issues a rotating refresh token (#1776 + #1778)', () => { + it('registers a passkey, logs in with it and refreshes the session', async () => { + const bootstrap = signedIn('grace'); + const auth = { 'x-session-token': bootstrap.accessToken }; + const authenticator = new SoftwareAuthenticator('ES256'); + const regOptions = await call( + 'POST', + '/api/v1/auth/passkeys/register/options', + { userName: 'grace' }, + auth + ); + await call( + 'POST', + '/api/v1/auth/passkeys/register/verify', + { response: authenticator.register(regOptions.body.data) }, + auth + ); + + const options = await call('POST', '/api/v1/auth/passkeys/login/options', {}); + const login = await call('POST', '/api/v1/auth/passkeys/login/verify', { + response: authenticator.assert(options.body.data), + }); + expect(login.status).toBe(200); + const refreshToken = login.headers.get('x-refresh-token') as string; + expect(refreshToken).toMatch(/^rt_/); + expect(login.body.data.session.refreshTokenExpiresAt).toBeGreaterThan(Date.now()); + + const refreshed = await form('/api/v1/auth/oauth/token', { + grant_type: 'refresh_token', + refresh_token: refreshToken, + }); + expect(refreshed.status).toBe(200); + expect(refreshed.body.refresh_token).not.toBe(refreshToken); + expect(refreshed.body.access_token).not.toBe(login.headers.get('x-session-token')); + }); + }); + + describe('refresh-token rotation over HTTP (#1776)', () => { + it('rotates on use and revokes the session when an old refresh token is replayed', async () => { + passwords.setPassword('ada', PASSWORD); + const login = await passwordLogin('ada'); + const access1 = login.headers.get('x-session-token') as string; + const refresh1 = login.headers.get('x-refresh-token') as string; + + const rotated = await form('/api/v1/auth/oauth/token', { + grant_type: 'refresh_token', + refresh_token: refresh1, + }); + expect(rotated.status).toBe(200); + expect(rotated.headers.get('cache-control')).toBe('no-store'); + expect(rotated.body).toMatchObject({ token_type: 'Bearer', scope: '*' }); + expect(rotated.body.expires_in).toBeGreaterThan(0); + const { access_token: access2, refresh_token: refresh2 } = rotated.body; + + // The previous access token is gone as soon as it is rotated. + expect((await call('GET', '/api/v1/auth/sessions', undefined, bearer(access1))).status).toBe( + 401 + ); + expect((await call('GET', '/api/v1/auth/sessions', undefined, bearer(access2))).status).toBe( + 200 + ); + + // Replay of the rotated refresh token → reuse detected → whole family revoked. + const replay = await form('/api/v1/auth/oauth/token', { + grant_type: 'refresh_token', + refresh_token: refresh1, + }); + expect(replay.status).toBe(400); + expect(replay.body.error).toBe('invalid_grant'); + + expect((await call('GET', '/api/v1/auth/sessions', undefined, bearer(access2))).status).toBe( + 401 + ); + const afterReuse = await form('/api/v1/auth/oauth/token', { + grant_type: 'refresh_token', + refresh_token: refresh2, + }); + expect(afterReuse.body.error).toBe('invalid_grant'); + }); + + it('validates token endpoint requests', async () => { + expect((await form('/api/v1/auth/oauth/token', {})).body.error).toBe('invalid_request'); + expect((await form('/api/v1/auth/oauth/token', { grant_type: 'password' })).body.error).toBe( + 'unsupported_grant_type' + ); + expect( + (await form('/api/v1/auth/oauth/token', { grant_type: 'refresh_token' })).body.error + ).toBe('invalid_request'); + const json = await call('POST', '/api/v1/auth/oauth/token', { + grant_type: 'refresh_token', + refresh_token: 'rt_bogus', + }); + expect(json.status).toBe(400); + expect(json.body.error).toBe('invalid_grant'); + }); + }); + + describe('session listing and remote revocation (#1777)', () => { + it('lists live sessions and revokes another one with immediate effect', async () => { + passwords.setPassword('ada', PASSWORD); + const phone = await passwordLogin('ada'); + const laptop = await passwordLogin('ada'); + const phoneAccess = phone.headers.get('x-session-token') as string; + const laptopAccess = laptop.headers.get('x-session-token') as string; + signedIn('someone-else'); + + const list = await call('GET', '/api/v1/auth/sessions', undefined, bearer(laptopAccess)); + expect(list.status).toBe(200); + expect(list.body.data).toHaveLength(2); + const current = list.body.data.find((s: any) => s.current); + const other = list.body.data.find((s: any) => !s.current); + expect(current.id).toBe(laptop.body.data.session.id); + expect(other.id).toBe(phone.body.data.session.id); + expect(JSON.stringify(list.body)).not.toContain(phoneAccess); + + // Phone still works before revocation … + expect( + (await call('GET', '/api/v1/auth/sessions', undefined, bearer(phoneAccess))).status + ).toBe(200); + + const revoke = await call( + 'DELETE', + `/api/v1/auth/sessions/${other.id}`, + undefined, + bearer(laptopAccess) + ); + expect(revoke.status).toBe(200); + expect(revoke.body.data).toMatchObject({ revoked: true, current: false }); + + // … and is rejected on the very next request, refresh token included. + expect( + (await call('GET', '/api/v1/auth/sessions', undefined, bearer(phoneAccess))).status + ).toBe(401); + const refresh = await form('/api/v1/auth/oauth/token', { + grant_type: 'refresh_token', + refresh_token: phone.headers.get('x-refresh-token') as string, + }); + expect(refresh.body.error).toBe('invalid_grant'); + + const after = await call('GET', '/api/v1/auth/sessions', undefined, bearer(laptopAccess)); + expect(after.body.data.map((s: any) => s.id)).toEqual([current.id]); + }); + + it("returns 404 for another user's session and leaves it intact", async () => { + const mine = signedIn('ada'); + const theirs = signedIn('mallory-victim'); + const res = await call( + 'DELETE', + `/api/v1/auth/sessions/${theirs.session.id}`, + undefined, + bearer(mine.accessToken) + ); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe('AUTH_SESSION_NOT_FOUND'); + expect(sessions.validateSession(theirs.accessToken).valid).toBe(true); + }); + + it('signs out other sessions and logs out the current one', async () => { + const a = signedIn('ada'); + const b = signedIn('ada'); + const c = signedIn('ada'); + const others = await call( + 'POST', + '/api/v1/auth/sessions/revoke-others', + {}, + bearer(a.accessToken) + ); + expect(others.body.data.revoked).toBe(2); + expect(sessions.validateSession(b.accessToken).valid).toBe(false); + expect(sessions.validateSession(c.accessToken).valid).toBe(false); + + const logout = await call( + 'POST', + '/api/v1/auth/logout', + {}, + { 'x-session-token': a.accessToken } + ); + expect(logout.status).toBe(200); + expect( + (await call('GET', '/api/v1/auth/sessions', undefined, bearer(a.accessToken))).status + ).toBe(401); + }); + }); + + describe('OAuth device authorization flow (#1779)', () => { + async function startDeviceFlow(scope = 'sessions:read subscriptions:read') { + const res = await form('/api/v1/auth/oauth/device_authorization', { + client_id: 'subtrackr-cli', + scope, + }); + expect(res.status).toBe(200); + return res.body as { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; + }; + } + + const pollToken = (deviceCode: string) => + form('/api/v1/auth/oauth/token', { + grant_type: DEVICE_CODE_GRANT_TYPE, + device_code: deviceCode, + client_id: 'subtrackr-cli', + }); + + it('runs the full flow and limits the issued token to the approved scopes', async () => { + const device = await startDeviceFlow(); + expect(device).toMatchObject({ + verification_uri: 'https://app.subtrackr.test/device', + expires_in: 600, + interval: 5, + }); + + const pending = await pollToken(device.device_code); + expect(pending.status).toBe(400); + expect(pending.body.error).toBe('authorization_pending'); + + deviceClock += 1000; + expect((await pollToken(device.device_code)).body.error).toBe('slow_down'); + + // The user, signed in elsewhere, reviews and approves a narrower grant. + const user = signedIn('ada'); + const review = await call( + 'GET', + `/api/v1/auth/oauth/device?user_code=${encodeURIComponent(device.user_code)}`, + undefined, + bearer(user.accessToken) + ); + expect(review.body.data).toMatchObject({ + clientName: 'SubTrackr CLI', + scopes: ['sessions:read', 'subscriptions:read'], + }); + const approve = await call( + 'POST', + '/api/v1/auth/oauth/device/approve', + { user_code: device.user_code, scope: 'sessions:read' }, + bearer(user.accessToken) + ); + expect(approve.status).toBe(200); + expect(approve.body.data.grantedScopes).toEqual(['sessions:read']); + + deviceClock += 15_000; + const granted = await pollToken(device.device_code); + expect(granted.status).toBe(200); + expect(granted.headers.get('cache-control')).toBe('no-store'); + expect(granted.body).toMatchObject({ token_type: 'Bearer', scope: 'sessions:read' }); + const deviceAccess = granted.body.access_token as string; + + // Device code is single-use. + deviceClock += 15_000; + expect((await pollToken(device.device_code)).body.error).toBe('invalid_grant'); + + // Granted scope works … + const list = await call('GET', '/api/v1/auth/sessions', undefined, bearer(deviceAccess)); + expect(list.status).toBe(200); + expect(list.body.data.find((s: any) => s.current)).toMatchObject({ + authMethod: 'device_code', + clientId: 'subtrackr-cli', + scopes: ['sessions:read'], + }); + + // … everything else is refused with 403 insufficient_scope. + const revoke = await call( + 'DELETE', + `/api/v1/auth/sessions/${user.session.id}`, + undefined, + bearer(deviceAccess) + ); + expect(revoke.status).toBe(403); + expect(revoke.body.error.code).toBe('AUTH_INSUFFICIENT_SCOPE'); + expect(revoke.headers.get('www-authenticate')).toContain('insufficient_scope'); + + const passkeys = await call('GET', '/api/v1/auth/passkeys', undefined, { + 'x-session-token': deviceAccess, + }); + expect(passkeys.status).toBe(403); + + const secondDevice = await startDeviceFlow('profile:read'); + const chained = await call( + 'POST', + '/api/v1/auth/oauth/device/approve', + { user_code: secondDevice.user_code }, + bearer(deviceAccess) + ); + expect(chained.status).toBe(403); + + // Refreshing a device token keeps the narrowed scope. + const refreshed = await form('/api/v1/auth/oauth/token', { + grant_type: 'refresh_token', + refresh_token: granted.body.refresh_token, + }); + expect(refreshed.body.scope).toBe('sessions:read'); + + // The owner sees the device session and can revoke it remotely. + const owner = await call('GET', '/api/v1/auth/sessions', undefined, bearer(user.accessToken)); + const cli = owner.body.data.find((s: any) => s.clientId === 'subtrackr-cli'); + await call('DELETE', `/api/v1/auth/sessions/${cli.id}`, undefined, bearer(user.accessToken)); + const afterRevoke = await call( + 'GET', + '/api/v1/auth/sessions', + undefined, + bearer(refreshed.body.access_token) + ); + expect(afterRevoke.status).toBe(401); + }); + + it('returns access_denied after the user denies', async () => { + const device = await startDeviceFlow(); + const user = signedIn('ada'); + const deny = await call( + 'POST', + '/api/v1/auth/oauth/device/deny', + { user_code: device.user_code }, + bearer(user.accessToken) + ); + expect(deny.status).toBe(200); + const res = await pollToken(device.device_code); + expect(res.status).toBe(400); + expect(res.body.error).toBe('access_denied'); + }); + + it('returns expired_token once the device code lapses', async () => { + const device = await startDeviceFlow(); + deviceClock += 11 * 60 * 1000; + expect((await pollToken(device.device_code)).body.error).toBe('expired_token'); + }); + + it('rejects unknown clients and disallowed scopes', async () => { + const unknown = await form('/api/v1/auth/oauth/device_authorization', { client_id: 'nope' }); + expect(unknown.status).toBe(401); + expect(unknown.body.error).toBe('invalid_client'); + + const scope = await form('/api/v1/auth/oauth/device_authorization', { + client_id: 'subtrackr-cli', + scope: 'sessions:write', + }); + expect(scope.status).toBe(400); + expect(scope.body.error).toBe('invalid_scope'); + }); + + it('requires a signed-in user to approve', async () => { + const device = await startDeviceFlow(); + const res = await call('POST', '/api/v1/auth/oauth/device/approve', { + user_code: device.user_code, + }); + expect(res.status).toBe(401); + }); + }); +}); diff --git a/backend/services/auth/domain/DeviceAuthorizationService.ts b/backend/services/auth/domain/DeviceAuthorizationService.ts new file mode 100644 index 00000000..a27c5cbd --- /dev/null +++ b/backend/services/auth/domain/DeviceAuthorizationService.ts @@ -0,0 +1,421 @@ +/** + * OAuth 2.0 Device Authorization Grant (RFC 8628) — SubTrackr, issue #1779 + * + * Lets input-constrained clients (the CLI, TVs, kiosks) sign a user in: + * + * 1. The device calls the device authorization endpoint and receives a + * `device_code` (secret, kept by the device), a short `user_code` and a + * `verification_uri`. + * 2. The user opens the URI on a device where they are already signed in, + * enters the user code, reviews the requested scopes and approves or + * denies. + * 3. Meanwhile the device polls the token endpoint with the device code and + * gets `authorization_pending` / `slow_down` until the user decides, then + * either tokens or `access_denied`, or `expired_token` after the codes + * lapse. + * + * Security properties: + * - Device and user codes are stored only as HMAC-SHA256 digests keyed with + * a per-process secret, never in plaintext. + * - User codes are single-use: the first approve/deny consumes them. Device + * codes are single-use: the first successful token response consumes them. + * - Codes expire (default 10 minutes); polling faster than the interval + * returns `slow_down` and widens the interval by 5 s as RFC 8628 §3.5 + * requires. + * - Requested scopes must be registered for the client; the approver can + * only grant scopes they hold themselves and may narrow the request; the + * issued session is limited to exactly the granted scopes. + * - Repeated wrong user codes from the same approver are rate limited. + */ + +import { createHmac, randomBytes, randomInt } from 'crypto'; +import { AuthError } from '../errors'; +import { hasScope } from './ApiKeyRotationService'; + +/** RFC 8628 §6.1: no vowels (no accidental words), no easily confused characters. */ +const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXZ'; +const DEVICE_CODE_BYTES = 32; +export const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'; + +export interface DeviceClient { + name: string; + /** Scopes this client may request. `*` and `auth:manage` are never grantable. */ + allowedScopes: string[]; + /** Requested when the client omits `scope`. Defaults to `allowedScopes`. */ + defaultScopes?: string[]; +} + +export interface DeviceFlowConfig { + verificationUri: string; + codeTtlMs: number; + /** Minimum polling interval in seconds (RFC 8628 default: 5). */ + pollIntervalSeconds: number; + userCodeLength: number; + clients: Record; + /** Wrong user codes an approver may enter per window before being blocked. */ + maxUserCodeFailures: number; + userCodeFailureWindowMs: number; +} + +/** Scopes that a device can never obtain, whatever the client registration says. */ +const NON_DELEGABLE_SCOPES = new Set(['*', 'auth:manage']); + +function parseClients(raw: string | undefined): Record | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Record; + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +} + +export const DEFAULT_DEVICE_FLOW_CONFIG: DeviceFlowConfig = { + verificationUri: process.env['DEVICE_FLOW_VERIFICATION_URI'] ?? 'http://localhost:8081/device', + codeTtlMs: 10 * 60 * 1000, + pollIntervalSeconds: 5, + userCodeLength: 8, + clients: parseClients(process.env['DEVICE_FLOW_CLIENTS']) ?? { + 'subtrackr-cli': { + name: 'SubTrackr CLI', + allowedScopes: ['profile:read', 'subscriptions:read', 'subscriptions:write', 'sessions:read'], + defaultScopes: ['profile:read', 'subscriptions:read'], + }, + }, + maxUserCodeFailures: 10, + userCodeFailureWindowMs: 15 * 60 * 1000, +}; + +export type DeviceGrantStatus = 'pending' | 'approved' | 'denied' | 'consumed'; + +interface DeviceGrant { + deviceCodeHash: string; + userCodeHash: string | null; // null once the user code has been used + clientId: string; + requestedScopes: string[]; + grantedScopes: string[]; + status: DeviceGrantStatus; + userId: string | null; + createdAt: number; + expiresAt: number; + intervalSeconds: number; + lastPolledAt: number | null; +} + +export interface DeviceAuthorizationResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; +} + +export interface PendingDeviceRequest { + clientId: string; + clientName: string; + scopes: string[]; + expiresAt: number; +} + +export type OAuthErrorCode = + | 'invalid_request' + | 'invalid_client' + | 'invalid_grant' + | 'invalid_scope' + | 'unsupported_grant_type' + | 'authorization_pending' + | 'slow_down' + | 'access_denied' + | 'expired_token'; + +/** An RFC 6749 §5.2 / RFC 8628 §3.5 error for the OAuth endpoints. */ +export class OAuthError extends Error { + constructor( + readonly error: OAuthErrorCode, + readonly description: string, + readonly status: number = error === 'invalid_client' ? 401 : 400 + ) { + super(description); + this.name = 'OAuthError'; + } + + toJSON(): { error: OAuthErrorCode; error_description: string } { + return { error: this.error, error_description: this.description }; + } +} + +export interface ApprovedDeviceGrant { + userId: string; + clientId: string; + scopes: string[]; +} + +export function parseScopeParam(scope: unknown): string[] | null { + if (scope === undefined || scope === null || scope === '') return null; + if (typeof scope !== 'string') return []; + return [...new Set(scope.split(' ').filter(Boolean))]; +} + +export class DeviceAuthorizationService { + private readonly config: DeviceFlowConfig; + private readonly now: () => number; + private readonly hmacKey = randomBytes(32); + private grants = new Map(); // device code hash → grant + private userCodes = new Map(); // user code hash → device code hash + private failures = new Map(); // approver → failures + + constructor(config: Partial = {}, now: () => number = Date.now) { + this.config = { ...DEFAULT_DEVICE_FLOW_CONFIG, ...config }; + this.now = now; + } + + // ── Step 1: device authorization request (RFC 8628 §3.1-3.2) ───────────── + + requestAuthorization(input: { + clientId?: unknown; + scope?: unknown; + }): DeviceAuthorizationResponse { + const clientId = typeof input.clientId === 'string' ? input.clientId : ''; + const client = clientId ? this.config.clients[clientId] : undefined; + if (!client) throw new OAuthError('invalid_client', 'Unknown client_id'); + + const requested = parseScopeParam(input.scope) ?? client.defaultScopes ?? client.allowedScopes; + if (requested.length === 0) throw new OAuthError('invalid_scope', 'scope must not be empty'); + for (const scope of requested) { + if (NON_DELEGABLE_SCOPES.has(scope) || !client.allowedScopes.includes(scope)) { + throw new OAuthError('invalid_scope', `Scope not allowed for this client: ${scope}`); + } + } + + this.sweep(); + const now = this.now(); + const deviceCode = randomBytes(DEVICE_CODE_BYTES).toString('base64url'); + const deviceCodeHash = this.digest(`device:${deviceCode}`); + let userCode = this.generateUserCode(); + let userCodeHash = this.digest(`user:${normalizeUserCode(userCode)}`); + while (this.userCodes.has(userCodeHash)) { + userCode = this.generateUserCode(); + userCodeHash = this.digest(`user:${normalizeUserCode(userCode)}`); + } + + this.grants.set(deviceCodeHash, { + deviceCodeHash, + userCodeHash, + clientId, + requestedScopes: requested, + grantedScopes: [], + status: 'pending', + userId: null, + createdAt: now, + expiresAt: now + this.config.codeTtlMs, + intervalSeconds: this.config.pollIntervalSeconds, + lastPolledAt: null, + }); + this.userCodes.set(userCodeHash, deviceCodeHash); + + const separator = this.config.verificationUri.includes('?') ? '&' : '?'; + return { + device_code: deviceCode, + user_code: userCode, + verification_uri: this.config.verificationUri, + verification_uri_complete: `${this.config.verificationUri}${separator}user_code=${encodeURIComponent(userCode)}`, + expires_in: Math.floor(this.config.codeTtlMs / 1000), + interval: this.config.pollIntervalSeconds, + }; + } + + // ── Step 2: user review / approval (RFC 8628 §3.3) ──────────────────────── + + /** What the approval screen shows. Does not consume the code. */ + describe(userCode: unknown, approverId: string): PendingDeviceRequest { + const grant = this.findPendingByUserCode(userCode, approverId); + const client = this.config.clients[grant.clientId]; + return { + clientId: grant.clientId, + clientName: client?.name ?? grant.clientId, + scopes: [...grant.requestedScopes], + expiresAt: grant.expiresAt, + }; + } + + /** + * Approve the request identified by `userCode` on behalf of `approverId`. + * `scopes` optionally narrows the requested scopes; every granted scope + * must be one the approver's own session holds. + */ + approve(input: { + userCode: unknown; + approverId: string; + approverScopes: string[]; + scopes?: string[]; + }): PendingDeviceRequest & { grantedScopes: string[] } { + const grant = this.findPendingByUserCode(input.userCode, input.approverId); + + const granted = input.scopes + ? [...new Set(input.scopes)].filter((scope) => grant.requestedScopes.includes(scope)) + : [...grant.requestedScopes]; + if (input.scopes && granted.length !== new Set(input.scopes).size) { + throw AuthError.userCodeInvalid('scopes must be a subset of the requested scopes'); + } + if (granted.length === 0) throw AuthError.userCodeInvalid('at least one scope must be granted'); + for (const scope of granted) { + if (!hasScope(input.approverScopes, scope)) throw AuthError.insufficientScope(scope); + } + + this.consumeUserCode(grant); + grant.status = 'approved'; + grant.userId = input.approverId; + grant.grantedScopes = granted; + const client = this.config.clients[grant.clientId]; + return { + clientId: grant.clientId, + clientName: client?.name ?? grant.clientId, + scopes: [...grant.requestedScopes], + grantedScopes: [...granted], + expiresAt: grant.expiresAt, + }; + } + + deny(input: { userCode: unknown; approverId: string }): void { + const grant = this.findPendingByUserCode(input.userCode, input.approverId); + this.consumeUserCode(grant); + grant.status = 'denied'; + grant.userId = input.approverId; + } + + // ── Step 3: device polls the token endpoint (RFC 8628 §3.4-3.5) ────────── + + /** + * Returns the approved grant exactly once; otherwise throws an `OAuthError` + * with the RFC 8628 error code the device should act on. + */ + poll(input: { clientId?: unknown; deviceCode?: unknown }): ApprovedDeviceGrant { + if (typeof input.deviceCode !== 'string' || !input.deviceCode) { + throw new OAuthError('invalid_request', 'device_code is required'); + } + if (typeof input.clientId !== 'string' || !this.config.clients[input.clientId]) { + throw new OAuthError('invalid_client', 'Unknown client_id'); + } + + const grant = this.grants.get(this.digest(`device:${input.deviceCode}`)); + if (!grant || grant.clientId !== input.clientId || grant.status === 'consumed') { + throw new OAuthError('invalid_grant', 'Unknown, foreign or already used device_code'); + } + + const now = this.now(); + if (now > grant.expiresAt) { + throw new OAuthError('expired_token', 'The device_code has expired; start a new request'); + } + + if (grant.status === 'denied') { + grant.status = 'consumed'; + throw new OAuthError('access_denied', 'The user denied the request'); + } + + if (grant.status === 'pending') { + const tooFast = + grant.lastPolledAt !== null && now - grant.lastPolledAt < grant.intervalSeconds * 1000; + grant.lastPolledAt = now; + if (tooFast) { + grant.intervalSeconds += 5; + throw new OAuthError('slow_down', `Polling too fast; wait ${grant.intervalSeconds}s`); + } + throw new OAuthError('authorization_pending', 'The user has not completed authorization'); + } + + // approved → hand out tokens once + grant.status = 'consumed'; + return { + userId: grant.userId as string, + clientId: grant.clientId, + scopes: [...grant.grantedScopes], + }; + } + + /** Current polling interval for a device code (exposed for tests and docs). */ + getInterval(deviceCode: string): number | undefined { + return this.grants.get(this.digest(`device:${deviceCode}`))?.intervalSeconds; + } + + /** Remove grants well past expiry. Called opportunistically on new requests. */ + sweep(): number { + const now = this.now(); + let removed = 0; + for (const [hash, grant] of this.grants) { + // Keep expired grants for one extra TTL so late polls still get `expired_token`. + if (now > grant.expiresAt + this.config.codeTtlMs) { + if (grant.userCodeHash) this.userCodes.delete(grant.userCodeHash); + this.grants.delete(hash); + removed++; + } + } + return removed; + } + + // ── Internals ───────────────────────────────────────────────────────────── + + private findPendingByUserCode(userCode: unknown, approverId: string): DeviceGrant { + const now = this.now(); + const failures = this.failures.get(approverId); + if ( + failures && + now - failures.windowStart <= this.config.userCodeFailureWindowMs && + failures.count >= this.config.maxUserCodeFailures + ) { + throw AuthError.userCodeAttemptsExceeded( + failures.windowStart + this.config.userCodeFailureWindowMs - now + ); + } + + const normalized = typeof userCode === 'string' ? normalizeUserCode(userCode) : ''; + const deviceHash = + normalized.length === this.config.userCodeLength + ? this.userCodes.get(this.digest(`user:${normalized}`)) + : undefined; + const grant = deviceHash ? this.grants.get(deviceHash) : undefined; + + if (!grant || grant.status !== 'pending') { + this.recordFailure(approverId, now); + throw AuthError.userCodeInvalid('unknown or already used code'); + } + if (now > grant.expiresAt) { + throw AuthError.userCodeInvalid('code expired'); + } + return grant; + } + + private consumeUserCode(grant: DeviceGrant): void { + if (grant.userCodeHash) this.userCodes.delete(grant.userCodeHash); + grant.userCodeHash = null; + } + + private recordFailure(approverId: string, now: number): void { + const current = this.failures.get(approverId); + if (!current || now - current.windowStart > this.config.userCodeFailureWindowMs) { + this.failures.set(approverId, { count: 1, windowStart: now }); + } else { + current.count += 1; + } + } + + private generateUserCode(): string { + let code = ''; + for (let i = 0; i < this.config.userCodeLength; i++) { + code += USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]; + } + const half = Math.ceil(code.length / 2); + return `${code.slice(0, half)}-${code.slice(half)}`; + } + + private digest(value: string): string { + return createHmac('sha256', this.hmacKey).update(value).digest('hex'); + } +} + +/** Case-insensitive; ignores the dash and spaces users type (RFC 8628 §6.1). */ +export function normalizeUserCode(code: string): string { + return code.toUpperCase().replace(/[\s-]/g, ''); +} + +export const deviceAuthorizationService = new DeviceAuthorizationService(); diff --git a/backend/services/auth/domain/PasswordCredentialService.ts b/backend/services/auth/domain/PasswordCredentialService.ts new file mode 100644 index 00000000..26d87f85 --- /dev/null +++ b/backend/services/auth/domain/PasswordCredentialService.ts @@ -0,0 +1,168 @@ +/** + * Password credentials — the fallback sign-in method next to passkeys (#1778). + * + * Passkeys (#1272) are the primary sign-in method, but a user whose device + * has no platform authenticator, who cancelled the WebAuthn prompt, or who + * lost their passkey still needs a way in. This service stores one password + * hash per user and verifies it: + * + * - Hashes use the repo's Argon2id-style KDF (`argon2Kdf.ts`); only the + * encoded hash is stored and comparison is constant-time. + * - Unknown users are verified against a dummy hash so the response time + * does not reveal whether an account has a password. + * - Repeated failures lock the account temporarily. + * - Changing an existing password requires the current one. + */ + +import { AuthError } from '../errors'; +import { hashPassword, verifyPassword, type Argon2Params } from './argon2Kdf'; + +export interface PasswordPolicyConfig { + minLength: number; + /** Upper bound keeps a single request from burning seconds of KDF time. */ + maxLength: number; + maxFailedAttempts: number; + /** Failures older than this no longer count towards the lockout. */ + failureWindowMs: number; + lockoutMs: number; + /** + * KDF cost. `ARGON2_INTERACTIVE` maps to ~4 GiB of scrypt memory with the + * parameter mapping used by `argon2Kdf.ts`, so this default (≈64 MiB, + * ~200 ms on a laptop) is chosen explicitly. + */ + kdfParams: Argon2Params; +} + +export const DEFAULT_PASSWORD_POLICY: PasswordPolicyConfig = { + minLength: 12, + maxLength: 256, + maxFailedAttempts: 5, + failureWindowMs: 15 * 60 * 1000, + lockoutMs: 15 * 60 * 1000, + kdfParams: { memoryCost: 1024, timeCost: 1, parallelism: 4, keyLen: 32 }, +}; + +interface StoredPassword { + encoded: string; + updatedAt: number; +} + +interface FailureState { + count: number; + firstFailureAt: number; + lockedUntil: number; +} + +export class PasswordCredentialService { + private readonly config: PasswordPolicyConfig; + private readonly now: () => number; + private passwords = new Map(); // userId → hash + private failures = new Map(); // userId → failures + private dummyHash: string | null = null; + + constructor(config: Partial = {}, now: () => number = Date.now) { + this.config = { ...DEFAULT_PASSWORD_POLICY, ...config }; + this.now = now; + } + + hasPassword(userId: string): boolean { + return this.passwords.has(userId); + } + + /** + * Set or change a user's password. When one is already set the caller must + * supply the current password as well (a stolen session alone cannot + * replace it). + */ + setPassword(userId: string, newPassword: unknown, currentPassword?: unknown): void { + if (!userId) throw AuthError.passwordPolicyViolation('userId is required'); + this.assertPolicy(newPassword); + + const existing = this.passwords.get(userId); + if (existing) { + if (typeof currentPassword !== 'string' || !this.verify(userId, currentPassword)) { + throw AuthError.invalidCredentials(); + } + } + + this.passwords.set(userId, { + encoded: hashPassword(newPassword, this.config.kdfParams).encoded, + updatedAt: this.now(), + }); + } + + /** + * Check a password. Throws `AUTH_ACCOUNT_LOCKED` while locked out and + * `AUTH_INVALID_CREDENTIALS` for an unknown user or a wrong password — the + * two are indistinguishable to the caller. + */ + authenticate(userId: unknown, password: unknown): void { + if (typeof userId !== 'string' || !userId || typeof password !== 'string' || !password) { + throw AuthError.invalidCredentials(); + } + if (password.length > this.config.maxLength) throw AuthError.invalidCredentials(); + + const now = this.now(); + const state = this.failures.get(userId); + if (state && state.lockedUntil > now) { + throw AuthError.accountLocked(state.lockedUntil - now); + } + + if (!this.verify(userId, password)) { + this.recordFailure(userId, now); + const after = this.failures.get(userId); + if (after && after.lockedUntil > now) throw AuthError.accountLocked(after.lockedUntil - now); + throw AuthError.invalidCredentials(); + } + + this.failures.delete(userId); + } + + private verify(userId: string, password: string): boolean { + const stored = this.passwords.get(userId); + if (!stored) { + // Burn the same KDF cost so timing does not reveal unknown accounts. + verifyPassword(password, this.getDummyHash()); + return false; + } + return verifyPassword(password, stored.encoded); + } + + private recordFailure(userId: string, now: number): void { + let state = this.failures.get(userId); + if (!state || now - state.firstFailureAt > this.config.failureWindowMs) { + state = { count: 0, firstFailureAt: now, lockedUntil: 0 }; + } + state.count += 1; + if (state.count >= this.config.maxFailedAttempts) { + state.lockedUntil = now + this.config.lockoutMs; + state.count = 0; + state.firstFailureAt = now; + } + this.failures.set(userId, state); + } + + private assertPolicy(password: unknown): asserts password is string { + if (typeof password !== 'string') + throw AuthError.passwordPolicyViolation('password must be a string'); + if (password.length < this.config.minLength) { + throw AuthError.passwordPolicyViolation( + `must be at least ${this.config.minLength} characters` + ); + } + if (password.length > this.config.maxLength) { + throw AuthError.passwordPolicyViolation( + `must be at most ${this.config.maxLength} characters` + ); + } + } + + private getDummyHash(): string { + if (!this.dummyHash) { + this.dummyHash = hashPassword('subtrackr-dummy-password', this.config.kdfParams).encoded; + } + return this.dummyHash; + } +} + +export const passwordCredentialService = new PasswordCredentialService(); diff --git a/backend/services/auth/domain/SessionTokenService.ts b/backend/services/auth/domain/SessionTokenService.ts new file mode 100644 index 00000000..f71d6cf4 --- /dev/null +++ b/backend/services/auth/domain/SessionTokenService.ts @@ -0,0 +1,500 @@ +/** + * Session token issuance with refresh-token rotation — SubTrackr + * + * Issues #1776 (refresh-token rotation), #1777 (session listing and remote + * revocation), #1778 (passkey / password login issue the same session pair) + * and #1779 (device-flow sessions with scopes). + * + * Model: + * - A *session* is a `ServerSessionService` record. Its token is the access + * token: opaque, short-lived (sliding TTL) and limited to `session.scopes`. + * - Every session issued here owns exactly one refresh-token *family*. + * A family holds the hash of the one refresh token that is currently + * redeemable plus the hashes of every token it has already rotated away. + * - Redeeming the current refresh token rotates both tokens: a new refresh + * token replaces the old one in the family and the session's access token + * is re-keyed, so the previous pair stops working at once. + * - Presenting a refresh token that was already rotated away is *reuse*: + * either the legitimate client or an attacker holds a copy. The server + * cannot tell which, so the whole family and its session are revoked + * (OAuth 2.0 Security BCP, RFC 9700 §4.14.2). + * - Revoking the session by any path (logout, remote revocation, revoke-all, + * concurrent-session eviction) revokes the family through the session + * service's revocation listener, so a revoked session can never be revived + * with its refresh token. + * + * Only SHA-256 digests of refresh tokens are stored. Refresh tokens carry 256 + * bits of entropy, so an unsalted digest is not brute-forceable, and the + * lookup is keyed by digest rather than compared character by character. + */ + +import { createHash, randomBytes, timingSafeEqual } from 'crypto'; +import type { IncomingMessage } from 'node:http'; +import { AuthError } from '../errors'; +import { logger } from '../../shared/logging'; +import { buildEvent, eventBus } from '../../shared/events'; +import { + serverSessionService, + type ServerSessionService, + type SessionRecord, +} from '../serverSessionService'; + +const REFRESH_TOKEN_PREFIX = 'rt_'; +const REFRESH_TOKEN_BYTES = 32; +const MAX_AUDIT_ENTRIES = 10_000; + +export type AuthMethod = 'passkey' | 'password' | 'device_code' | 'session'; + +export type TokenFamilyStatus = 'active' | 'revoked' | 'expired'; + +export interface RefreshTokenConfig { + /** A refresh token not redeemed within this window expires. Default: 7 days. */ + idleTtlMs: number; + /** Hard cap on a family's lifetime regardless of activity. Default: 30 days. */ + absoluteTtlMs: number; +} + +export const DEFAULT_REFRESH_TOKEN_CONFIG: RefreshTokenConfig = { + idleTtlMs: 7 * 24 * 60 * 60 * 1000, + absoluteTtlMs: 30 * 24 * 60 * 60 * 1000, +}; + +export interface TokenFamily { + id: string; + sessionId: string; + userId: string; + authMethod: AuthMethod; + clientId: string | null; + /** SHA-256 (hex) of the only refresh token that may be redeemed. */ + currentTokenHash: string; + /** Incremented on every rotation; the current token has this generation. */ + generation: number; + status: TokenFamilyStatus; + createdAt: number; + lastRotatedAt: number; + /** Idle expiry of the current refresh token (never beyond `absoluteExpiresAt`). */ + expiresAt: number; + absoluteExpiresAt: number; + revokedAt?: number; + revokedReason?: string; +} + +export interface IssueSessionInput { + userId: string; + req: IncomingMessage; + authMethod: AuthMethod; + /** Defaults to `['*']` (interactive login). */ + scopes?: string[]; + clientId?: string; + metadata?: Record; +} + +export interface IssuedSession { + accessToken: string; + tokenType: 'Bearer'; + /** Seconds until the access token's current expiry. */ + expiresIn: number; + refreshToken: string; + refreshTokenExpiresAt: number; + scopes: string[]; + session: SessionRecord; + family: TokenFamily; +} + +/** Listing view of a session — never includes token material. */ +export interface SessionSummary { + id: string; + current: boolean; + authMethod: AuthMethod | 'unknown'; + clientId: string | null; + scopes: string[]; + device: { + platform: string; + browser: string; + os: string; + isMobile: boolean; + }; + location: { country: string | null; region: string | null; city: string | null }; + createdAt: number; + lastActiveAt: number; + accessTokenExpiresAt: number; + refreshTokenExpiresAt: number | null; + isSuspicious: boolean; +} + +export interface TokenAuditEntry { + familyId: string; + sessionId: string; + userId: string; + action: 'issued' | 'rotated' | 'reuse_detected' | 'revoked' | 'rejected'; + generation: number; + timestamp: number; + detail?: string; +} + +interface TokenIndexEntry { + familyId: string; + generation: number; +} + +export interface SessionTokenServiceOptions { + sessions?: ServerSessionService; + config?: Partial; + now?: () => number; +} + +export function hashRefreshToken(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex'); +} + +function hashesEqual(a: string, b: string): boolean { + const left = Buffer.from(a, 'hex'); + const right = Buffer.from(b, 'hex'); + return left.length === right.length && timingSafeEqual(left, right); +} + +export class SessionTokenService { + private readonly sessions: ServerSessionService; + private readonly config: RefreshTokenConfig; + private readonly now: () => number; + private families = new Map(); // familyId → family + private familyBySession = new Map(); // sessionId → familyId + private tokenIndex = new Map(); // token hash → family + generation + private familyTokenHashes = new Map>(); // familyId → every hash issued + private auditLog: TokenAuditEntry[] = []; + private readonly unsubscribe: () => void; + + constructor(options: SessionTokenServiceOptions = {}) { + this.sessions = options.sessions ?? serverSessionService; + this.config = { ...DEFAULT_REFRESH_TOKEN_CONFIG, ...options.config }; + this.now = options.now ?? Date.now; + // Any revocation of the session — wherever it originates — kills the family. + this.unsubscribe = this.sessions.onRevoke((session, reason) => { + const familyId = this.familyBySession.get(session.id); + if (familyId) this.revokeFamily(familyId, `session_revoked:${reason}`); + }); + } + + /** The session store this service issues into. */ + get sessionStore(): ServerSessionService { + return this.sessions; + } + + /** Detach from the session service (tests / hot reload). */ + dispose(): void { + this.unsubscribe(); + } + + // ── Issuance ────────────────────────────────────────────────────────────── + + /** + * Create a session and its refresh-token family. Every login path (passkey, + * password, device flow) goes through here so they all get identical + * rotation, listing and revocation behaviour. + */ + issue(input: IssueSessionInput): IssuedSession { + const scopes = input.scopes && input.scopes.length > 0 ? [...input.scopes] : ['*']; + const session = this.sessions.createSession({ + userId: input.userId, + req: input.req, + scopes, + metadata: { + ...input.metadata, + authMethod: input.authMethod, + ...(input.clientId ? { clientId: input.clientId } : {}), + }, + }); + + const now = this.now(); + const refreshToken = this.generateRefreshToken(); + const tokenHash = hashRefreshToken(refreshToken); + const absoluteExpiresAt = now + this.config.absoluteTtlMs; + const family: TokenFamily = { + id: `rtf_${randomBytes(12).toString('hex')}`, + sessionId: session.id, + userId: input.userId, + authMethod: input.authMethod, + clientId: input.clientId ?? null, + currentTokenHash: tokenHash, + generation: 1, + status: 'active', + createdAt: now, + lastRotatedAt: now, + expiresAt: Math.min(now + this.config.idleTtlMs, absoluteExpiresAt), + absoluteExpiresAt, + }; + + this.families.set(family.id, family); + this.familyBySession.set(session.id, family.id); + this.tokenIndex.set(tokenHash, { familyId: family.id, generation: 1 }); + this.familyTokenHashes.set(family.id, new Set([tokenHash])); + this.audit(family, 'issued', `method=${input.authMethod}`); + + return this.toIssued(session, family, refreshToken); + } + + // ── Rotation ────────────────────────────────────────────────────────────── + + /** + * Redeem a refresh token: returns a new access token and a new refresh + * token. Throws `AUTH_REFRESH_TOKEN_REUSED` (and revokes the session) when a + * token that was already rotated is presented again, and + * `AUTH_REFRESH_TOKEN_INVALID` for unknown, expired or revoked tokens. + */ + refresh(refreshToken: string, req?: IncomingMessage): IssuedSession { + if (typeof refreshToken !== 'string' || !refreshToken.startsWith(REFRESH_TOKEN_PREFIX)) { + throw AuthError.refreshTokenInvalid('malformed'); + } + + const presentedHash = hashRefreshToken(refreshToken); + const entry = this.tokenIndex.get(presentedHash); + const family = entry ? this.families.get(entry.familyId) : undefined; + if (!entry || !family) throw AuthError.refreshTokenInvalid('unknown'); + + if (family.status === 'revoked') { + this.audit(family, 'rejected', `family revoked (${family.revokedReason ?? 'unknown'})`); + throw AuthError.refreshTokenInvalid('revoked'); + } + + const isCurrent = + entry.generation === family.generation && hashesEqual(presentedHash, family.currentTokenHash); + if (!isCurrent) { + this.handleReuse(family, entry.generation); + throw AuthError.refreshTokenReused(); + } + + const now = this.now(); + if (now > family.expiresAt) { + family.status = 'expired'; + this.audit(family, 'rejected', 'expired'); + throw AuthError.refreshTokenInvalid('expired'); + } + + const session = this.sessions.getSessionById(family.sessionId); + if (!session) { + // The session record was swept; the family cannot outlive it. + this.revokeFamily(family.id, 'session_missing'); + throw AuthError.refreshTokenInvalid('session_ended'); + } + + const rotation = this.sessions.rotateSessionToken(session.token, req); + if (!rotation.rotated || !rotation.session) { + this.audit(family, 'rejected', rotation.reason); + if (rotation.reason === 'session_revoked') { + this.revokeFamily(family.id, 'session_revoked'); + throw AuthError.refreshTokenInvalid('revoked'); + } + // Device-binding mismatch: refuse without rotating, so the legitimate + // device's token stays valid. + throw AuthError.refreshTokenInvalid(rotation.reason ?? 'rejected'); + } + + const nextToken = this.generateRefreshToken(); + const nextHash = hashRefreshToken(nextToken); + family.generation += 1; + family.currentTokenHash = nextHash; + family.lastRotatedAt = now; + family.expiresAt = Math.min(now + this.config.idleTtlMs, family.absoluteExpiresAt); + this.tokenIndex.set(nextHash, { familyId: family.id, generation: family.generation }); + this.familyTokenHashes.get(family.id)?.add(nextHash); + this.audit(family, 'rotated'); + + return this.toIssued(rotation.session, family, nextToken); + } + + private handleReuse(family: TokenFamily, presentedGeneration: number): void { + const detectedAt = this.now(); + this.audit( + family, + 'reuse_detected', + `presented generation ${presentedGeneration}, current ${family.generation}` + ); + logger.warn('Refresh token reuse detected; revoking session', { + familyId: family.id, + sessionId: family.sessionId, + userId: family.userId, + }); + this.revokeFamily(family.id, 'reuse_detected'); + + const event = buildEvent( + 'auth', + 'refresh_token_reuse_detected', + { + familyId: family.id, + sessionId: family.sessionId, + userId: family.userId, + presentedGeneration, + currentGeneration: family.generation, + detectedAt, + }, + { aggregateId: family.sessionId } + ); + // A failing subscriber must never undo or block the revocation. + eventBus.publish(event).catch((err: unknown) => { + logger.error('Failed to publish refresh token reuse event', { + error: err instanceof Error ? err.message : String(err), + }); + }); + } + + // ── Revocation ──────────────────────────────────────────────────────────── + + /** + * Revoke a family and the session it belongs to. Idempotent. The session + * revocation re-enters through the listener, which is a no-op once the + * family is already revoked. + */ + revokeFamily(familyId: string, reason: string): boolean { + const family = this.families.get(familyId); + if (!family || family.status === 'revoked') return false; + family.status = 'revoked'; + family.revokedAt = this.now(); + family.revokedReason = reason; + this.audit(family, 'revoked', reason); + this.sessions.revokeSessionById(family.sessionId, reason); + return true; + } + + /** + * Revoke one of `userId`'s sessions by id. Sessions owned by someone else + * are reported as not found so ids cannot be probed. + */ + revokeSession(userId: string, sessionId: string, reason = 'user_revoked'): boolean { + const session = this.sessions.getSessionById(sessionId); + if (!session || session.userId !== userId || session.status === 'revoked') return false; + return this.sessions.revokeSession(session.token, reason); + } + + /** Revoke every session of `userId` except `keepSessionId`. Returns the count. */ + revokeOtherSessions( + userId: string, + keepSessionId: string, + reason = 'user_signed_out_others' + ): number { + let count = 0; + for (const session of this.sessions.getAllSessionsForUser(userId)) { + if (session.id === keepSessionId) continue; + if (this.revokeSession(userId, session.id, reason)) count++; + } + return count; + } + + // ── Queries ─────────────────────────────────────────────────────────────── + + getFamilyForSession(sessionId: string): TokenFamily | undefined { + const familyId = this.familyBySession.get(sessionId); + return familyId ? this.families.get(familyId) : undefined; + } + + /** + * A session is listed while it can still be used: its access token is live, + * or its refresh token can still mint a new one. Revoked sessions and + * sessions whose access and refresh tokens have both lapsed are excluded. + */ + listSessions(userId: string, currentSessionId?: string): SessionSummary[] { + const now = this.now(); + const summaries: SessionSummary[] = []; + for (const session of this.sessions.getAllSessionsForUser(userId)) { + if (session.status === 'revoked') continue; + const family = this.getFamilyForSession(session.id); + const accessLive = session.status === 'active' && now <= session.expiresAt; + const refreshLive = !!family && family.status === 'active' && now <= family.expiresAt; + if (!accessLive && !refreshLive) continue; + + const authMethod = (session.metadata['authMethod'] as AuthMethod | undefined) ?? 'unknown'; + summaries.push({ + id: session.id, + current: session.id === currentSessionId, + authMethod, + clientId: family?.clientId ?? null, + scopes: [...session.scopes], + device: { + platform: session.device.platform, + browser: session.device.browser, + os: session.device.os, + isMobile: session.device.isMobile, + }, + location: { + country: session.geo.country, + region: session.geo.region, + city: session.geo.city, + }, + createdAt: session.createdAt, + lastActiveAt: session.lastActiveAt, + accessTokenExpiresAt: session.expiresAt, + refreshTokenExpiresAt: refreshLive && family ? family.expiresAt : null, + isSuspicious: session.isSuspicious, + }); + } + return summaries.sort((a, b) => b.lastActiveAt - a.lastActiveAt); + } + + getAuditLog( + filter: { userId?: string; sessionId?: string; limit?: number } = {} + ): TokenAuditEntry[] { + let log = this.auditLog; + if (filter.userId) log = log.filter((e) => e.userId === filter.userId); + if (filter.sessionId) log = log.filter((e) => e.sessionId === filter.sessionId); + return log.slice(-(filter.limit ?? 200)); + } + + /** + * Drop families that can no longer be redeemed together with their token + * hashes. Reuse of a purged token is then reported as `unknown`, which is + * still a rejection. Returns the number of families removed. + */ + sweep(): number { + const now = this.now(); + let removed = 0; + for (const [id, family] of this.families) { + const dead = family.status !== 'active' || now > family.expiresAt; + if (!dead || now <= family.absoluteExpiresAt) continue; + for (const hash of this.familyTokenHashes.get(id) ?? []) this.tokenIndex.delete(hash); + this.familyTokenHashes.delete(id); + this.familyBySession.delete(family.sessionId); + this.families.delete(id); + removed++; + } + return removed; + } + + // ── Internals ───────────────────────────────────────────────────────────── + + private generateRefreshToken(): string { + return `${REFRESH_TOKEN_PREFIX}${randomBytes(REFRESH_TOKEN_BYTES).toString('base64url')}`; + } + + private toIssued( + session: SessionRecord, + family: TokenFamily, + refreshToken: string + ): IssuedSession { + return { + accessToken: session.token, + tokenType: 'Bearer', + expiresIn: Math.max(0, Math.floor((session.expiresAt - this.now()) / 1000)), + refreshToken, + refreshTokenExpiresAt: family.expiresAt, + scopes: [...session.scopes], + session, + family, + }; + } + + private audit(family: TokenFamily, action: TokenAuditEntry['action'], detail?: string): void { + this.auditLog.push({ + familyId: family.id, + sessionId: family.sessionId, + userId: family.userId, + action, + generation: family.generation, + timestamp: this.now(), + detail, + }); + if (this.auditLog.length > MAX_AUDIT_ENTRIES) { + this.auditLog = this.auditLog.slice(-MAX_AUDIT_ENTRIES / 2); + } + } +} + +export const sessionTokenService = new SessionTokenService(); diff --git a/backend/services/auth/errors.ts b/backend/services/auth/errors.ts index e57f4c0f..7c340ac8 100644 --- a/backend/services/auth/errors.ts +++ b/backend/services/auth/errors.ts @@ -15,6 +15,15 @@ export const AuthErrorCode = { PASSKEY_CREDENTIAL_NOT_FOUND: 'AUTH_PASSKEY_CREDENTIAL_NOT_FOUND' as ErrorCode, PASSKEY_CREDENTIAL_EXISTS: 'AUTH_PASSKEY_CREDENTIAL_EXISTS' as ErrorCode, PASSKEY_COUNTER_REGRESSION: 'AUTH_PASSKEY_COUNTER_REGRESSION' as ErrorCode, + INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS' as ErrorCode, + ACCOUNT_LOCKED: 'AUTH_ACCOUNT_LOCKED' as ErrorCode, + PASSWORD_POLICY_VIOLATION: 'AUTH_PASSWORD_POLICY_VIOLATION' as ErrorCode, + REFRESH_TOKEN_INVALID: 'AUTH_REFRESH_TOKEN_INVALID' as ErrorCode, + REFRESH_TOKEN_REUSED: 'AUTH_REFRESH_TOKEN_REUSED' as ErrorCode, + SESSION_NOT_FOUND: 'AUTH_SESSION_NOT_FOUND' as ErrorCode, + INSUFFICIENT_SCOPE: 'AUTH_INSUFFICIENT_SCOPE' as ErrorCode, + USER_CODE_INVALID: 'AUTH_USER_CODE_INVALID' as ErrorCode, + USER_CODE_ATTEMPTS_EXCEEDED: 'AUTH_USER_CODE_ATTEMPTS_EXCEEDED' as ErrorCode, } as const; export class AuthError extends DomainError { @@ -81,4 +90,62 @@ export class AuthError extends DomainError { { credentialId } ); } + + // ── Password login (#1778) ──────────────────────────────────────────────── + + /** Deliberately identical for unknown users and wrong passwords. */ + static invalidCredentials(): AuthError { + return new AuthError(AuthErrorCode.INVALID_CREDENTIALS, 'Invalid credentials'); + } + + static accountLocked(retryAfterMs: number): AuthError { + return new AuthError( + AuthErrorCode.ACCOUNT_LOCKED, + 'Too many failed sign-in attempts; try again later', + { retryAfterSeconds: String(Math.ceil(retryAfterMs / 1000)) } + ); + } + + static passwordPolicyViolation(reason: string): AuthError { + return new AuthError(AuthErrorCode.PASSWORD_POLICY_VIOLATION, `Password rejected: ${reason}`, { reason }); + } + + // ── Refresh tokens & sessions (#1776, #1777) ───────────────────────────── + + static refreshTokenInvalid(reason: string): AuthError { + return new AuthError(AuthErrorCode.REFRESH_TOKEN_INVALID, `Refresh token rejected: ${reason}`, { reason }); + } + + static refreshTokenReused(): AuthError { + return new AuthError( + AuthErrorCode.REFRESH_TOKEN_REUSED, + 'Refresh token was already used; the session has been revoked' + ); + } + + static sessionNotFound(sessionId: string): AuthError { + return new AuthError(AuthErrorCode.SESSION_NOT_FOUND, `Session not found: ${sessionId}`, { sessionId }); + } + + static insufficientScope(requiredScope: string): AuthError { + return new AuthError( + AuthErrorCode.INSUFFICIENT_SCOPE, + `Access token lacks required scope: ${requiredScope}`, + { requiredScope } + ); + } + + // ── Device authorization (#1779) ───────────────────────────────────────── + + static userCodeInvalid(reason: string): AuthError { + return new AuthError(AuthErrorCode.USER_CODE_INVALID, `User code rejected: ${reason}`, { reason }); + } + + static userCodeAttemptsExceeded(retryAfterMs: number): AuthError { + return new AuthError( + AuthErrorCode.USER_CODE_ATTEMPTS_EXCEEDED, + 'Too many invalid user codes; try again later', + { retryAfterSeconds: String(Math.ceil(retryAfterMs / 1000)) } + ); + } } diff --git a/backend/services/auth/index.ts b/backend/services/auth/index.ts index 4e528ecc..742de6e4 100644 --- a/backend/services/auth/index.ts +++ b/backend/services/auth/index.ts @@ -67,5 +67,33 @@ export type { RecoveryAuditEntry, RecoverySession, } from './domain/AccountRecoveryService'; +export { + SessionTokenService, + sessionTokenService, + DEFAULT_REFRESH_TOKEN_CONFIG, + hashRefreshToken, +} from './domain/SessionTokenService'; +export type { + AuthMethod, + IssuedSession, + RefreshTokenConfig, + SessionSummary, + TokenFamily, +} from './domain/SessionTokenService'; +export { + PasswordCredentialService, + passwordCredentialService, + DEFAULT_PASSWORD_POLICY, +} from './domain/PasswordCredentialService'; +export { + DeviceAuthorizationService, + deviceAuthorizationService, + DEFAULT_DEVICE_FLOW_CONFIG, + DEVICE_CODE_GRANT_TYPE, + OAuthError, +} from './domain/DeviceAuthorizationService'; +export type { DeviceClient, DeviceFlowConfig } from './domain/DeviceAuthorizationService'; +export { createRequireAuth, extractAccessToken } from './middleware/sessionAuth'; +export { createSessionRouter } from './router/sessionRouter'; export { AuthError, AuthErrorCode } from './errors'; diff --git a/backend/services/auth/middleware/sessionAuth.ts b/backend/services/auth/middleware/sessionAuth.ts new file mode 100644 index 00000000..83fd1ac5 --- /dev/null +++ b/backend/services/auth/middleware/sessionAuth.ts @@ -0,0 +1,79 @@ +/** + * Session access-token authentication and scope enforcement (#1777, #1779). + * + * Accepts the access token as `Authorization: Bearer ` (OAuth clients, + * the CLI) or `X-Session-Token` (the existing app convention). The session is + * re-validated on every request, so a revoked session is rejected on its very + * next call. Scope checks follow RFC 6750 §3.1: 401 `invalid_token` for a + * missing / bad token, 403 `insufficient_scope` when the token is valid but + * was not granted the scope the route needs. + */ + +import type { NextFunction, Request, RequestHandler, Response } from 'express'; +import { hasScope } from '../domain/ApiKeyRotationService'; +import { + serverSessionService, + type ServerSessionService, + type SessionRecord, +} from '../serverSessionService'; +import { ERROR_HTTP_STATUS_MAP, fail } from '../../shared/apiResponse'; + +export function extractAccessToken(req: Request): string | null { + const authorization = req.headers['authorization']; + if (typeof authorization === 'string') { + const match = /^Bearer\s+(\S+)$/i.exec(authorization.trim()); + if (match) return match[1] as string; + } + const header = req.headers['x-session-token']; + return typeof header === 'string' && header.length > 0 ? header : null; +} + +function requestIdOf(req: Request): string | undefined { + const header = req.headers['x-request-id']; + return typeof header === 'string' ? header : undefined; +} + +/** The authenticated session, set by `createRequireAuth`. */ +export function sessionOf(res: Response): SessionRecord { + return res.locals['session'] as SessionRecord; +} + +/** + * Build the authentication middleware. When `scope` is given the session + * must hold it; otherwise any valid session passes. + */ +export function createRequireAuth( + options: { sessions?: ServerSessionService; scope?: string } = {} +): RequestHandler { + const sessions = options.sessions ?? serverSessionService; + return function requireAuth(req: Request, res: Response, next: NextFunction): void { + const token = extractAccessToken(req); + const result = token ? sessions.validateSession(token, req) : null; + if (!result?.valid || !result.session) { + res.setHeader('WWW-Authenticate', 'Bearer error="invalid_token"'); + res + .status(401) + .json(fail('UNAUTHORIZED', 'A valid access token is required', requestIdOf(req))); + return; + } + if (options.scope && !hasScope(result.session.scopes, options.scope)) { + res.setHeader( + 'WWW-Authenticate', + `Bearer error="insufficient_scope", scope="${options.scope}"` + ); + res + .status(ERROR_HTTP_STATUS_MAP.AUTH_INSUFFICIENT_SCOPE) + .json( + fail( + 'AUTH_INSUFFICIENT_SCOPE', + `Access token lacks required scope: ${options.scope}`, + requestIdOf(req), + { requiredScope: options.scope } + ) + ); + return; + } + res.locals['session'] = result.session; + next(); + }; +} diff --git a/backend/services/auth/router/authRouter.ts b/backend/services/auth/router/authRouter.ts index e33077e5..456d094e 100644 --- a/backend/services/auth/router/authRouter.ts +++ b/backend/services/auth/router/authRouter.ts @@ -6,10 +6,14 @@ * POST /passkeys/register/options – creation options (session required) * POST /passkeys/register/verify – verify attestation, store passkey (session required) * POST /passkeys/login/options – assertion options (optional userId) - * POST /passkeys/login/verify – verify assertion, issue session (X-Session-Token) + * POST /passkeys/login/verify – verify assertion, issue session + refresh token + * (X-Session-Token / X-Refresh-Token, #1776/#1778) * GET /passkeys – list the caller's passkeys (session required) * DELETE /passkeys/:credentialId – remove one of the caller's passkeys (session required) * + * Managing passkeys needs a session holding the `auth:manage` scope, which + * interactive sign-ins have and device-flow tokens never get (#1779). + * * API key routes (mount at /api/v1/api-keys): * POST /:keyId/revoke – revoke a key immediately * POST /:keyId/report-leak – manually report a key as exposed @@ -37,6 +41,8 @@ import { type ServerSessionService, type SessionRecord, } from '../serverSessionService'; +import { sessionTokenService, SessionTokenService } from '../domain/SessionTokenService'; +import { hasScope } from '../domain/ApiKeyRotationService'; import { ERROR_HTTP_STATUS_MAP, fail, type ApiResponse } from '../../shared/apiResponse'; import type { LeakIncidentStatus } from '../interfaces'; @@ -67,14 +73,22 @@ function queryString(value: unknown): string | undefined { export interface PasskeyRouterOptions { controller?: PasskeyController; sessions?: ServerSessionService; + /** Issues the session + refresh-token family on login (#1776). */ + tokens?: SessionTokenService; } export function createPasskeyRouter(options: PasskeyRouterOptions = {}): Router { const controller = options.controller ?? defaultPasskeyController; - const sessions = options.sessions ?? serverSessionService; + const sessions = options.sessions ?? options.tokens?.sessionStore ?? serverSessionService; + const tokens = + options.tokens ?? + (options.sessions ? new SessionTokenService({ sessions }) : sessionTokenService); const router = Router(); - /** Adding or removing passkeys requires an existing authenticated session. */ + /** + * Adding or removing passkeys requires an existing authenticated session + * with the `auth:manage` scope. + */ function requireSession(req: Request, res: Response): SessionRecord | null { const token = req.headers['x-session-token']; const result = typeof token === 'string' ? sessions.validateSession(token, req) : null; @@ -82,6 +96,20 @@ export function createPasskeyRouter(options: PasskeyRouterOptions = {}): Router send(res, fail('UNAUTHORIZED', 'A valid X-Session-Token is required', requestIdOf(req))); return null; } + if (!hasScope(result.session.scopes, 'auth:manage')) { + send( + res, + fail( + 'AUTH_INSUFFICIENT_SCOPE', + 'Access token lacks required scope: auth:manage', + requestIdOf(req), + { + requiredScope: 'auth:manage', + } + ) + ); + return null; + } return result.session; } @@ -139,13 +167,18 @@ export function createPasskeyRouter(options: PasskeyRouterOptions = {}): Router return; } - const session = sessions.createSession({ + const issued = tokens.issue({ userId: result.data.userId, req, - metadata: { authMethod: 'passkey', credentialId: result.data.credentialId }, + authMethod: 'passkey', + metadata: { credentialId: result.data.credentialId }, }); - // Same convention as POST /sessions: the raw token travels in a header. - res.setHeader('X-Session-Token', session.token); + const session = issued.session; + // Same convention as POST /sessions: raw tokens travel in headers, never + // in the body. The refresh token rotates on every use (#1776). + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-Session-Token', issued.accessToken); + res.setHeader('X-Refresh-Token', issued.refreshToken); send(res, { ...result, data: { @@ -153,6 +186,8 @@ export function createPasskeyRouter(options: PasskeyRouterOptions = {}): Router session: { id: session.id, expiresAt: session.expiresAt, + refreshTokenExpiresAt: issued.refreshTokenExpiresAt, + scopes: issued.scopes, isSuspicious: session.isSuspicious, }, }, diff --git a/backend/services/auth/router/sessionRouter.ts b/backend/services/auth/router/sessionRouter.ts new file mode 100644 index 00000000..9144c75e --- /dev/null +++ b/backend/services/auth/router/sessionRouter.ts @@ -0,0 +1,375 @@ +/** + * Session, token and device-flow routes — issues #1776-#1779. + * + * Mount at /api/v1/auth: + * + * Password fallback (#1778) + * POST /login/password – sign in with userId + password + * PUT /password – set / change password (scope auth:manage) + * + * Tokens (#1776, #1779) — RFC 6749 token endpoint, form or JSON body + * POST /oauth/token – grant_type=refresh_token | device_code + * + * Device authorization (#1779) — RFC 8628 + * POST /oauth/device_authorization – start: device_code + user_code + * GET /oauth/device?user_code=… – what is being approved (scope auth:manage) + * POST /oauth/device/approve – approve, optionally narrowing scopes (scope auth:manage) + * POST /oauth/device/deny – deny (scope auth:manage) + * + * Sessions (#1777) + * GET /sessions – the caller's live sessions (scope sessions:read) + * DELETE /sessions/:sessionId – revoke one session now (scope sessions:write) + * POST /sessions/revoke-others – revoke every other session (scope sessions:write) + * POST /logout – revoke the calling session + * + * Interactive sign-ins (passkey, password) return the access token in + * `X-Session-Token` and the refresh token in `X-Refresh-Token`, the same + * header convention as `POST /sessions`. The OAuth token endpoint returns + * them in the JSON body as RFC 6749 §5.1 requires. + */ + +import express, { Router, type Request, type Response, type NextFunction } from 'express'; +import { AuthError, AuthErrorCode } from '../errors'; +import { + sessionTokenService as defaultTokens, + SessionTokenService, + type IssuedSession, +} from '../domain/SessionTokenService'; +import { + passwordCredentialService as defaultPasswords, + PasswordCredentialService, +} from '../domain/PasswordCredentialService'; +import { + DEVICE_CODE_GRANT_TYPE, + deviceAuthorizationService as defaultDeviceFlow, + DeviceAuthorizationService, + OAuthError, + parseScopeParam, +} from '../domain/DeviceAuthorizationService'; +import { createRequireAuth, sessionOf } from '../middleware/sessionAuth'; +import { serverSessionService, type ServerSessionService } from '../serverSessionService'; +import { + ERROR_HTTP_STATUS_MAP, + fail, + ok, + type ApiResponse, + type ErrorCode, +} from '../../shared/apiResponse'; + +export interface SessionRouterOptions { + sessions?: ServerSessionService; + tokens?: SessionTokenService; + passwords?: PasswordCredentialService; + deviceFlow?: DeviceAuthorizationService; +} + +type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise | void; + +function handler(fn: AsyncHandler) { + return (req: Request, res: Response, next: NextFunction): void => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +} + +function requestIdOf(req: Request): string | undefined { + const header = req.headers['x-request-id']; + return typeof header === 'string' ? header : undefined; +} + +function send(res: Response, result: ApiResponse, successStatus = 200): void { + const status = result.success ? successStatus : (ERROR_HTTP_STATUS_MAP[result.error.code] ?? 500); + res.status(status).json(result); +} + +function sendAuthError(req: Request, res: Response, err: unknown): void { + if (err instanceof AuthError) { + send( + res, + fail( + err.code as ErrorCode, + err.message, + requestIdOf(req), + err.details as Record + ) + ); + return; + } + throw err; +} + +function str(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** Tokens must never be cached by intermediaries (RFC 6749 §5.1). */ +function noStore(res: Response): void { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Pragma', 'no-cache'); +} + +/** Interactive login response: tokens in headers, metadata in the body. */ +function sendInteractiveLogin(req: Request, res: Response, issued: IssuedSession): void { + noStore(res); + res.setHeader('X-Session-Token', issued.accessToken); + res.setHeader('X-Refresh-Token', issued.refreshToken); + send( + res, + ok( + { + userId: issued.session.userId, + session: { + id: issued.session.id, + expiresAt: issued.session.expiresAt, + refreshTokenExpiresAt: issued.refreshTokenExpiresAt, + scopes: issued.scopes, + isSuspicious: issued.session.isSuspicious, + }, + }, + requestIdOf(req) + ) + ); +} + +function tokenResponse(issued: IssuedSession): Record { + return { + access_token: issued.accessToken, + token_type: issued.tokenType, + expires_in: issued.expiresIn, + refresh_token: issued.refreshToken, + scope: issued.scopes.join(' '), + }; +} + +function sendOAuthError(res: Response, err: OAuthError): void { + noStore(res); + if (err.status === 401) res.setHeader('WWW-Authenticate', 'Basic realm="subtrackr"'); + res.status(err.status).json(err.toJSON()); +} + +export function createSessionRouter(options: SessionRouterOptions = {}): Router { + const sessions = options.sessions ?? options.tokens?.sessionStore ?? serverSessionService; + const tokens = + options.tokens ?? (options.sessions ? new SessionTokenService({ sessions }) : defaultTokens); + const passwords = options.passwords ?? defaultPasswords; + const deviceFlow = options.deviceFlow ?? defaultDeviceFlow; + const router = Router(); + + // RFC 6749 / 8628 clients send application/x-www-form-urlencoded. + const formBody = express.urlencoded({ extended: false, limit: '16kb' }); + + const requireAuth = createRequireAuth({ sessions }); + const requireAuthManage = createRequireAuth({ sessions, scope: 'auth:manage' }); + const requireSessionsRead = createRequireAuth({ sessions, scope: 'sessions:read' }); + const requireSessionsWrite = createRequireAuth({ sessions, scope: 'sessions:write' }); + + // ── Password fallback (#1778) ───────────────────────────────────────────── + + router.post( + '/login/password', + formBody, + handler((req, res) => { + const body = req.body ?? {}; + try { + passwords.authenticate(body.userId, body.password); + } catch (err) { + if (err instanceof AuthError && err.code === AuthErrorCode.ACCOUNT_LOCKED) { + const retry = (err.details as Record | undefined)?.['retryAfterSeconds']; + if (retry) res.setHeader('Retry-After', retry); + } + sendAuthError(req, res, err); + return; + } + const issued = tokens.issue({ userId: body.userId, req, authMethod: 'password' }); + sendInteractiveLogin(req, res, issued); + }) + ); + + router.put( + '/password', + requireAuthManage, + handler((req, res) => { + const session = sessionOf(res); + const body = req.body ?? {}; + try { + passwords.setPassword(session.userId, body.newPassword, body.currentPassword); + } catch (err) { + sendAuthError(req, res, err); + return; + } + send(res, ok({ updated: true }, requestIdOf(req))); + }) + ); + + // ── Token endpoint (#1776 refresh, #1779 device_code) ───────────────────── + + router.post( + '/oauth/token', + formBody, + handler((req, res) => { + const body = req.body ?? {}; + const grantType = str(body.grant_type); + try { + if (grantType === 'refresh_token') { + const refreshToken = str(body.refresh_token); + if (!refreshToken) throw new OAuthError('invalid_request', 'refresh_token is required'); + let issued: IssuedSession; + try { + issued = tokens.refresh(refreshToken, req); + } catch (err) { + if (err instanceof AuthError) throw new OAuthError('invalid_grant', err.message); + throw err; + } + noStore(res); + res.status(200).json(tokenResponse(issued)); + return; + } + + if (grantType === DEVICE_CODE_GRANT_TYPE) { + const grant = deviceFlow.poll({ clientId: body.client_id, deviceCode: body.device_code }); + const issued = tokens.issue({ + userId: grant.userId, + req, + authMethod: 'device_code', + scopes: grant.scopes, + clientId: grant.clientId, + }); + noStore(res); + res.status(200).json(tokenResponse(issued)); + return; + } + + throw new OAuthError( + grantType ? 'unsupported_grant_type' : 'invalid_request', + grantType ? `Unsupported grant_type: ${grantType}` : 'grant_type is required' + ); + } catch (err) { + if (err instanceof OAuthError) { + sendOAuthError(res, err); + return; + } + throw err; + } + }) + ); + + // ── Device authorization (#1779) ────────────────────────────────────────── + + router.post( + '/oauth/device_authorization', + formBody, + handler((req, res) => { + const body = req.body ?? {}; + try { + const response = deviceFlow.requestAuthorization({ + clientId: body.client_id, + scope: body.scope, + }); + noStore(res); + res.status(200).json(response); + } catch (err) { + if (err instanceof OAuthError) { + sendOAuthError(res, err); + return; + } + throw err; + } + }) + ); + + router.get( + '/oauth/device', + requireAuthManage, + handler((req, res) => { + try { + const pending = deviceFlow.describe(req.query['user_code'], sessionOf(res).userId); + send(res, ok(pending, requestIdOf(req))); + } catch (err) { + sendAuthError(req, res, err); + } + }) + ); + + router.post( + '/oauth/device/approve', + requireAuthManage, + handler((req, res) => { + const session = sessionOf(res); + const body = req.body ?? {}; + const narrowed = parseScopeParam(body.scope); + try { + const approved = deviceFlow.approve({ + userCode: body.user_code, + approverId: session.userId, + approverScopes: session.scopes, + scopes: narrowed ?? undefined, + }); + send(res, ok(approved, requestIdOf(req))); + } catch (err) { + sendAuthError(req, res, err); + } + }) + ); + + router.post( + '/oauth/device/deny', + requireAuthManage, + handler((req, res) => { + try { + deviceFlow.deny({ userCode: req.body?.user_code, approverId: sessionOf(res).userId }); + send(res, ok({ denied: true }, requestIdOf(req))); + } catch (err) { + sendAuthError(req, res, err); + } + }) + ); + + // ── Sessions (#1777) ────────────────────────────────────────────────────── + + router.get( + '/sessions', + requireSessionsRead, + handler((req, res) => { + const session = sessionOf(res); + send(res, ok(tokens.listSessions(session.userId, session.id), requestIdOf(req))); + }) + ); + + router.post( + '/sessions/revoke-others', + requireSessionsWrite, + handler((req, res) => { + const session = sessionOf(res); + const revoked = tokens.revokeOtherSessions(session.userId, session.id); + send(res, ok({ revoked }, requestIdOf(req))); + }) + ); + + router.delete( + '/sessions/:sessionId', + requireSessionsWrite, + handler((req, res) => { + const session = sessionOf(res); + const sessionId = req.params['sessionId'] as string; + if (!tokens.revokeSession(session.userId, sessionId, 'user_revoked')) { + sendAuthError(req, res, AuthError.sessionNotFound(sessionId)); + return; + } + send( + res, + ok({ revoked: true, sessionId, current: sessionId === session.id }, requestIdOf(req)) + ); + }) + ); + + router.post( + '/logout', + requireAuth, + handler((req, res) => { + const session = sessionOf(res); + tokens.revokeSession(session.userId, session.id, 'user_logout'); + send(res, ok({ revoked: true }, requestIdOf(req))); + }) + ); + + return router; +} diff --git a/backend/services/auth/serverSessionService.ts b/backend/services/auth/serverSessionService.ts index 821a956f..b9f86f96 100644 --- a/backend/services/auth/serverSessionService.ts +++ b/backend/services/auth/serverSessionService.ts @@ -13,6 +13,9 @@ * - Session revocation (single / all / other-devices) * - Full audit trail per session lifecycle event * - Prometheus metrics export + * - Per-session scopes, access-token rotation and revocation listeners so the + * refresh-token families in `domain/SessionTokenService.ts` stay in step + * with the session they belong to (issues #1776, #1777, #1779) */ import { randomBytes, createHash } from 'node:crypto'; @@ -73,6 +76,12 @@ export interface SessionRecord { revocationReason?: string; isSuspicious: boolean; suspiciousReasons: SuspiciousReason[]; + /** + * Scopes the session's access token is limited to. Interactive logins get + * `['*']`; sessions issued through the OAuth device flow carry only the + * scopes the user approved (issue #1779). + */ + scopes: string[]; metadata: Record; } @@ -80,9 +89,23 @@ export interface SessionCreateOptions { userId: string; req: IncomingMessage; ttlMs?: number; + /** Defaults to `['*']` (full access), matching sessions created before scopes existed. */ + scopes?: string[]; metadata?: Record; } +/** Called whenever a session moves to `revoked`, whatever the trigger. */ +export type SessionRevocationListener = (session: SessionRecord, reason: string) => void; + +export interface RotateTokenResult { + rotated: boolean; + reason?: + | 'session_not_found' + | 'session_revoked' + | 'device_binding_mismatch'; + session?: SessionRecord; +} + export interface SessionValidateResult { valid: boolean; session?: SessionRecord; @@ -118,7 +141,8 @@ export interface SessionAuditEntry { | 'revoked' | 'expired' | 'suspicious_flagged' - | 'rebound'; + | 'rebound' + | 'token_rotated'; ip: string; userAgent: string; timestamp: number; @@ -249,6 +273,7 @@ export class ServerSessionService { private knownDevices = new Map>(); // userId → Set private auditLog: SessionAuditEntry[] = []; private readonly maxAuditEntries = 100_000; + private revocationListeners = new Set(); private config: SessionServiceConfig; @@ -269,7 +294,13 @@ export class ServerSessionService { // ------------------------------------------------------------------------- createSession(opts: SessionCreateOptions): SessionRecord { - const { userId, req, ttlMs = this.config.defaultTtlMs, metadata = {} } = opts; + const { + userId, + req, + ttlMs = this.config.defaultTtlMs, + scopes = ['*'], + metadata = {}, + } = opts; const now = Date.now(); // Device fingerprint @@ -323,6 +354,7 @@ export class ServerSessionService { expiresAt: now + ttlMs, isSuspicious: suspiciousReasons.length > 0, suspiciousReasons, + scopes: [...scopes], metadata, }; @@ -559,13 +591,87 @@ export class ServerSessionService { return { rebound: true, session }; } + // ------------------------------------------------------------------------- + // Access-token rotation (issue #1776) + // ------------------------------------------------------------------------- + + /** + * Replace a session's access token with a fresh one, keeping the stable + * session `id`. The previous token stops validating immediately. + * + * Used when a refresh token is redeemed: the session continues, but only + * the newest access token is accepted. A session whose access token merely + * expired is reactivated; a revoked session never is. + */ + rotateSessionToken(token: string, req?: IncomingMessage): RotateTokenResult { + const session = this.sessions.get(token); + if (!session) return { rotated: false, reason: 'session_not_found' }; + if (session.status === 'revoked') { + return { rotated: false, reason: 'session_revoked', session }; + } + + const now = Date.now(); + if (req && this.config.deviceBinding === 'enforce') { + const fingerprint = currentDeviceFingerprint(req); + if (fingerprint !== session.device.fingerprint) { + this.audit({ + sessionId: session.id, + userId: session.userId, + action: 'suspicious_flagged', + ip: session.geo.ip, + userAgent: session.device.userAgent, + timestamp: now, + detail: `token rotation refused: device_binding_mismatch bound=${session.device.fingerprint} presented=${fingerprint}`, + }); + return { rotated: false, reason: 'device_binding_mismatch', session }; + } + } + + const nextToken = randomBytes(32).toString('hex'); + this.sessions.delete(token); + session.token = nextToken; + session.status = 'active'; + session.lastActiveAt = now; + session.expiresAt = now + this.config.defaultTtlMs; + this.sessions.set(nextToken, session); + + const userTokens = this.userSessionIndex.get(session.userId) ?? new Set(); + userTokens.delete(token); + userTokens.add(nextToken); + this.userSessionIndex.set(session.userId, userTokens); + + this.audit({ + sessionId: session.id, + userId: session.userId, + action: 'token_rotated', + ip: req ? buildGeoInfo(req).ip : session.geo.ip, + userAgent: session.device.userAgent, + timestamp: now, + }); + + return { rotated: true, session }; + } + // ------------------------------------------------------------------------- // Revocation // ------------------------------------------------------------------------- + /** + * Subscribe to revocations. Every revocation path (logout, revoke-all, + * revoke-others, concurrent-session eviction, remote revocation) notifies + * listeners, so state hanging off a session (refresh-token families) can be + * torn down in the same call. Returns an unsubscribe function. + */ + onRevoke(listener: SessionRevocationListener): () => void { + this.revocationListeners.add(listener); + return () => { + this.revocationListeners.delete(listener); + }; + } + revokeSession(token: string, reason = 'user_logout'): boolean { const session = this.sessions.get(token); - if (!session || session.status !== 'active') return false; + if (!session || session.status === 'revoked') return false; session.status = 'revoked'; session.revokedAt = Date.now(); session.revocationReason = reason; @@ -578,9 +684,18 @@ export class ServerSessionService { timestamp: session.revokedAt, detail: reason, }); + for (const listener of this.revocationListeners) { + listener(session, reason); + } return true; } + /** Revoke a session by its stable id (the token is never exposed in listings). */ + revokeSessionById(id: string, reason = 'user_revoked'): boolean { + const session = this.getSessionById(id); + return session ? this.revokeSession(session.token, reason) : false; + } + revokeAllSessions(userId: string, reason = 'admin_revoke_all'): number { const tokens = this.userSessionIndex.get(userId); if (!tokens) return 0; diff --git a/backend/services/shared/apiResponse.ts b/backend/services/shared/apiResponse.ts index 0f7e1caf..5433233c 100644 --- a/backend/services/shared/apiResponse.ts +++ b/backend/services/shared/apiResponse.ts @@ -166,6 +166,16 @@ export type ErrorCode = | 'AUTH_PASSKEY_CREDENTIAL_NOT_FOUND' | 'AUTH_PASSKEY_CREDENTIAL_EXISTS' | 'AUTH_PASSKEY_COUNTER_REGRESSION' + // ── Auth / Sessions, refresh tokens, password + device flow (#1776-#1779) ─ + | 'AUTH_INVALID_CREDENTIALS' + | 'AUTH_ACCOUNT_LOCKED' + | 'AUTH_PASSWORD_POLICY_VIOLATION' + | 'AUTH_REFRESH_TOKEN_INVALID' + | 'AUTH_REFRESH_TOKEN_REUSED' + | 'AUTH_SESSION_NOT_FOUND' + | 'AUTH_INSUFFICIENT_SCOPE' + | 'AUTH_USER_CODE_INVALID' + | 'AUTH_USER_CODE_ATTEMPTS_EXCEEDED' // ── Payment Gateway (Issue #581) ───────────────────────────────────────── | 'PAYMENT_GATEWAY_NOT_FOUND' | 'PAYMENT_GATEWAY_ERROR' @@ -260,6 +270,16 @@ export const ERROR_HTTP_STATUS_MAP: Record = { AUTH_PASSKEY_CREDENTIAL_NOT_FOUND: 404, AUTH_PASSKEY_CREDENTIAL_EXISTS: 409, AUTH_PASSKEY_COUNTER_REGRESSION: 401, + // Auth / Sessions, refresh tokens, password + device flow (#1776-#1779) + AUTH_INVALID_CREDENTIALS: 401, + AUTH_ACCOUNT_LOCKED: 429, + AUTH_PASSWORD_POLICY_VIOLATION: 422, + AUTH_REFRESH_TOKEN_INVALID: 401, + AUTH_REFRESH_TOKEN_REUSED: 401, + AUTH_SESSION_NOT_FOUND: 404, + AUTH_INSUFFICIENT_SCOPE: 403, + AUTH_USER_CODE_INVALID: 400, + AUTH_USER_CODE_ATTEMPTS_EXCEEDED: 429, // Payment Gateway (Issue #581) PAYMENT_GATEWAY_NOT_FOUND: 404, PAYMENT_GATEWAY_ERROR: 502, diff --git a/backend/services/shared/events.ts b/backend/services/shared/events.ts index 3c9f29da..3464abbc 100644 --- a/backend/services/shared/events.ts +++ b/backend/services/shared/events.ts @@ -219,8 +219,20 @@ export interface ApiKeyLeakDetectedPayload extends Record { detectedAt: number; } +/** A rotated refresh token was presented again; its whole family was revoked (#1776). */ +export interface RefreshTokenReuseDetectedPayload extends Record { + familyId: string; + sessionId: string; + userId: string; + /** Generation of the token that was replayed vs. the family's current one. */ + presentedGeneration: number; + currentGeneration: number; + detectedAt: number; +} + export type AuthEvent = | DomainEvent<'auth', 'api_key_rotated', ApiKeyRotatedPayload> + | DomainEvent<'auth', 'refresh_token_reuse_detected', RefreshTokenReuseDetectedPayload> | DomainEvent<'auth', 'api_key_revoked', ApiKeyRevokedPayload> | DomainEvent<'auth', 'api_key_leak_detected', ApiKeyLeakDetectedPayload> | DomainEvent<'auth', 'sso_session_created', SsoSessionCreatedPayload>; diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 762d49c3..cb41c0e9 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -37,5 +37,11 @@ If a strategy validates the request, authentication succeeds immediately. If a s ## Passwordless Login with Passkeys Users can register WebAuthn passkeys while signed in and later sign in without a password. A successful passkey assertion issues a regular server session (`X-Session-Token`). See [passkeys.md](./passkeys.md) for the flow, configuration and API. +## Sessions, Refresh-Token Rotation and Password Fallback +Every sign-in (passkey, password, device flow) issues a session through `SessionTokenService`: a short-lived access token plus a refresh token that rotates on every use. Replaying a rotated refresh token revokes the whole session. Users can list their sessions and revoke any of them remotely, with immediate effect. Passwords are a fallback sign-in method next to passkeys. See [sessions-and-tokens.md](./sessions-and-tokens.md). + +## OAuth Device Authorization Flow +CLIs and other input-constrained devices sign in with the RFC 8628 device code flow; the issued token is limited to the scopes the user approved. See [oauth-device-flow.md](./oauth-device-flow.md). + ## API Key Revocation & Leak Detection API keys can be revoked instantly, and exposed keys are detected by scanning content, matching breach-feed hashes and watching for usage anomalies — with automatic revocation or flagging per merchant policy. See [api-key-revocation.md](./api-key-revocation.md). diff --git a/docs/oauth-device-flow.md b/docs/oauth-device-flow.md new file mode 100644 index 00000000..a5d62b9f --- /dev/null +++ b/docs/oauth-device-flow.md @@ -0,0 +1,135 @@ +# OAuth Device Authorization Flow (RFC 8628) + +Issue #1779. Lets the SubTrackr CLI and other input-constrained devices (TVs, kiosks, headless scripts) sign a user in without typing a password on the device. The user approves the request on a phone or laptop where they are already signed in. + +The issued session is a normal SubTrackr session (see [sessions-and-tokens.md](./sessions-and-tokens.md)). It has a rotating refresh token, appears in the user's session list, and can be revoked remotely. It is limited to the scopes the user approved. + +--- + +## Flow + +``` +Device (CLI) SubTrackr API User's browser / app + │ POST /oauth/device_authorization ──► │ + │ client_id, scope │ device_code, user_code, │ + │ ◄───────────────────────────────────│ verification_uri, expires_in, interval + │ │ + │ show: "Open https://app.subtrackr.app/device and enter BDWX-KTPQ" │ + │ │ + │ POST /oauth/token (device_code) ──► │ 400 authorization_pending │ + │ … every `interval` seconds … │ │ + │ │ ◄── GET /oauth/device?user_code=… │ (signed in) + │ │ ──► client name + requested scopes │ + │ │ ◄── POST /oauth/device/approve │ + │ POST /oauth/token (device_code) ──► │ │ + │ ◄── 200 access_token, refresh_token, scope │ +``` + +## Endpoints + +All under `/api/v1/auth`. The device-facing endpoints accept `application/x-www-form-urlencoded` (as RFC 8628 requires) and JSON. They are exempt from the CSRF check because they carry no cookies. The approval endpoints are used by a signed-in browser or app, so they require CSRF and a session holding `auth:manage`. + +### 1. Device authorization request (device) + +```bash +curl -X POST https://api.subtrackr.app/api/v1/auth/oauth/device_authorization \ + -d client_id=subtrackr-cli \ + -d 'scope=subscriptions:read sessions:read' +``` + +```json +{ + "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", + "user_code": "BDWX-KTPQ", + "verification_uri": "https://app.subtrackr.app/device", + "verification_uri_complete": "https://app.subtrackr.app/device?user_code=BDWX-KTPQ", + "expires_in": 600, + "interval": 5 +} +``` + +`scope` is space-delimited. If you omit it, the client's default scopes are used. Each scope must be registered for the client (`400 invalid_scope` otherwise). An unknown `client_id` gets `401 invalid_client`. + +### 2. User approval (signed-in user) + +| Method | Path | Body | Description | +|--------|------|------|-------------| +| `GET` | `/oauth/device?user_code=BDWX-KTPQ` | — | Client name, requested scopes and expiry, for the consent screen. Does not consume the code | +| `POST` | `/oauth/device/approve` | `{ user_code, scope? }` | Approve. `scope` optionally narrows the request (it can never widen it) | +| `POST` | `/oauth/device/deny` | `{ user_code }` | Deny | + +User codes are case-insensitive, and the dash and spaces are optional. Each code is single-use: the first approve or deny consumes it. The approver can only grant scopes their own session holds. + +### 3. Token polling (device) + +```bash +curl -X POST https://api.subtrackr.app/api/v1/auth/oauth/token \ + -d grant_type=urn:ietf:params:oauth:grant-type:device_code \ + -d device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS \ + -d client_id=subtrackr-cli +``` + +| Response | Meaning | What the device should do | +|----------|---------|---------------------------| +| `400 {"error":"authorization_pending"}` | User has not decided yet | Wait `interval` seconds, poll again | +| `400 {"error":"slow_down"}` | Polled faster than `interval` | Add 5 s to the interval (the server already did), keep polling | +| `400 {"error":"access_denied"}` | User denied | Stop | +| `400 {"error":"expired_token"}` | Codes expired (10 min) | Start over at step 1 | +| `400 {"error":"invalid_grant"}` | Unknown device code, another client's code, or already redeemed | Stop | +| `200` | Approved | Store the tokens | + +```json +{ + "access_token": "9f2c…", + "token_type": "Bearer", + "expires_in": 1800, + "refresh_token": "rt_…", + "scope": "sessions:read" +} +``` + +Use the access token as `Authorization: Bearer …`. Refresh it with `grant_type=refresh_token` on the same endpoint. The refresh token rotates on every use and the scopes stay the same ([details](./sessions-and-tokens.md#refresh-token-rotation-and-reuse-detection)). + +## Scopes Are Enforced + +- The token can only do what was approved. A device token with `sessions:read` can list sessions but gets `403 AUTH_INSUFFICIENT_SCOPE` on `DELETE /sessions/:id` (`sessions:write`). +- `*` and `auth:manage` can never be granted through the device flow, even if a client is misconfigured to allow them. A device therefore cannot manage passkeys, change the password, or approve *other* device sign-ins. +- The user sees the session in `GET /sessions` (`authMethod: "device_code"`, `clientId`, `scopes`) and can revoke it at any time. + +## Security + +- `device_code` (256 bits) and `user_code` are stored only as HMAC-SHA256 digests under a per-process key. +- User codes are 8 characters from `BCDFGHJKLMNPQRSTVWXZ` (no vowels, no look-alikes; about 2.5 × 10¹⁰ combinations) and expire after 10 minutes. +- 10 wrong user codes within 15 minutes from one approver return `429 AUTH_USER_CODE_ATTEMPTS_EXCEEDED`. +- Device codes are single-use. Polling faster than `interval` widens the interval by 5 s (RFC 8628 §3.5). +- Token responses carry `Cache-Control: no-store`. + +## Configuration + +| Env var | Default | Meaning | +|---------|---------|---------| +| `DEVICE_FLOW_VERIFICATION_URI` | `http://localhost:8081/device` | Page where users enter the code | +| `DEVICE_FLOW_CLIENTS` | `subtrackr-cli` (see below) | JSON map of `client_id` → `{ name, allowedScopes, defaultScopes? }` | + +Default client: + +```json +{ + "subtrackr-cli": { + "name": "SubTrackr CLI", + "allowedScopes": ["profile:read", "subscriptions:read", "subscriptions:write", "sessions:read"], + "defaultScopes": ["profile:read", "subscriptions:read"] + } +} +``` + +Code TTL (10 min), polling interval (5 s) and code length (8) are `DeviceFlowConfig` options on `DeviceAuthorizationService`. + +## Testing + +```bash +npx jest --config jest.backend.config.js backend/services/auth/__tests__/DeviceAuthorizationService.test.ts \ + backend/services/auth/__tests__/sessionRouter.test.ts +``` + +The HTTP suite runs the flow end to end against a real Express server: authorization request, `authorization_pending`, `slow_down`, review, narrowed approval, token issuance, single-use device code, scope enforcement (`403` on routes outside the grant, no passkey management, no chained approvals), refresh keeping the scope, and remote revocation. It also covers deny → `access_denied`, expiry → `expired_token`, and invalid client / scope. diff --git a/docs/passkeys.md b/docs/passkeys.md index 3a7ef913..3bfc7e23 100644 --- a/docs/passkeys.md +++ b/docs/passkeys.md @@ -27,7 +27,7 @@ SubTrackr supports passwordless sign-in with **passkeys** (WebAuthn / FIDO2 cred | `PasskeyController` | `backend/services/auth/controller/passkeyController.ts` | Wraps service calls in the standard `ApiResponse` envelope | | `createPasskeyRouter` | `backend/services/auth/router/authRouter.ts` | Express routes mounted at `/api/v1/auth`; issues a server session on successful login | -A successful passkey login creates a regular server session through `serverSessionService` (the same mechanism as `POST /sessions`), so device tracking, concurrent-session limits and revocation work unchanged. The session metadata records `authMethod: "passkey"` and the credential ID. +A successful passkey login creates a regular server session through `SessionTokenService.issue()`, which wraps `serverSessionService` (the same mechanism as `POST /sessions`), so device tracking, concurrent-session limits and revocation work unchanged. Since #1776 the login also returns a rotating refresh token (`X-Refresh-Token`), and since #1778 a password can be used as a fallback sign-in method that issues exactly the same session pair. See [sessions-and-tokens.md](./sessions-and-tokens.md). The session metadata records `authMethod: "passkey"` and the credential ID. Supported algorithms: **ES256** (`-7`), **EdDSA / Ed25519** (`-8`) and **RS256** (`-257`). Supported attestation formats: **`none`** (requested by default) and **`packed`** (self attestation or `x5c` signature check). @@ -48,7 +48,7 @@ Other options (`challengeTtlMs` — 5 minutes, `userVerification` — `preferred ## Registration Flow -Adding a passkey requires an existing authenticated session (`X-Session-Token`), so a passkey can only be attached to the account that is currently signed in. +Adding a passkey requires an existing authenticated session (`X-Session-Token`) holding the `auth:manage` scope, so a passkey can only be attached to the account that is currently signed in. Interactive sessions (passkey or password) have every scope; tokens obtained through the [device flow](./oauth-device-flow.md) never get `auth:manage` and receive `403 AUTH_INSUFFICIENT_SCOPE`. ``` Client Backend @@ -69,9 +69,24 @@ Client Backend │ ◄── PublicKeyCredentialRequestOptions │ navigator.credentials.get() │ POST /passkeys/login/verify ──► verify signature + counter - │ ◄── 200 + X-Session-Token header create server session + │ ◄── 200 + X-Session-Token create server session + + │ + X-Refresh-Token refresh-token family ``` +### Fallback to password (#1778) + +Passkey sign-in can fail for reasons that are not an attack: the device has no platform authenticator, the user dismissed the prompt, or the passkey was deleted. Clients should then offer the password form: + +``` +navigator.credentials.get() unavailable / NotAllowedError + or POST /passkeys/login/verify → 4xx + │ + ▼ +POST /login/password { userId, password } → 200 + X-Session-Token + X-Refresh-Token +``` + +Both paths return the same headers and body shape and create the same kind of session, so the rest of the client does not need to know which one was used. A password is set (or changed, which requires the current password) with `PUT /api/v1/auth/password`. See [sessions-and-tokens.md](./sessions-and-tokens.md#password-fallback). + --- ## Security Checks @@ -98,7 +113,7 @@ All routes are mounted at `/api/v1/auth` and return the standard `ApiResponse` e | `POST` | `/passkeys/register/options` | `X-Session-Token` | `{ userName, displayName? }` | Creation options for `navigator.credentials.create()` | | `POST` | `/passkeys/register/verify` | `X-Session-Token` | `{ response, deviceName? }` | Verify and store the new passkey | | `POST` | `/passkeys/login/options` | — | `{ userId? }` | Request options for `navigator.credentials.get()` | -| `POST` | `/passkeys/login/verify` | — | `{ response }` | Verify the assertion; returns `X-Session-Token` header | +| `POST` | `/passkeys/login/verify` | — | `{ response }` | Verify the assertion; returns `X-Session-Token` and `X-Refresh-Token` headers | | `GET` | `/passkeys` | `X-Session-Token` | — | List the caller's passkeys (public keys are never returned) | | `DELETE` | `/passkeys/:credentialId` | `X-Session-Token` | — | Remove one of the caller's passkeys | @@ -116,6 +131,7 @@ All routes are mounted at `/api/v1/auth` and return the standard `ApiResponse` e | `AUTH_PASSKEY_CREDENTIAL_EXISTS` | 409 | Credential already registered | | `AUTH_PASSKEY_COUNTER_REGRESSION` | 401 | Signature counter did not increase — credential suspended | | `UNAUTHORIZED` | 401 | Missing or invalid `X-Session-Token` on a management route | +| `AUTH_INSUFFICIENT_SCOPE` | 403 | The session lacks `auth:manage` (e.g. a device-flow token) | --- @@ -153,6 +169,25 @@ const res = await api.post('/api/v1/auth/passkeys/login/verify', { }, }); const sessionToken = res.headers['x-session-token']; +const refreshToken = res.headers['x-refresh-token']; // store securely; rotates on every refresh +``` + +Falling back to the password when the passkey path fails: + +```ts +async function signIn(userId: string, askPassword: () => Promise) { + try { + return await signInWithPasskey(userId); // the code above + } catch { + // No authenticator, prompt dismissed, or the server rejected the assertion. + const password = await askPassword(); + const res = await api.post('/api/v1/auth/login/password', { userId, password }); + return { + sessionToken: res.headers['x-session-token'], + refreshToken: res.headers['x-refresh-token'], + }; + } +} ``` --- diff --git a/docs/sessions-and-tokens.md b/docs/sessions-and-tokens.md new file mode 100644 index 00000000..3a4344ae --- /dev/null +++ b/docs/sessions-and-tokens.md @@ -0,0 +1,190 @@ +# Sessions, Refresh-Token Rotation and Password Fallback + +Issues #1776 (refresh-token rotation), #1777 (session listing and remote revocation) and #1778 (password fallback for passkey login). + +All sign-in methods (passkey, password and the [OAuth device flow](./oauth-device-flow.md)) issue sessions through one service, so rotation, listing and revocation behave the same whichever method was used. + +--- + +## Table of Contents + +- [Model](#model) +- [Tokens](#tokens) +- [Refresh-Token Rotation and Reuse Detection](#refresh-token-rotation-and-reuse-detection) +- [Sessions: Listing and Revocation](#sessions-listing-and-revocation) +- [Password Fallback](#password-fallback) +- [Scopes](#scopes) +- [API Reference](#api-reference) +- [Error Codes](#error-codes) +- [Configuration](#configuration) +- [Testing](#testing) + +--- + +## Model + +``` + ┌──────────────── session (ServerSessionService) ───────────────┐ + sign-in ──► │ id: sess_… userId device geo scopes metadata.authMethod │ + (passkey, │ token = access token (opaque, 30 min sliding TTL) │ + password, └──────────────────────────────┬────────────────────────────────┘ + device flow) │ 1 : 1 + ┌──────────────── refresh-token family (SessionTokenService) ───┐ + │ currentTokenHash generation expiresAt absoluteExpiresAt │ + │ hashes of every token already rotated away │ + └───────────────────────────────────────────────────────────────┘ +``` + +| Component | File | Role | +|-----------|------|------| +| `ServerSessionService` | `backend/services/auth/serverSessionService.ts` | Session records, device binding, access-token validation. Now carries `scopes`, can rotate a session's access token, and notifies listeners on every revocation | +| `SessionTokenService` | `backend/services/auth/domain/SessionTokenService.ts` | Issues session + refresh-token family, rotates on refresh, detects reuse, lists and revokes sessions | +| `PasswordCredentialService` | `backend/services/auth/domain/PasswordCredentialService.ts` | Password hashes, verification, lockout | +| `createRequireAuth` | `backend/services/auth/middleware/sessionAuth.ts` | Bearer / `X-Session-Token` authentication with scope enforcement | +| `createSessionRouter` | `backend/services/auth/router/sessionRouter.ts` | Routes, mounted at `/api/v1/auth` | + +## Tokens + +| Token | Format | Lifetime | Stored as | +|-------|--------|----------|-----------| +| Access token | 64 hex chars | 30 min, sliding on use | Session record key (unchanged from #1160) | +| Refresh token | `rt_` + 43 base64url chars (256 bits) | 7 days idle, 30 days absolute | SHA-256 digest only | + +Interactive sign-ins return the tokens in headers, keeping them out of response bodies (the same convention as `POST /sessions`): + +``` +X-Session-Token: +X-Refresh-Token: +Cache-Control: no-store +``` + +The access token is sent as `Authorization: Bearer ` or `X-Session-Token: `. + +## Refresh-Token Rotation and Reuse Detection + +`POST /api/v1/auth/oauth/token` with `grant_type=refresh_token` (form-encoded or JSON): + +```bash +curl -X POST https://api.subtrackr.app/api/v1/auth/oauth/token \ + -d grant_type=refresh_token -d refresh_token=rt_… +``` + +```json +{ + "access_token": "9f2c…", + "token_type": "Bearer", + "expires_in": 1800, + "refresh_token": "rt_…", + "scope": "*" +} +``` + +- **Rotation on use.** Each successful refresh returns a new refresh token *and* a new access token. Both previous tokens stop working at once. The session keeps its `id`, so it shows up as the same entry in the session list. +- **Reuse detection.** The family remembers the digest of every refresh token it has issued. If a token that was already rotated away is presented again, either the client or an attacker holds a stolen copy, and the server cannot tell which. So the whole family is revoked: the session, its current access token and its current refresh token. The request gets `400 invalid_grant`, an audit entry is written, and an `auth.refresh_token_reuse_detected` event is published. (OAuth 2.0 Security BCP, RFC 9700 §4.14.2.) +- **Device binding.** A refresh from a device whose fingerprint differs from the session's is refused without rotating, so the legitimate device's token keeps working (#1766). +- **Revival.** A session whose access token idled out can be resumed with its refresh token until the refresh token itself expires. A revoked session can never be resumed. + +> **Clients:** refresh is strictly single-use. Run at most one refresh at a time per session (serialise or de-duplicate concurrent refreshes). Two parallel refreshes with the same token would look like reuse and sign the user out. + +## Sessions: Listing and Revocation + +`GET /api/v1/auth/sessions` returns the caller's sessions that can still be used: the access token is live, or the refresh token can still mint a new one. Revoked sessions, and sessions whose access and refresh tokens have both lapsed, are excluded. The list never contains token material. + +```json +{ + "success": true, + "data": [ + { + "id": "sess_3fa1c0d29b7e", + "current": true, + "authMethod": "passkey", + "clientId": null, + "scopes": ["*"], + "device": { "platform": "web", "browser": "Chrome", "os": "macOS", "isMobile": false }, + "location": { "country": "NG", "region": null, "city": "Lagos" }, + "createdAt": 1760000000000, + "lastActiveAt": 1760000300000, + "accessTokenExpiresAt": 1760002100000, + "refreshTokenExpiresAt": 1760605100000, + "isSuspicious": false + } + ] +} +``` + +Revocation takes effect immediately. The session is marked revoked and its refresh-token family is revoked in the same call, so the revoked device's next request is rejected with `401`, and its next refresh with `invalid_grant`. This holds for every revocation path: `DELETE /sessions/:id`, `POST /sessions/revoke-others`, `POST /logout`, the legacy `/sessions/revoke*` routes in `backend/server.ts`, and concurrent-session eviction. All of them go through `ServerSessionService.revokeSession`, which notifies the token service. + +Users can only revoke their own sessions. Another user's session id returns `404 AUTH_SESSION_NOT_FOUND`, the same as an unknown id. + +## Password Fallback + +Passkeys stay the primary sign-in method. A password is the fallback for devices without an authenticator, a dismissed WebAuthn prompt, or a lost passkey (see [passkeys.md](./passkeys.md#fallback-to-password)). + +- `PUT /api/v1/auth/password` `{ newPassword, currentPassword? }` sets the password for the signed-in user (requires `auth:manage`). Changing an existing password requires `currentPassword`. +- `POST /api/v1/auth/login/password` `{ userId, password }` signs in and returns the same headers and body as a passkey login. +- Passwords are hashed with the repo's Argon2id-style KDF (`argon2Kdf.ts`) and compared in constant time. Policy: 12–256 characters. +- An unknown user and a wrong password give the identical `401 AUTH_INVALID_CREDENTIALS`, and unknown users are checked against a dummy hash so timing does not reveal which accounts exist. +- 5 failures within 15 minutes lock the account for 15 minutes (`429 AUTH_ACCOUNT_LOCKED` with `Retry-After`). While the lock is active, the correct password is refused too. + +## Scopes + +Sessions carry `scopes`. Interactive sign-ins get `["*"]`. Device-flow sessions get only the scopes the user approved, and keep them across refreshes. Routes declare the scope they need, and a token without it receives `403 AUTH_INSUFFICIENT_SCOPE` with `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"` (RFC 6750 §3.1). + +| Scope | Grants | +|-------|--------| +| `sessions:read` | `GET /sessions` | +| `sessions:write` | `DELETE /sessions/:id`, `POST /sessions/revoke-others` | +| `auth:manage` | Passkey management, `PUT /password`, approving device sign-ins. Never grantable through the device flow | +| `profile:read`, `subscriptions:read`, `subscriptions:write` | Reserved for resource routes; use `createRequireAuth({ scope })` to enforce them | + +Scope matching uses `hasScope` from `ApiKeyRotationService` (`*` and `prefix:*` wildcards are supported). + +## API Reference + +All routes are under `/api/v1/auth`. + +| Method | Path | Auth | Body | Description | +|--------|------|------|------|-------------| +| `POST` | `/login/password` | — (CSRF) | `{ userId, password }` | Password sign-in; tokens in headers | +| `PUT` | `/password` | `auth:manage` | `{ newPassword, currentPassword? }` | Set or change password | +| `POST` | `/oauth/token` | — | `grant_type=refresh_token&refresh_token=…` | Rotate tokens (also serves the device flow) | +| `GET` | `/sessions` | `sessions:read` | — | List live sessions | +| `DELETE` | `/sessions/:sessionId` | `sessions:write` | — | Revoke one session | +| `POST` | `/sessions/revoke-others` | `sessions:write` | — | Revoke all other sessions | +| `POST` | `/logout` | any session | — | Revoke the calling session | + +`/oauth/token` and `/oauth/device_authorization` are exempt from the double-submit CSRF check. They are called by non-browser clients and carry no ambient credentials, because the refresh token or device code travels in the body. All other state-changing routes keep the CSRF check (`GET /api/v1/csrf-token` bootstraps the token). + +## Error Codes + +| Code | HTTP | Meaning | +|------|------|---------| +| `UNAUTHORIZED` | 401 | Missing, invalid, expired or revoked access token | +| `AUTH_INSUFFICIENT_SCOPE` | 403 | Token valid but lacks the route's scope | +| `AUTH_SESSION_NOT_FOUND` | 404 | No such session for the caller | +| `AUTH_INVALID_CREDENTIALS` | 401 | Unknown user or wrong password | +| `AUTH_ACCOUNT_LOCKED` | 429 | Too many failed password attempts | +| `AUTH_PASSWORD_POLICY_VIOLATION` | 422 | New password too short/long | +| `invalid_grant` (OAuth) | 400 | Refresh token unknown, expired, revoked or reused | + +## Configuration + +| Setting | Default | Where | +|---------|---------|-------| +| Access-token TTL | 30 min sliding | `ServerSessionService` `defaultTtlMs` / `slidingTtlMs` | +| Refresh-token idle TTL | 7 days | `SessionTokenService` `config.idleTtlMs` | +| Refresh-token absolute TTL | 30 days | `SessionTokenService` `config.absoluteTtlMs` | +| Password lockout | 5 failures / 15 min → 15 min lock | `PasswordCredentialService` config | +| Password KDF cost | `{ memoryCost: 1024, timeCost: 1, parallelism: 4 }` (~64 MiB, ~200 ms) | `DEFAULT_PASSWORD_POLICY.kdfParams` | + +Like the rest of the auth module, these stores are in memory. Families past their absolute lifetime are swept by the 5-minute session sweep in `backend/server.ts`. + +## Testing + +```bash +npx jest --config jest.backend.config.js backend/services/auth +``` + +- `SessionTokenService.test.ts`: rotation, revival, device binding, reuse detection (family plus session revoked, event published), expiry, revocation through every path, listing accuracy, sweep. +- `PasswordCredentialService.test.ts`: hashing, policy, change-requires-current, identical errors, lockout window. +- `sessionRouter.test.ts`: a real Express server. Covers passkey failure → password fallback, passkey login → refresh, rotation and reuse over HTTP, list → remote revoke → next request 401, cross-user revocation, and the device flow with scope enforcement.