Spaces:
Sleeping
Sleeping
| /** | |
| * Unit tests for server/src/services/oauthService.ts. | |
| */ | |
| import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; | |
| import crypto from 'crypto'; | |
| const { testDb, dbMock } = vi.hoisted(() => { | |
| const Database = require('better-sqlite3'); | |
| const db = new Database(':memory:'); | |
| db.exec('PRAGMA journal_mode = WAL'); | |
| db.exec('PRAGMA foreign_keys = ON'); | |
| db.exec('PRAGMA busy_timeout = 5000'); | |
| const mock = { | |
| db, | |
| closeDb: () => {}, | |
| reinitialize: () => {}, | |
| getPlaceWithTags: () => null, | |
| canAccessTrip: (tripId: any, userId: number) => | |
| db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), | |
| isOwner: (tripId: any, userId: number) => | |
| !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), | |
| }; | |
| return { testDb: db, dbMock: mock }; | |
| }); | |
| vi.mock('../../../src/db/database', () => dbMock); | |
| vi.mock('../../../src/config', () => ({ | |
| JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', | |
| ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', | |
| updateJwtSecret: () => {}, | |
| })); | |
| vi.mock('../../../src/services/apiKeyCrypto', () => ({ | |
| encrypt_api_key: (v: string) => v, | |
| decrypt_api_key: (v: string) => v, | |
| maybe_encrypt_api_key: (v: string) => v, | |
| })); | |
| vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn(), sessions: new Map() })); | |
| vi.mock('../../../src/demo/demo-reset', () => ({ saveBaseline: vi.fn() })); | |
| vi.mock('../../../src/services/adminService', () => ({ | |
| isAddonEnabled: vi.fn().mockReturnValue(true), | |
| getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }), | |
| })); | |
| import { createTables } from '../../../src/db/schema'; | |
| import { runMigrations } from '../../../src/db/migrations'; | |
| import { resetTestDb } from '../../helpers/test-db'; | |
| import { createUser } from '../../helpers/factories'; | |
| // PKCE helper β generates a valid code_verifier + code_challenge pair (RFC 7636) | |
| function makePkce() { | |
| const verifier = crypto.randomBytes(32).toString('base64url'); // 43 chars | |
| const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); // 43 chars | |
| return { verifier, challenge }; | |
| } | |
| import { | |
| createOAuthClient, | |
| listOAuthClients, | |
| deleteOAuthClient, | |
| rotateOAuthClientSecret, | |
| createAuthCode, | |
| consumeAuthCode, | |
| issueTokens, | |
| getUserByAccessToken, | |
| refreshTokens, | |
| revokeToken, | |
| listOAuthSessions, | |
| revokeSession, | |
| validateAuthorizeRequest, | |
| verifyPKCE, | |
| authenticateClient, | |
| saveConsent, | |
| getConsent, | |
| isConsentSufficient, | |
| } from '../../../src/services/oauthService'; | |
| import { isAddonEnabled } from '../../../src/services/adminService'; | |
| beforeAll(() => { | |
| createTables(testDb); | |
| runMigrations(testDb); | |
| }); | |
| beforeEach(() => { | |
| resetTestDb(testDb); | |
| // Clear oauth tables manually since they're not in the standard reset list | |
| testDb.exec('DELETE FROM oauth_tokens'); | |
| testDb.exec('DELETE FROM oauth_consents'); | |
| testDb.exec('DELETE FROM oauth_clients'); | |
| vi.mocked(isAddonEnabled).mockReturnValue(true); | |
| }); | |
| afterAll(() => { | |
| testDb.close(); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Helper | |
| // --------------------------------------------------------------------------- | |
| function makeClient( | |
| userId: number, | |
| overrides: Partial<{ name: string; redirectUris: string[]; scopes: string[] }> = {} | |
| ) { | |
| return createOAuthClient( | |
| userId, | |
| overrides.name ?? 'Test Client', | |
| overrides.redirectUris ?? ['https://example.com/callback'], | |
| overrides.scopes ?? ['trips:read'], | |
| ); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // createOAuthClient | |
| // --------------------------------------------------------------------------- | |
| describe('createOAuthClient', () => { | |
| it('creates a client successfully and returns client_secret only on creation', () => { | |
| const { user } = createUser(testDb); | |
| const result = makeClient(user.id); | |
| expect(result.error).toBeUndefined(); | |
| expect(result.client).toBeDefined(); | |
| expect(typeof result.client!.client_secret).toBe('string'); | |
| expect((result.client!.client_secret as string).startsWith('trekcs_')).toBe(true); | |
| }); | |
| it('client_id is a UUID', () => { | |
| const { user } = createUser(testDb); | |
| const result = makeClient(user.id); | |
| expect(result.client!.client_id).toMatch( | |
| /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ | |
| ); | |
| }); | |
| it('returns 400 error if name is empty', () => { | |
| const { user } = createUser(testDb); | |
| const result = createOAuthClient(user.id, '', ['https://example.com/cb'], ['trips:read']); | |
| expect(result.status).toBe(400); | |
| expect(result.error).toContain('Name'); | |
| }); | |
| it('returns 400 error if name exceeds 100 characters', () => { | |
| const { user } = createUser(testDb); | |
| const longName = 'A'.repeat(101); | |
| const result = createOAuthClient(user.id, longName, ['https://example.com/cb'], ['trips:read']); | |
| expect(result.status).toBe(400); | |
| expect(result.error).toContain('100'); | |
| }); | |
| it('returns 400 error if no redirect URIs provided', () => { | |
| const { user } = createUser(testDb); | |
| const result = createOAuthClient(user.id, 'Test', [], ['trips:read']); | |
| expect(result.status).toBe(400); | |
| expect(result.error).toContain('redirect URI'); | |
| }); | |
| it('returns 400 error if more than 10 redirect URIs provided', () => { | |
| const { user } = createUser(testDb); | |
| const uris = Array.from({ length: 11 }, (_, i) => `https://example${i}.com/cb`); | |
| const result = createOAuthClient(user.id, 'Test', uris, ['trips:read']); | |
| expect(result.status).toBe(400); | |
| expect(result.error).toContain('10'); | |
| }); | |
| it('returns 400 error for invalid URI format', () => { | |
| const { user } = createUser(testDb); | |
| const result = createOAuthClient(user.id, 'Test', ['not-a-url'], ['trips:read']); | |
| expect(result.status).toBe(400); | |
| expect(result.error).toContain('Invalid redirect URI'); | |
| }); | |
| it('returns 400 error for non-https URI (not localhost)', () => { | |
| const { user } = createUser(testDb); | |
| const result = createOAuthClient(user.id, 'Test', ['http://example.com/cb'], ['trips:read']); | |
| expect(result.status).toBe(400); | |
| expect(result.error).toContain('HTTPS'); | |
| }); | |
| it('allows http://localhost redirect URI', () => { | |
| const { user } = createUser(testDb); | |
| const result = createOAuthClient(user.id, 'Test', ['http://localhost:3000/callback'], ['trips:read']); | |
| expect(result.error).toBeUndefined(); | |
| expect(result.client).toBeDefined(); | |
| }); | |
| it('allows http://127.0.0.1 redirect URI', () => { | |
| const { user } = createUser(testDb); | |
| const result = createOAuthClient(user.id, 'Test', ['http://127.0.0.1:5000/callback'], ['trips:read']); | |
| expect(result.error).toBeUndefined(); | |
| expect(result.client).toBeDefined(); | |
| }); | |
| it('returns 400 error if no scopes provided', () => { | |
| const { user } = createUser(testDb); | |
| const result = createOAuthClient(user.id, 'Test', ['https://example.com/cb'], []); | |
| expect(result.status).toBe(400); | |
| expect(result.error).toContain('scope'); | |
| }); | |
| it('returns 400 error for invalid scopes', () => { | |
| const { user } = createUser(testDb); | |
| const result = createOAuthClient(user.id, 'Test', ['https://example.com/cb'], ['invalid:scope']); | |
| expect(result.status).toBe(400); | |
| expect(result.error).toContain('Invalid scopes'); | |
| }); | |
| it('enforces max 10 clients per user', () => { | |
| const { user } = createUser(testDb); | |
| for (let i = 0; i < 10; i++) { | |
| const r = makeClient(user.id, { name: `Client ${i}` }); | |
| expect(r.error).toBeUndefined(); | |
| } | |
| const eleventh = makeClient(user.id, { name: 'Eleventh' }); | |
| expect(eleventh.status).toBe(400); | |
| expect(eleventh.error).toContain('10'); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // listOAuthClients | |
| // --------------------------------------------------------------------------- | |
| describe('listOAuthClients', () => { | |
| it('returns empty array for user with no clients', () => { | |
| const { user } = createUser(testDb); | |
| expect(listOAuthClients(user.id)).toEqual([]); | |
| }); | |
| it('returns created clients with redirect_uris and allowed_scopes as arrays', () => { | |
| const { user } = createUser(testDb); | |
| makeClient(user.id, { name: 'Client A', redirectUris: ['https://a.com/cb'], scopes: ['trips:read', 'budget:read'] }); | |
| const clients = listOAuthClients(user.id); | |
| expect(clients).toHaveLength(1); | |
| expect(clients[0].name).toBe('Client A'); | |
| expect(Array.isArray(clients[0].redirect_uris)).toBe(true); | |
| expect(Array.isArray(clients[0].allowed_scopes)).toBe(true); | |
| expect(clients[0].allowed_scopes).toContain('trips:read'); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // deleteOAuthClient | |
| // --------------------------------------------------------------------------- | |
| describe('deleteOAuthClient', () => { | |
| it('deletes own client successfully', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientRowId = created.client!.id as string; | |
| const result = deleteOAuthClient(user.id, clientRowId); | |
| expect(result.success).toBe(true); | |
| expect(listOAuthClients(user.id)).toHaveLength(0); | |
| }); | |
| it('returns 404 for non-existent client', () => { | |
| const { user } = createUser(testDb); | |
| const result = deleteOAuthClient(user.id, 'non-existent-id'); | |
| expect(result.status).toBe(404); | |
| }); | |
| it("returns 404 for another user's client", () => { | |
| const { user: owner } = createUser(testDb); | |
| const { user: other } = createUser(testDb); | |
| const created = makeClient(owner.id); | |
| const result = deleteOAuthClient(other.id, created.client!.id as string); | |
| expect(result.status).toBe(404); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // rotateOAuthClientSecret | |
| // --------------------------------------------------------------------------- | |
| describe('rotateOAuthClientSecret', () => { | |
| it('rotates secret and returns new client_secret starting with trekcs_', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const oldSecret = created.client!.client_secret as string; | |
| const result = rotateOAuthClientSecret(user.id, created.client!.id as string); | |
| expect(result.error).toBeUndefined(); | |
| expect(result.client_secret).toBeDefined(); | |
| expect((result.client_secret as string).startsWith('trekcs_')).toBe(true); | |
| expect(result.client_secret).not.toBe(oldSecret); | |
| }); | |
| it('returns 404 for non-existent client', () => { | |
| const { user } = createUser(testDb); | |
| const result = rotateOAuthClientSecret(user.id, 'non-existent-id'); | |
| expect(result.status).toBe(404); | |
| }); | |
| it('revokes old tokens after rotation', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const { access_token } = issueTokens(clientId, user.id, ['trips:read']); | |
| expect(getUserByAccessToken(access_token)).not.toBeNull(); | |
| rotateOAuthClientSecret(user.id, created.client!.id as string); | |
| expect(getUserByAccessToken(access_token)).toBeNull(); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // createAuthCode + consumeAuthCode | |
| // --------------------------------------------------------------------------- | |
| describe('createAuthCode + consumeAuthCode', () => { | |
| it('create code and consume it once returns the pending entry', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const code = createAuthCode({ | |
| clientId, | |
| userId: user.id, | |
| redirectUri: 'https://example.com/callback', | |
| scopes: ['trips:read'], | |
| codeChallenge: 'abc123', | |
| codeChallengeMethod: 'S256', | |
| }); | |
| const entry = consumeAuthCode(code); | |
| expect(entry).not.toBeNull(); | |
| expect(entry!.userId).toBe(user.id); | |
| expect(entry!.clientId).toBe(clientId); | |
| }); | |
| it('returns null for non-existent code', () => { | |
| expect(consumeAuthCode('does-not-exist')).toBeNull(); | |
| }); | |
| it('consuming same code twice returns null (one-time use)', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const code = createAuthCode({ | |
| clientId, | |
| userId: user.id, | |
| redirectUri: 'https://example.com/callback', | |
| scopes: ['trips:read'], | |
| codeChallenge: 'abc123', | |
| codeChallengeMethod: 'S256', | |
| }); | |
| consumeAuthCode(code); | |
| expect(consumeAuthCode(code)).toBeNull(); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // issueTokens + getUserByAccessToken | |
| // --------------------------------------------------------------------------- | |
| describe('issueTokens + getUserByAccessToken', () => { | |
| it('issues tokens with correct prefixes', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const tokens = issueTokens(clientId, user.id, ['trips:read']); | |
| expect(tokens.access_token.startsWith('trekoa_')).toBe(true); | |
| expect(tokens.refresh_token.startsWith('trekrf_')).toBe(true); | |
| expect(tokens.token_type).toBe('Bearer'); | |
| expect(typeof tokens.expires_in).toBe('number'); | |
| }); | |
| it('getUserByAccessToken returns user and scopes for a valid token', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const { access_token } = issueTokens(clientId, user.id, ['trips:read', 'budget:write']); | |
| const info = getUserByAccessToken(access_token); | |
| expect(info).not.toBeNull(); | |
| expect(info!.user.email).toBe(user.email); | |
| expect(info!.scopes).toContain('trips:read'); | |
| expect(info!.scopes).toContain('budget:write'); | |
| }); | |
| it('getUserByAccessToken returns null for unknown token', () => { | |
| expect(getUserByAccessToken('trekoa_unknown')).toBeNull(); | |
| }); | |
| it('getUserByAccessToken returns null for revoked token', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const { access_token } = issueTokens(clientId, user.id, ['trips:read']); | |
| revokeToken(access_token, clientId); | |
| expect(getUserByAccessToken(access_token)).toBeNull(); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // refreshTokens | |
| // --------------------------------------------------------------------------- | |
| describe('refreshTokens', () => { | |
| it('exchanges a refresh token for a new token pair', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const rawSecret = created.client!.client_secret as string; | |
| const { refresh_token } = issueTokens(clientId, user.id, ['trips:read']); | |
| const result = refreshTokens(refresh_token, clientId, rawSecret); | |
| expect(result.error).toBeUndefined(); | |
| expect(result.tokens).toBeDefined(); | |
| expect(result.tokens!.access_token.startsWith('trekoa_')).toBe(true); | |
| }); | |
| it('old tokens are revoked after refresh (rotation)', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const rawSecret = created.client!.client_secret as string; | |
| const { access_token, refresh_token } = issueTokens(clientId, user.id, ['trips:read']); | |
| refreshTokens(refresh_token, clientId, rawSecret); | |
| expect(getUserByAccessToken(access_token)).toBeNull(); | |
| }); | |
| it('returns invalid_grant for unknown refresh token', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const rawSecret = created.client!.client_secret as string; | |
| const result = refreshTokens('trekrf_unknown', clientId, rawSecret); | |
| expect(result.error).toBe('invalid_grant'); | |
| expect(result.status).toBe(400); | |
| }); | |
| it('returns invalid_grant for revoked token', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const rawSecret = created.client!.client_secret as string; | |
| const { access_token, refresh_token } = issueTokens(clientId, user.id, ['trips:read']); | |
| revokeToken(access_token, clientId); | |
| const result = refreshTokens(refresh_token, clientId, rawSecret); | |
| expect(result.error).toBe('invalid_grant'); | |
| }); | |
| it('returns invalid_client for wrong client_secret', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const { refresh_token } = issueTokens(clientId, user.id, ['trips:read']); | |
| const result = refreshTokens(refresh_token, clientId, 'wrong-secret'); | |
| expect(result.error).toBe('invalid_client'); | |
| expect(result.status).toBe(401); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // revokeToken | |
| // --------------------------------------------------------------------------- | |
| describe('revokeToken', () => { | |
| it('after revoking access token, getUserByAccessToken returns null', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const { access_token } = issueTokens(clientId, user.id, ['trips:read']); | |
| expect(getUserByAccessToken(access_token)).not.toBeNull(); | |
| revokeToken(access_token, clientId); | |
| expect(getUserByAccessToken(access_token)).toBeNull(); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // listOAuthSessions + revokeSession | |
| // --------------------------------------------------------------------------- | |
| describe('listOAuthSessions + revokeSession', () => { | |
| it('lists active sessions', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| issueTokens(clientId, user.id, ['trips:read']); | |
| const sessions = listOAuthSessions(user.id); | |
| expect(sessions).toHaveLength(1); | |
| expect(sessions[0].client_id).toBe(clientId); | |
| }); | |
| it('revoked session is not listed', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const { access_token } = issueTokens(clientId, user.id, ['trips:read']); | |
| revokeToken(access_token, clientId); | |
| const sessions = listOAuthSessions(user.id); | |
| expect(sessions).toHaveLength(0); | |
| }); | |
| it('revokeSession returns 404 for unknown session', () => { | |
| const { user } = createUser(testDb); | |
| const result = revokeSession(user.id, 99999); | |
| expect(result.status).toBe(404); | |
| }); | |
| it('revokeSession by session id removes session from list', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| issueTokens(clientId, user.id, ['trips:read']); | |
| const sessions = listOAuthSessions(user.id); | |
| const sessionId = sessions[0].id as number; | |
| const result = revokeSession(user.id, sessionId); | |
| expect(result.success).toBe(true); | |
| expect(listOAuthSessions(user.id)).toHaveLength(0); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // validateAuthorizeRequest | |
| // --------------------------------------------------------------------------- | |
| describe('validateAuthorizeRequest', () => { | |
| // Use a proper 43-char S256 code_challenge to pass H1 format validation | |
| const { challenge: VALID_CHALLENGE } = makePkce(); | |
| function makeParams(overrides: Partial<{ | |
| response_type: string; | |
| client_id: string; | |
| redirect_uri: string; | |
| scope: string; | |
| code_challenge: string; | |
| code_challenge_method: string; | |
| }> = {}) { | |
| return { | |
| response_type: 'code', | |
| client_id: '', | |
| redirect_uri: 'https://example.com/callback', | |
| scope: 'trips:read', | |
| code_challenge: VALID_CHALLENGE, | |
| code_challenge_method: 'S256', | |
| ...overrides, | |
| }; | |
| } | |
| it('returns mcp_disabled when isAddonEnabled returns false', () => { | |
| vi.mocked(isAddonEnabled).mockReturnValue(false); | |
| const result = validateAuthorizeRequest(makeParams({ client_id: 'x' }), null); | |
| expect(result.valid).toBe(false); | |
| expect(result.error).toBe('mcp_disabled'); | |
| }); | |
| it('requires response_type=code', () => { | |
| const { user } = createUser(testDb); | |
| const result = validateAuthorizeRequest(makeParams({ response_type: 'token', client_id: 'x' }), user.id); | |
| expect(result.valid).toBe(false); | |
| expect(result.error).toBe('unsupported_response_type'); | |
| }); | |
| it('requires PKCE with S256', () => { | |
| const { user } = createUser(testDb); | |
| const result = validateAuthorizeRequest(makeParams({ client_id: 'x', code_challenge_method: 'plain' }), user.id); | |
| expect(result.valid).toBe(false); | |
| expect(result.error).toBe('invalid_request'); | |
| }); | |
| it('requires valid client_id', () => { | |
| const { user } = createUser(testDb); | |
| const result = validateAuthorizeRequest(makeParams({ client_id: 'nonexistent' }), user.id); | |
| expect(result.valid).toBe(false); | |
| expect(result.error).toBe('invalid_client'); | |
| }); | |
| it('validates redirect_uri against registered URIs', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id, { redirectUris: ['https://example.com/callback'] }); | |
| const clientId = created.client!.client_id as string; | |
| const result = validateAuthorizeRequest( | |
| makeParams({ client_id: clientId, redirect_uri: 'https://evil.com/callback' }), | |
| user.id | |
| ); | |
| expect(result.valid).toBe(false); | |
| expect(result.error).toBe('invalid_redirect_uri'); | |
| }); | |
| it('validates scope against client allowed_scopes', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id, { scopes: ['trips:read'] }); | |
| const clientId = created.client!.client_id as string; | |
| const result = validateAuthorizeRequest( | |
| makeParams({ client_id: clientId, scope: 'budget:write' }), | |
| user.id | |
| ); | |
| expect(result.valid).toBe(false); | |
| expect(result.error).toBe('invalid_scope'); | |
| }); | |
| it('returns loginRequired when userId is null', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const result = validateAuthorizeRequest(makeParams({ client_id: clientId }), null); | |
| expect(result.valid).toBe(true); | |
| expect(result.loginRequired).toBe(true); | |
| }); | |
| it('returns consentRequired=true when consent not yet saved', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const result = validateAuthorizeRequest(makeParams({ client_id: clientId }), user.id); | |
| expect(result.valid).toBe(true); | |
| expect(result.consentRequired).toBe(true); | |
| }); | |
| it('returns consentRequired=false when consent already saved and sufficient', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| saveConsent(clientId, user.id, ['trips:read']); | |
| const result = validateAuthorizeRequest(makeParams({ client_id: clientId }), user.id); | |
| expect(result.valid).toBe(true); | |
| expect(result.consentRequired).toBe(false); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // verifyPKCE | |
| // --------------------------------------------------------------------------- | |
| describe('verifyPKCE', () => { | |
| it('returns true for valid code_verifier / code_challenge pair (SHA256 base64url)', () => { | |
| const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; | |
| const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); | |
| expect(verifyPKCE(verifier, challenge)).toBe(true); | |
| }); | |
| it('returns false for wrong verifier', () => { | |
| const verifier = 'correct-verifier'; | |
| const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); | |
| expect(verifyPKCE('wrong-verifier', challenge)).toBe(false); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // authenticateClient | |
| // --------------------------------------------------------------------------- | |
| describe('authenticateClient', () => { | |
| it('returns client row for correct credentials', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const rawSecret = created.client!.client_secret as string; | |
| const client = authenticateClient(clientId, rawSecret); | |
| expect(client).not.toBeNull(); | |
| expect(client!.client_id).toBe(clientId); | |
| }); | |
| it('returns null for wrong secret', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| expect(authenticateClient(clientId, 'wrong-secret')).toBeNull(); | |
| }); | |
| it('returns null for unknown client_id', () => { | |
| expect(authenticateClient('unknown-client-id', 'any-secret')).toBeNull(); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // saveConsent + getConsent + isConsentSufficient | |
| // --------------------------------------------------------------------------- | |
| describe('saveConsent + getConsent + isConsentSufficient', () => { | |
| it('saves and retrieves consent', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| saveConsent(clientId, user.id, ['trips:read', 'budget:write']); | |
| const consent = getConsent(clientId, user.id); | |
| expect(consent).not.toBeNull(); | |
| expect(consent).toContain('trips:read'); | |
| expect(consent).toContain('budget:write'); | |
| }); | |
| it('isConsentSufficient returns true when all requested scopes are in existing', () => { | |
| expect(isConsentSufficient(['trips:read', 'budget:write'], ['trips:read'])).toBe(true); | |
| expect(isConsentSufficient(['trips:read', 'budget:write'], ['trips:read', 'budget:write'])).toBe(true); | |
| }); | |
| it('isConsentSufficient returns false when some scopes are missing', () => { | |
| expect(isConsentSufficient(['trips:read'], ['trips:read', 'budget:write'])).toBe(false); | |
| expect(isConsentSufficient([], ['trips:read'])).toBe(false); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // M5 β saveConsent unions instead of replacing | |
| // --------------------------------------------------------------------------- | |
| describe('saveConsent β scope union (M5)', () => { | |
| it('unioning scopes: approving B after A leaves both in consent', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id, { scopes: ['trips:read', 'budget:write'] }); | |
| const clientId = created.client!.client_id as string; | |
| saveConsent(clientId, user.id, ['trips:read']); | |
| saveConsent(clientId, user.id, ['budget:write']); | |
| const consent = getConsent(clientId, user.id); | |
| expect(consent).toContain('trips:read'); | |
| expect(consent).toContain('budget:write'); | |
| }); | |
| it('re-approving a superset scope still preserves previously-consented scopes', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id, { scopes: ['trips:read', 'trips:write'] }); | |
| const clientId = created.client!.client_id as string; | |
| saveConsent(clientId, user.id, ['trips:read', 'trips:write']); | |
| // approve only trips:read on a later request | |
| saveConsent(clientId, user.id, ['trips:read']); | |
| const consent = getConsent(clientId, user.id); | |
| // trips:write should NOT be removed (union semantics) | |
| expect(consent).toContain('trips:read'); | |
| expect(consent).toContain('trips:write'); | |
| }); | |
| it('consent is sufficient after sequential approvals β no re-prompt needed', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id, { scopes: ['trips:read', 'budget:write'] }); | |
| const clientId = created.client!.client_id as string; | |
| saveConsent(clientId, user.id, ['trips:read']); | |
| saveConsent(clientId, user.id, ['budget:write']); | |
| // Should not require consent again for either scope | |
| expect(isConsentSufficient(getConsent(clientId, user.id)!, ['trips:read'])).toBe(true); | |
| expect(isConsentSufficient(getConsent(clientId, user.id)!, ['budget:write'])).toBe(true); | |
| expect(isConsentSufficient(getConsent(clientId, user.id)!, ['trips:read', 'budget:write'])).toBe(true); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // C2 β getUserByAccessToken returns clientId | |
| // --------------------------------------------------------------------------- | |
| describe('getUserByAccessToken β includes clientId (C2)', () => { | |
| it('returns clientId matching the issuing OAuth client', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const { access_token } = issueTokens(clientId, user.id, ['trips:read']); | |
| const info = getUserByAccessToken(access_token); | |
| expect(info).not.toBeNull(); | |
| expect(info!.clientId).toBe(clientId); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // C3 β Refresh token replay detection and chain revocation | |
| // --------------------------------------------------------------------------- | |
| describe('refreshTokens β replay detection (C3)', () => { | |
| it('replaying a revoked refresh token returns invalid_grant', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const rawSecret = created.client!.client_secret as string; | |
| // Issue tokens, then rotate once (old token becomes revoked) | |
| const { refresh_token: firstRefresh } = issueTokens(clientId, user.id, ['trips:read']); | |
| const rotateResult = refreshTokens(firstRefresh, clientId, rawSecret); | |
| expect(rotateResult.error).toBeUndefined(); | |
| const { refresh_token: secondRefresh } = rotateResult.tokens!; | |
| // Replay the FIRST (now revoked) refresh token | |
| const replayResult = refreshTokens(firstRefresh, clientId, rawSecret); | |
| expect(replayResult.error).toBe('invalid_grant'); | |
| expect(replayResult.status).toBe(400); | |
| }); | |
| it('replaying a revoked token also revokes the entire rotation chain', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const rawSecret = created.client!.client_secret as string; | |
| // Issue β rotate once | |
| const { refresh_token: first } = issueTokens(clientId, user.id, ['trips:read']); | |
| const r1 = refreshTokens(first, clientId, rawSecret); | |
| const { access_token: access2, refresh_token: second } = r1.tokens!; | |
| // Replay first (revoked) refresh token β chain revoke | |
| refreshTokens(first, clientId, rawSecret); | |
| // The rotated access token should also be dead now | |
| expect(getUserByAccessToken(access2)).toBeNull(); | |
| // The second refresh token should also be revoked | |
| const r2 = refreshTokens(second, clientId, rawSecret); | |
| expect(r2.error).toBe('invalid_grant'); | |
| }); | |
| it('new rotation chain after replay is independent', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const rawSecret = created.client!.client_secret as string; | |
| const { refresh_token: first } = issueTokens(clientId, user.id, ['trips:read']); | |
| // Rotate once | |
| const r1 = refreshTokens(first, clientId, rawSecret); | |
| const { refresh_token: second } = r1.tokens!; | |
| // Rotate again on the second token | |
| const r2 = refreshTokens(second, clientId, rawSecret); | |
| expect(r2.error).toBeUndefined(); | |
| const { refresh_token: third } = r2.tokens!; | |
| // Replay the first revoked token β revokes chain containing first+second+third | |
| refreshTokens(first, clientId, rawSecret); | |
| // third should now be revoked too (it's in the same chain) | |
| const r3 = refreshTokens(third, clientId, rawSecret); | |
| expect(r3.error).toBe('invalid_grant'); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // H1 β PKCE code_challenge / code_verifier format validation | |
| // --------------------------------------------------------------------------- | |
| describe('verifyPKCE β format validation (H1)', () => { | |
| it('returns false for a code_verifier that is too short (< 43 chars)', () => { | |
| const { challenge } = makePkce(); | |
| expect(verifyPKCE('short', challenge)).toBe(false); | |
| }); | |
| it('returns false for a code_verifier that is too long (> 128 chars)', () => { | |
| const { challenge } = makePkce(); | |
| const longVerifier = 'a'.repeat(129); | |
| expect(verifyPKCE(longVerifier, challenge)).toBe(false); | |
| }); | |
| it('returns false for a code_verifier with invalid characters', () => { | |
| const { challenge } = makePkce(); | |
| const badVerifier = 'A'.repeat(42) + ' '; // space is not allowed | |
| expect(verifyPKCE(badVerifier, challenge)).toBe(false); | |
| }); | |
| it('returns true for a valid 43-char verifier matching its challenge', () => { | |
| const { verifier, challenge } = makePkce(); | |
| expect(verifyPKCE(verifier, challenge)).toBe(true); | |
| }); | |
| }); | |
| describe('validateAuthorizeRequest β PKCE format (H1)', () => { | |
| it('returns invalid_request when code_challenge is shorter than 43 chars', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const result = validateAuthorizeRequest({ | |
| response_type: 'code', | |
| client_id: clientId, | |
| redirect_uri: 'https://example.com/callback', | |
| scope: 'trips:read', | |
| code_challenge: 'tooshort', | |
| code_challenge_method: 'S256', | |
| }, user.id); | |
| expect(result.valid).toBe(false); | |
| expect(result.error).toBe('invalid_request'); | |
| }); | |
| it('returns invalid_request when code_challenge contains invalid characters', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| // 43 chars but includes '=' which is not base64url | |
| const badChallenge = '='.repeat(43); | |
| const result = validateAuthorizeRequest({ | |
| response_type: 'code', | |
| client_id: clientId, | |
| redirect_uri: 'https://example.com/callback', | |
| scope: 'trips:read', | |
| code_challenge: badChallenge, | |
| code_challenge_method: 'S256', | |
| }, user.id); | |
| expect(result.valid).toBe(false); | |
| expect(result.error).toBe('invalid_request'); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // H3 β validateAuthorizeRequest: loginRequired response strips client info | |
| // --------------------------------------------------------------------------- | |
| describe('validateAuthorizeRequest β unauthenticated strips client info (H3)', () => { | |
| it('loginRequired response does not include client.name or allowed_scopes', () => { | |
| const { user } = createUser(testDb); | |
| const created = makeClient(user.id); | |
| const clientId = created.client!.client_id as string; | |
| const { challenge } = makePkce(); | |
| const result = validateAuthorizeRequest({ | |
| response_type: 'code', | |
| client_id: clientId, | |
| redirect_uri: 'https://example.com/callback', | |
| scope: 'trips:read', | |
| code_challenge: challenge, | |
| code_challenge_method: 'S256', | |
| }, null /* unauthenticated */); | |
| expect(result.valid).toBe(true); | |
| expect(result.loginRequired).toBe(true); | |
| // Must NOT expose client metadata to unauthenticated callers | |
| expect(result.client).toBeUndefined(); | |
| expect(result.scopes).toBeUndefined(); | |
| }); | |
| }); | |