mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
1351 lines
58 KiB
TypeScript
1351 lines
58 KiB
TypeScript
/**
|
|
* Unit tests for the OAuth 2.1 domain service.
|
|
*
|
|
* Moved 1:1 from tests/unit/services/oauthService.test.ts with the fold — the
|
|
* cases and their names are unchanged; the free-function imports became
|
|
* methods bound to a hand-constructed OauthService (the repo's DB-backed
|
|
* service-test shape). The delegation cases of the old thin wrapper died with
|
|
* it; mcpEnabled/mcpSafeUrl, the module metadata and the bridge seam are
|
|
* pinned at the bottom.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
import crypto from 'crypto';
|
|
|
|
vi.mock('../../../src/db/database', async () => {
|
|
|
|
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
|
|
const db = createSnapshotTestDb();
|
|
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 mock;
|
|
});
|
|
|
|
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
vi.mock('../../../src/nest/common/crypto/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() }));
|
|
import { db as testDb } from '../../../src/db/database';
|
|
import { revokeUserSessionsForClient } from '../../../src/mcp/sessionManager';
|
|
vi.mock('../../../src/demo/demo-reset', () => ({ saveBaseline: vi.fn() }));
|
|
|
|
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn().mockReturnValue(true) }));
|
|
|
|
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 { OauthService } from '../../../src/nest/oauth/oauth.service';
|
|
import { AuditService } from '../../../src/nest/audit/audit.service';
|
|
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
|
import { getMcpSafeUrl } from '../../../src/app-config';
|
|
import { ADDON_IDS } from '../../../src/addons';
|
|
import { MAX_PENDING_CODES, sweepPendingCodes } from '../../../src/nest/oauth/oauth.pending-codes';
|
|
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
|
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
|
|
import type { AuditLogRepository } from '../../../src/db/repositories/AuditLog.repository';
|
|
import { Users } from '../../../src/db/entities/Users.entity';
|
|
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
|
import { OauthClients } from '../../../src/db/entities/OauthClients.entity';
|
|
import type { OauthClientsRepository } from '../../../src/db/repositories/OauthClients.repository';
|
|
import { OauthTokens } from '../../../src/db/entities/OauthTokens.entity';
|
|
import type { OauthTokensRepository } from '../../../src/db/repositories/OauthTokens.repository';
|
|
import { OauthConsents } from '../../../src/db/entities/OauthConsents.entity';
|
|
import type { OauthConsentsRepository } from '../../../src/db/repositories/OauthConsents.repository';
|
|
|
|
// Stubbed rather than real: every case drives the MCP gate through this one
|
|
// flag, exactly as the addons.bridge mock did before the fold.
|
|
const addonsStub = { isAddonEnabled } as unknown as AddonsService;
|
|
let svc: OauthService;
|
|
let auditLogRepo: AuditLogRepository;
|
|
let usersRepo: UsersRepository;
|
|
let clientsRepo: OauthClientsRepository;
|
|
let tokensRepo: OauthTokensRepository;
|
|
let consentsRepo: OauthConsentsRepository;
|
|
let t: TestOrm;
|
|
|
|
// Legacy free-function names delegating to the service, so the moved cases below
|
|
// read exactly as they did before the fold. Arrow wrappers rather than `.bind`:
|
|
// with `strictBindCallApply: false` a bound alias is typed `any`, which would
|
|
// hide every missing `await` on the now-async methods from the type checker.
|
|
const createOAuthClient = (...args: Parameters<OauthService['createOAuthClient']>) => svc.createOAuthClient(...args);
|
|
const listOAuthClients = (...args: Parameters<OauthService['listOAuthClients']>) => svc.listOAuthClients(...args);
|
|
const deleteOAuthClient = (...args: Parameters<OauthService['deleteOAuthClient']>) => svc.deleteOAuthClient(...args);
|
|
const rotateOAuthClientSecret = (...args: Parameters<OauthService['rotateOAuthClientSecret']>) => svc.rotateOAuthClientSecret(...args);
|
|
const createAuthCode = (...args: Parameters<OauthService['createAuthCode']>) => svc.createAuthCode(...args);
|
|
const consumeAuthCode = (...args: Parameters<OauthService['consumeAuthCode']>) => svc.consumeAuthCode(...args);
|
|
const issueTokens = (...args: Parameters<OauthService['issueTokens']>) => svc.issueTokens(...args);
|
|
const getUserByAccessToken = (...args: Parameters<OauthService['getUserByAccessToken']>) => svc.getUserByAccessToken(...args);
|
|
const refreshTokens = (...args: Parameters<OauthService['refreshTokens']>) => svc.refreshTokens(...args);
|
|
const revokeToken = (...args: Parameters<OauthService['revokeToken']>) => svc.revokeToken(...args);
|
|
const listOAuthSessions = (...args: Parameters<OauthService['listOAuthSessions']>) => svc.listOAuthSessions(...args);
|
|
const revokeSession = (...args: Parameters<OauthService['revokeSession']>) => svc.revokeSession(...args);
|
|
const validateAuthorizeRequest = (...args: Parameters<OauthService['validateAuthorizeRequest']>) => svc.validateAuthorizeRequest(...args);
|
|
const verifyPKCE = (...args: Parameters<OauthService['verifyPKCE']>) => svc.verifyPKCE(...args);
|
|
const authenticateClient = (...args: Parameters<OauthService['authenticateClient']>) => svc.authenticateClient(...args);
|
|
const saveConsent = (...args: Parameters<OauthService['saveConsent']>) => svc.saveConsent(...args);
|
|
const getConsent = (...args: Parameters<OauthService['getConsent']>) => svc.getConsent(...args);
|
|
const isConsentSufficient = (...args: Parameters<OauthService['isConsentSufficient']>) => svc.isConsentSufficient(...args);
|
|
|
|
beforeAll(async () => {
|
|
t = await createTestOrm(testDb);
|
|
auditLogRepo = t.repo(AuditLog);
|
|
usersRepo = t.repo(Users);
|
|
clientsRepo = t.repo(OauthClients);
|
|
tokensRepo = t.repo(OauthTokens);
|
|
consentsRepo = t.repo(OauthConsents);
|
|
svc = new OauthService(clientsRepo, tokensRepo, consentsRepo, addonsStub, new AuditService(auditLogRepo, usersRepo));
|
|
});
|
|
|
|
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');
|
|
isAddonEnabled.mockReturnValue(true);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await t.close();
|
|
testDb.close();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function makeClient(
|
|
userId: number,
|
|
overrides: Partial<{ name: string; redirectUris: string[]; scopes: string[] }> = {}
|
|
) {
|
|
return await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const longName = 'A'.repeat(101);
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const uris = Array.from({ length: 11 }, (_, i) => `https://example${i}.com/cb`);
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await 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)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await createOAuthClient(user.id, 'Test', ['http://127.0.0.1:5000/callback'], ['trips:read']);
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.client).toBeDefined();
|
|
});
|
|
|
|
it('allows the http://[::1] loopback redirect URI (#2227)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await createOAuthClient(user.id, 'Test', ['http://[::1]:8080/callback'], ['trips:read']);
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.client).toBeDefined();
|
|
});
|
|
|
|
it('allows a private-use custom scheme (#2227)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const uri = 'workbuddy://workbuddy/mcp/connector%3A/oauth/callback';
|
|
const result = await createOAuthClient(user.id, 'Test', [uri], ['trips:read']);
|
|
expect(result.error).toBeUndefined();
|
|
// The stored value must round-trip untouched: the whole authorize/token
|
|
// chain compares it byte for byte.
|
|
expect(result.client!.redirect_uris).toEqual([uri]);
|
|
});
|
|
|
|
it('rejects dangerous schemes that a localhost host used to smuggle through (#2227)', async () => {
|
|
const { user } = createUser(testDb);
|
|
for (const uri of ['javascript://localhost/%0aalert(1)', 'blob://localhost/x', 'about://localhost/x']) {
|
|
const result = await createOAuthClient(user.id, 'Test', [uri], ['trips:read']);
|
|
expect(result.status).toBe(400);
|
|
expect(result.error).toContain('Dangerous');
|
|
}
|
|
});
|
|
|
|
it('rejects non-navigable schemes on a localhost host (#2227)', async () => {
|
|
const { user } = createUser(testDb);
|
|
for (const uri of ['ftp://localhost/x', 'wss://localhost/x']) {
|
|
const result = await createOAuthClient(user.id, 'Test', [uri], ['trips:read']);
|
|
expect(result.status).toBe(400);
|
|
expect(result.error).toContain('HTTPS');
|
|
}
|
|
});
|
|
|
|
it('returns 400 error if no scopes provided', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
for (let i = 0; i < 10; i++) {
|
|
const r = await makeClient(user.id, { name: `Client ${i}` });
|
|
expect(r.error).toBeUndefined();
|
|
}
|
|
const eleventh = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
expect(await listOAuthClients(user.id)).toEqual([]);
|
|
});
|
|
|
|
it('returns created clients with redirect_uris and allowed_scopes as arrays', async () => {
|
|
const { user } = createUser(testDb);
|
|
await makeClient(user.id, { name: 'Client A', redirectUris: ['https://a.com/cb'], scopes: ['trips:read', 'budget:read'] });
|
|
const clients = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientRowId = created.client!.id as string;
|
|
const result = await deleteOAuthClient(user.id, clientRowId);
|
|
expect(result.success).toBe(true);
|
|
expect(await listOAuthClients(user.id)).toHaveLength(0);
|
|
});
|
|
|
|
it('returns 404 for non-existent client', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await deleteOAuthClient(user.id, 'non-existent-id');
|
|
expect(result.status).toBe(404);
|
|
});
|
|
|
|
it("returns 404 for another user's client", async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const created = await makeClient(owner.id);
|
|
const result = await 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_', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const oldSecret = created.client!.client_secret as string;
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await rotateOAuthClientSecret(user.id, 'non-existent-id');
|
|
expect(result.status).toBe(404);
|
|
});
|
|
|
|
it('revokes old tokens after rotation', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
const { access_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
expect(await getUserByAccessToken(access_token)).not.toBeNull();
|
|
|
|
await rotateOAuthClientSecret(user.id, created.client!.id as string);
|
|
|
|
expect(await getUserByAccessToken(access_token)).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// createAuthCode + consumeAuthCode
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('createAuthCode + consumeAuthCode', () => {
|
|
it('create code and consume it once returns the pending entry', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await 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'],
|
|
resource: null,
|
|
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', async () => {
|
|
expect(consumeAuthCode('does-not-exist')).toBeNull();
|
|
});
|
|
|
|
it('consuming same code twice returns null (one-time use)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await 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'],
|
|
resource: null,
|
|
codeChallenge: 'abc123',
|
|
codeChallengeMethod: 'S256',
|
|
});
|
|
|
|
consumeAuthCode(code);
|
|
expect(consumeAuthCode(code)).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// issueTokens + getUserByAccessToken
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('issueTokens + getUserByAccessToken', () => {
|
|
it('issues tokens with correct prefixes', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
const tokens = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
const { access_token } = await issueTokens(clientId, user.id, ['trips:read', 'budget:write']);
|
|
const info = await 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', async () => {
|
|
expect(await getUserByAccessToken('trekoa_unknown')).toBeNull();
|
|
});
|
|
|
|
it('getUserByAccessToken returns null for revoked token', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
const { access_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
await revokeToken(access_token, clientId);
|
|
expect(await getUserByAccessToken(access_token)).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// refreshTokens
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('refreshTokens', () => {
|
|
it('exchanges a refresh token for a new token pair', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
const rawSecret = created.client!.client_secret as string;
|
|
|
|
const { refresh_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
const result = await 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)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
const rawSecret = created.client!.client_secret as string;
|
|
|
|
const { access_token, refresh_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
await refreshTokens(refresh_token, clientId, rawSecret);
|
|
expect(await getUserByAccessToken(access_token)).toBeNull();
|
|
});
|
|
|
|
it('does not revoke the active MCP session on a normal (non-replayed) refresh (#1475)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
const rawSecret = created.client!.client_secret as string;
|
|
|
|
const { refresh_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
const callsBefore = vi.mocked(revokeUserSessionsForClient).mock.calls.length;
|
|
const result = await refreshTokens(refresh_token, clientId, rawSecret);
|
|
expect(result.error).toBeUndefined();
|
|
expect(vi.mocked(revokeUserSessionsForClient).mock.calls.length).toBe(callsBefore);
|
|
});
|
|
|
|
it('returns invalid_grant for unknown refresh token', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
const rawSecret = created.client!.client_secret as string;
|
|
|
|
const result = await refreshTokens('trekrf_unknown', clientId, rawSecret);
|
|
expect(result.error).toBe('invalid_grant');
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('returns invalid_grant for revoked token', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
const rawSecret = created.client!.client_secret as string;
|
|
|
|
const { access_token, refresh_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
await revokeToken(access_token, clientId);
|
|
const result = await refreshTokens(refresh_token, clientId, rawSecret);
|
|
expect(result.error).toBe('invalid_grant');
|
|
});
|
|
|
|
it('returns invalid_client for wrong client_secret', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
const { refresh_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
const { access_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
expect(await getUserByAccessToken(access_token)).not.toBeNull();
|
|
|
|
await revokeToken(access_token, clientId);
|
|
expect(await getUserByAccessToken(access_token)).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// listOAuthSessions + revokeSession
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('listOAuthSessions + revokeSession', () => {
|
|
it('lists active sessions', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
await issueTokens(clientId, user.id, ['trips:read']);
|
|
const sessions = await listOAuthSessions(user.id);
|
|
expect(sessions).toHaveLength(1);
|
|
expect(sessions[0].client_id).toBe(clientId);
|
|
});
|
|
|
|
it('revoked session is not listed', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
const { access_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
await revokeToken(access_token, clientId);
|
|
const sessions = await listOAuthSessions(user.id);
|
|
expect(sessions).toHaveLength(0);
|
|
});
|
|
|
|
it('revokeSession returns 404 for unknown session', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await revokeSession(user.id, 99999);
|
|
expect(result.status).toBe(404);
|
|
});
|
|
|
|
it('revokeSession by session id removes session from list', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
await issueTokens(clientId, user.id, ['trips:read']);
|
|
const sessions = await listOAuthSessions(user.id);
|
|
const sessionId = sessions[0].id as number;
|
|
|
|
const result = await revokeSession(user.id, sessionId);
|
|
expect(result.success).toBe(true);
|
|
expect(await 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', async () => {
|
|
vi.mocked(isAddonEnabled).mockReturnValue(false);
|
|
const result = await validateAuthorizeRequest(makeParams({ client_id: 'x' }), null);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('mcp_disabled');
|
|
});
|
|
|
|
it('requires response_type=code', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id, { redirectUris: ['https://example.com/callback'] });
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
const result = await 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('accepts a loopback redirect on the port the OS handed the client (#2227)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id, { redirectUris: ['http://[::1]:8080/oauth/callback'] });
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
const result = await validateAuthorizeRequest(
|
|
makeParams({ client_id: clientId, redirect_uri: 'http://[::1]:54321/oauth/callback' }),
|
|
user.id
|
|
);
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('relaxes only the port of a loopback redirect, never the path (#2227)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id, { redirectUris: ['http://127.0.0.1:8080/oauth/callback'] });
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
const result = await validateAuthorizeRequest(
|
|
makeParams({ client_id: clientId, redirect_uri: 'http://127.0.0.1:54321/stolen' }),
|
|
user.id
|
|
);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('invalid_redirect_uri');
|
|
});
|
|
|
|
it('validates scope against client allowed_scopes', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id, { scopes: ['trips:read'] });
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
await saveConsent(clientId, user.id, ['trips:read']);
|
|
const result = await 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)', async () => {
|
|
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', async () => {
|
|
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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
const rawSecret = created.client!.client_secret as string;
|
|
|
|
const client = await authenticateClient(clientId, rawSecret);
|
|
expect(client).not.toBeNull();
|
|
expect(client!.client_id).toBe(clientId);
|
|
});
|
|
|
|
it('returns null for wrong secret', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
expect(await authenticateClient(clientId, 'wrong-secret')).toBeNull();
|
|
});
|
|
|
|
it('returns null for unknown client_id', async () => {
|
|
expect(await authenticateClient('unknown-client-id', 'any-secret')).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// saveConsent + getConsent + isConsentSufficient
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('saveConsent + getConsent + isConsentSufficient', () => {
|
|
it('saves and retrieves consent', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
await saveConsent(clientId, user.id, ['trips:read', 'budget:write']);
|
|
const consent = await 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', async () => {
|
|
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', async () => {
|
|
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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id, { scopes: ['trips:read', 'budget:write'] });
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
await saveConsent(clientId, user.id, ['trips:read']);
|
|
await saveConsent(clientId, user.id, ['budget:write']);
|
|
|
|
const consent = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id, { scopes: ['trips:read', 'trips:write'] });
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
await saveConsent(clientId, user.id, ['trips:read', 'trips:write']);
|
|
// approve only trips:read on a later request
|
|
await saveConsent(clientId, user.id, ['trips:read']);
|
|
|
|
const consent = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id, { scopes: ['trips:read', 'budget:write'] });
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
await saveConsent(clientId, user.id, ['trips:read']);
|
|
await saveConsent(clientId, user.id, ['budget:write']);
|
|
|
|
// Should not require consent again for either scope
|
|
expect(isConsentSufficient((await getConsent(clientId, user.id))!, ['trips:read'])).toBe(true);
|
|
expect(isConsentSufficient((await getConsent(clientId, user.id))!, ['budget:write'])).toBe(true);
|
|
expect(isConsentSufficient((await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
const { access_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
const info = await getUserByAccessToken(access_token);
|
|
expect(info).not.toBeNull();
|
|
expect(info!.clientId).toBe(clientId);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// C3 — Refresh token replay detection and chain revocation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Push a token's rotation out of the concurrency grace window (#1007), so the
|
|
* replay cases below still describe theft — a token used minutes later — rather
|
|
* than two clients racing.
|
|
*/
|
|
function agePastGrace(rawRefreshToken: string) {
|
|
const hash = crypto.createHash('sha256').update(rawRefreshToken).digest('hex');
|
|
const old = new Date(Date.now() - 5 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
|
testDb.prepare('UPDATE oauth_tokens SET revoked_at = ? WHERE refresh_token_hash = ?').run(old, hash);
|
|
}
|
|
|
|
describe('refreshTokens — replay detection (C3)', () => {
|
|
it('replaying a revoked refresh token returns invalid_grant', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await 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 } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
const rotateResult = await refreshTokens(firstRefresh, clientId, rawSecret);
|
|
expect(rotateResult.error).toBeUndefined();
|
|
const { refresh_token: secondRefresh } = rotateResult.tokens!;
|
|
|
|
// Replay the FIRST (now revoked) refresh token, long enough after the
|
|
// rotation that it cannot be a concurrent refresh.
|
|
agePastGrace(firstRefresh);
|
|
const callsBefore = vi.mocked(revokeUserSessionsForClient).mock.calls.length;
|
|
const replayResult = await refreshTokens(firstRefresh, clientId, rawSecret);
|
|
expect(replayResult.error).toBe('invalid_grant');
|
|
expect(replayResult.status).toBe(400);
|
|
// Replay IS a security event — sessions must still be torn down here.
|
|
expect(vi.mocked(revokeUserSessionsForClient).mock.calls.length).toBe(callsBefore + 1);
|
|
expect(vi.mocked(revokeUserSessionsForClient)).toHaveBeenLastCalledWith(user.id, clientId);
|
|
});
|
|
|
|
it('replaying a revoked token also revokes the entire rotation chain', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await 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 } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
const r1 = await refreshTokens(first, clientId, rawSecret);
|
|
const { access_token: access2, refresh_token: second } = r1.tokens!;
|
|
|
|
// Replay first (revoked) refresh token → chain revoke
|
|
agePastGrace(first);
|
|
await refreshTokens(first, clientId, rawSecret);
|
|
|
|
// The rotated access token should also be dead now
|
|
expect(await getUserByAccessToken(access2)).toBeNull();
|
|
|
|
// The second refresh token should also be revoked
|
|
const r2 = await refreshTokens(second, clientId, rawSecret);
|
|
expect(r2.error).toBe('invalid_grant');
|
|
});
|
|
|
|
it('new rotation chain after replay is independent', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
const rawSecret = created.client!.client_secret as string;
|
|
|
|
const { refresh_token: first } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
// Rotate once
|
|
const r1 = await refreshTokens(first, clientId, rawSecret);
|
|
const { refresh_token: second } = r1.tokens!;
|
|
// Rotate again on the second token
|
|
const r2 = await 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
|
|
agePastGrace(first);
|
|
await refreshTokens(first, clientId, rawSecret);
|
|
|
|
// third should now be revoked too (it's in the same chain)
|
|
const r3 = await refreshTokens(third, clientId, rawSecret);
|
|
expect(r3.error).toBe('invalid_grant');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #1007 — Concurrent rotation is not a replay
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('refreshTokens — concurrent rotation grace (#1007)', () => {
|
|
const setup = async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
return {
|
|
user,
|
|
clientId: created.client!.client_id as string,
|
|
rawSecret: created.client!.client_secret as string,
|
|
};
|
|
};
|
|
|
|
it('OAUTH-GRACE-001: two clients refreshing the same token both get tokens', async () => {
|
|
const { user, clientId, rawSecret } = await setup();
|
|
const { refresh_token: shared } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
|
|
const first = await refreshTokens(shared, clientId, rawSecret);
|
|
const callsBefore = vi.mocked(revokeUserSessionsForClient).mock.calls.length;
|
|
const second = await refreshTokens(shared, clientId, rawSecret);
|
|
|
|
expect(first.error).toBeUndefined();
|
|
expect(second.error).toBeUndefined();
|
|
expect(second.tokens!.refresh_token).not.toBe(first.tokens!.refresh_token);
|
|
// The whole point: no chain revocation, no torn-down MCP sessions, no login window.
|
|
expect(vi.mocked(revokeUserSessionsForClient).mock.calls.length).toBe(callsBefore);
|
|
expect(await getUserByAccessToken(first.tokens!.access_token)).not.toBeNull();
|
|
expect(await getUserByAccessToken(second.tokens!.access_token)).not.toBeNull();
|
|
});
|
|
|
|
it('OAUTH-GRACE-002: both successors keep working afterwards', async () => {
|
|
const { user, clientId, rawSecret } = await setup();
|
|
const { refresh_token: shared } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
const a = (await refreshTokens(shared, clientId, rawSecret)).tokens!;
|
|
const b = (await refreshTokens(shared, clientId, rawSecret)).tokens!;
|
|
|
|
expect((await refreshTokens(a.refresh_token, clientId, rawSecret)).error).toBeUndefined();
|
|
expect((await refreshTokens(b.refresh_token, clientId, rawSecret)).error).toBeUndefined();
|
|
});
|
|
|
|
it('OAUTH-GRACE-003: the same token replayed after the window is still theft', async () => {
|
|
const { user, clientId, rawSecret } = await setup();
|
|
const { refresh_token: shared } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
const rotated = (await refreshTokens(shared, clientId, rawSecret)).tokens!;
|
|
|
|
agePastGrace(shared);
|
|
const replay = await refreshTokens(shared, clientId, rawSecret);
|
|
|
|
expect(replay.error).toBe('invalid_grant');
|
|
expect((await refreshTokens(rotated.refresh_token, clientId, rawSecret)).error).toBe('invalid_grant');
|
|
});
|
|
|
|
it('OAUTH-GRACE-004: a token revoked by logout is not re-opened by the window', async () => {
|
|
const { user, clientId, rawSecret } = await setup();
|
|
const { refresh_token: shared } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
|
|
// Explicit revocation leaves no successor, so there is nothing to be
|
|
// concurrent with — the grace must not resurrect it.
|
|
await revokeToken(shared, clientId, user.id);
|
|
const result = await refreshTokens(shared, clientId, rawSecret);
|
|
|
|
expect(result.error).toBe('invalid_grant');
|
|
});
|
|
|
|
it('OAUTH-GRACE-005: a chain killed by a real replay stays dead inside the window', async () => {
|
|
const { user, clientId, rawSecret } = await setup();
|
|
const { refresh_token: first } = await issueTokens(clientId, user.id, ['trips:read']);
|
|
const second = (await refreshTokens(first, clientId, rawSecret)).tokens!;
|
|
|
|
// Real theft: the first token turns up again once the window has passed.
|
|
agePastGrace(first);
|
|
expect((await refreshTokens(first, clientId, rawSecret)).error).toBe('invalid_grant');
|
|
|
|
// The successor was revoked with the chain, so presenting it now must not
|
|
// find a live child and slip through as "concurrent".
|
|
expect((await refreshTokens(second.refresh_token, clientId, rawSecret)).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)', async () => {
|
|
const { challenge } = makePkce();
|
|
expect(verifyPKCE('short', challenge)).toBe(false);
|
|
});
|
|
|
|
it('returns false for a code_verifier that is too long (> 128 chars)', async () => {
|
|
const { challenge } = makePkce();
|
|
const longVerifier = 'a'.repeat(129);
|
|
expect(verifyPKCE(longVerifier, challenge)).toBe(false);
|
|
});
|
|
|
|
it('returns false for a code_verifier with invalid characters', async () => {
|
|
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', async () => {
|
|
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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
|
|
const result = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await 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 = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = created.client!.client_id as string;
|
|
const { challenge } = makePkce();
|
|
|
|
const result = await 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();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wrapper surface that survived the fold, plus the branches the legacy suite
|
|
// never reached (they were free functions nobody could drive in isolation).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('addon gate and MCP endpoint', () => {
|
|
it('mcpEnabled checks the MCP addon flag', async () => {
|
|
isAddonEnabled.mockReturnValue(true);
|
|
expect(await svc.mcpEnabled()).toBe(true);
|
|
expect(isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.MCP);
|
|
isAddonEnabled.mockReturnValue(false);
|
|
expect(await svc.mcpEnabled()).toBe(false);
|
|
});
|
|
|
|
it('mcpSafeUrl forwards to the app-config helper', async () => {
|
|
expect(svc.mcpSafeUrl()).toBe(getMcpSafeUrl());
|
|
});
|
|
});
|
|
|
|
describe('pending-code store', () => {
|
|
const codeParams = {
|
|
clientId: 'c',
|
|
userId: 42,
|
|
redirectUri: 'https://example.com/callback',
|
|
scopes: ['trips:read'],
|
|
resource: null,
|
|
codeChallenge: 'x',
|
|
codeChallengeMethod: 'S256' as const,
|
|
};
|
|
|
|
it('refuses a new code at capacity and the sweep frees it again', async () => {
|
|
for (let i = 0; i < MAX_PENDING_CODES; i++) createAuthCode(codeParams);
|
|
|
|
expect(createAuthCode(codeParams)).toBeNull();
|
|
|
|
// Everything in the store is past its 2-minute TTL by then.
|
|
sweepPendingCodes(Date.now() + 3 * 60 * 1000);
|
|
|
|
const afterSweep = createAuthCode(codeParams);
|
|
expect(afterSweep).not.toBeNull();
|
|
sweepPendingCodes(Date.now() + 3 * 60 * 1000);
|
|
});
|
|
|
|
it('a code past its TTL is consumed as invalid', async () => {
|
|
const code = createAuthCode(codeParams)!;
|
|
const realNow = Date.now;
|
|
Date.now = () => realNow() + 3 * 60 * 1000;
|
|
try {
|
|
expect(consumeAuthCode(code)).toBeNull();
|
|
} finally {
|
|
Date.now = realNow;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('branches the legacy suite could not reach', () => {
|
|
it('rejects an expired access token', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = (created.client as { client_id: string }).client_id;
|
|
const tokens = await issueTokens(clientId, user.id, ['trips:read']);
|
|
testDb.prepare("UPDATE oauth_tokens SET access_token_expires_at = '2000-01-01T00:00:00.000Z'").run();
|
|
|
|
expect(await getUserByAccessToken(tokens.access_token)).toBeNull();
|
|
});
|
|
|
|
it('refreshTokens rejects an unknown client before touching the token', async () => {
|
|
expect(await refreshTokens('trekrf_whatever', 'no-such-client', 'secret')).toEqual({ error: 'invalid_client', status: 401 });
|
|
});
|
|
|
|
it('refreshTokens skips the secret check for a public client', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await createOAuthClient(user.id, 'Public', ['https://example.com/callback'], ['trips:read'], null, { isPublic: true });
|
|
const clientId = (created.client as { client_id: string }).client_id;
|
|
const tokens = await issueTokens(clientId, user.id, ['trips:read']);
|
|
|
|
const result = await refreshTokens(tokens.refresh_token, clientId, undefined);
|
|
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.tokens?.access_token).toMatch(/^trekoa_/);
|
|
});
|
|
|
|
it('authenticateClient identifies a public client by id alone and rejects a missing secret otherwise', async () => {
|
|
const { user } = createUser(testDb);
|
|
const pub = await createOAuthClient(user.id, 'Public', ['https://example.com/callback'], ['trips:read'], null, { isPublic: true });
|
|
const pubId = (pub.client as { client_id: string }).client_id;
|
|
const conf = await makeClient(user.id, { name: 'Confidential' });
|
|
const confId = (conf.client as { client_id: string }).client_id;
|
|
|
|
expect((await authenticateClient(pubId, undefined))?.client_id).toBe(pubId);
|
|
expect(await authenticateClient(confId, undefined)).toBeNull();
|
|
});
|
|
|
|
it('validateAuthorizeRequest rejects a resource that is not the MCP endpoint', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = (created.client as { client_id: string }).client_id;
|
|
const { challenge } = makePkce();
|
|
|
|
const result = await validateAuthorizeRequest({
|
|
response_type: 'code',
|
|
client_id: clientId,
|
|
redirect_uri: 'https://example.com/callback',
|
|
scope: 'trips:read',
|
|
code_challenge: challenge,
|
|
code_challenge_method: 'S256',
|
|
resource: 'https://evil.example.org/mcp',
|
|
}, user.id);
|
|
|
|
expect(result).toEqual({ valid: false, error: 'invalid_target', error_description: 'Requested resource must be the TREK MCP endpoint' });
|
|
});
|
|
|
|
it('validateAuthorizeRequest accepts the MCP endpoint passed explicitly, trailing slash and all', async () => {
|
|
const { user } = createUser(testDb);
|
|
const created = await makeClient(user.id);
|
|
const clientId = (created.client as { client_id: string }).client_id;
|
|
const { challenge } = makePkce();
|
|
const mcpResource = getMcpSafeUrl().replace(/\/+$/, '') + '/mcp';
|
|
|
|
const result = await validateAuthorizeRequest({
|
|
response_type: 'code',
|
|
client_id: clientId,
|
|
redirect_uri: 'https://example.com/callback',
|
|
scope: 'trips:read',
|
|
code_challenge: challenge,
|
|
code_challenge_method: 'S256',
|
|
resource: mcpResource + '/',
|
|
}, user.id);
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.resource).toBe(mcpResource);
|
|
});
|
|
});
|
|
|
|
describe('module-scoped OAuth state', () => {
|
|
it('shares the pending-code map across service instances', async () => {
|
|
// The load-bearing invariant: the consent controller writes the code through
|
|
// the DI singleton, the SDK exchange path reads it back. The map is module-
|
|
// scoped, so even a second hand-built instance must see it — two maps would
|
|
// kill the authorization-code flow silently.
|
|
const code = createAuthCode({
|
|
clientId: 'c',
|
|
userId: 42,
|
|
redirectUri: 'https://example.com/callback',
|
|
scopes: ['trips:read'],
|
|
resource: null,
|
|
codeChallenge: 'x',
|
|
codeChallengeMethod: 'S256',
|
|
})!;
|
|
|
|
const secondInstance = new OauthService(clientsRepo, tokensRepo, consentsRepo, addonsStub, new AuditService(auditLogRepo, usersRepo));
|
|
expect(secondInstance.consumeAuthCode(code)?.userId).toBe(42);
|
|
});
|
|
});
|
|
|
|
describe('OauthModule', () => {
|
|
it('wires the public + api controllers and the providers', async () => {
|
|
const { OauthModule } = await import('../../../src/nest/oauth/oauth.module');
|
|
const { OauthPublicController } = await import('../../../src/nest/oauth/oauth-public.controller');
|
|
const { OauthApiController } = await import('../../../src/nest/oauth/oauth-api.controller');
|
|
const { OauthService: Svc } = await import('../../../src/nest/oauth/oauth.service');
|
|
|
|
const { TrekClientsStore, TrekOAuthProvider } = await import('../../../src/nest/oauth/oauth-sdk.provider');
|
|
const { OauthTokenRetentionJob } = await import('../../../src/nest/oauth/oauth-token-retention.job');
|
|
|
|
const controllers = Reflect.getMetadata('controllers', OauthModule);
|
|
const providers = Reflect.getMetadata('providers', OauthModule);
|
|
expect(controllers).toEqual([OauthPublicController, OauthApiController]);
|
|
// RateLimitService is deliberately absent: it comes from the global
|
|
// RateLimitModule so all consumers share one set of counters.
|
|
expect(providers).toEqual([Svc, TrekClientsStore, TrekOAuthProvider, OauthTokenRetentionJob]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Admin view of live sessions — moved here from admin.service.test.ts with the
|
|
// method. Named apart from the user-facing listOAuthSessions because the two
|
|
// are different queries: this one spans users and carries the owner username.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('admin OAuth sessions', () => {
|
|
it('ADMIN-SVC-074 — listAllOAuthSessions survives a row with malformed scopes JSON', async () => {
|
|
const { user } = createUser(testDb);
|
|
testDb.prepare("INSERT INTO oauth_clients (client_id, client_secret_hash, name) VALUES ('c1', 'hash', 'Client')").run();
|
|
testDb.prepare(`
|
|
INSERT INTO oauth_tokens (client_id, user_id, access_token_hash, refresh_token_hash, scopes,
|
|
access_token_expires_at, refresh_token_expires_at)
|
|
VALUES ('c1', ?, 'ahash', 'rhash', 'not-json{', datetime('now', '+1 hour'), datetime('now', '+1 day'))
|
|
`).run(user.id);
|
|
|
|
const sessions = (await svc.listAllOAuthSessions()) as any[];
|
|
expect(sessions).toHaveLength(1);
|
|
expect(sessions[0].scopes).toBeNull();
|
|
});
|
|
});
|