mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
security(oauth): harden OAuth 2.1/MCP implementation (Critical + High + Medium findings)
Address 14 security findings from internal review of the OAuth 2.1 + MCP layer: Critical: - C1: Scope-gate all MCP resources (trips, budget, packing, collab, atlas, vacay, etc.) - C2: Wire token/session revocation into active MCP session lifecycle per (user, client_id) - C3: Refresh-token replay detection via parent_token_id chain + cascade revoke on replay High: - H1: Validate PKCE code_challenge (43-char base64url) and code_verifier (43–128 chars) format - H2: Rate-limit /oauth/token (30/min), /authorize/validate (30/min), /oauth/revoke (10/min) - H3: Strip client metadata from unauthenticated /authorize/validate responses (oracle prevention) - H4: Constant-time secret comparison via crypto.timingSafeEqual (prevents timing attacks) - H5: Collapse all invalid_grant cases to a single generic message; log specifics server-side Medium: - M1: Set Cache-Control: no-store + Pragma: no-cache on token endpoint responses - M2: Return 404 (not 200/403) on discovery + revoke endpoints when MCP addon is disabled - M4: Audit-log all OAuth lifecycle events (create, consent, issue, refresh, revoke, replay) - M5: Union consent scopes on re-authorization instead of replacing existing grants - M7: Require httpOnly cookie auth (not Bearer JWT) on all state-mutating OAuth endpoints - M8: Strict Bearer scheme check in MCP token verification Refactoring: - Extract MCP session management (sessions Map, revokeUserSessions, revokeUserSessionsForClient) into mcp/sessionManager.ts to break the circular dependency between oauthService and mcp/index - Extract verifyJwtAndLoadUser helper in auth middleware, shared by authenticate and new requireCookieAuth middleware Tests: - Fix all existing integration tests broken by the security hardening (OAUTH-019 to OAUTH-032) - Add 13 new integration tests covering M1, M2, H1, H3, H5, M5, M7, C3 - Add 14 new unit tests covering C2, C3, H1, H3, M5 behaviors in oauthService
This commit is contained in:
@@ -51,6 +51,7 @@ vi.mock('../../src/services/adminService', async (importOriginal) => {
|
||||
vi.mock('../../src/services/oidcService', () => ({ getAppUrl: () => 'https://trek.example.com' }));
|
||||
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn(), sessions: new Map() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
@@ -471,20 +472,21 @@ describe('POST /oauth/revoke', () => {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/oauth/authorize/validate', () => {
|
||||
it('OAUTH-019 — returns 200 with valid:false when MCP addon disabled', async () => {
|
||||
it('OAUTH-019 — returns 404 when MCP addon disabled (M2: prevents feature fingerprinting)', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
const res = await request(app)
|
||||
.get('/api/oauth/authorize/validate')
|
||||
.query({ response_type: 'code', client_id: 'x', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read', code_challenge: 'c', code_challenge_method: 'S256' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
expect(res.body.error).toBe('mcp_disabled');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('OAUTH-020 — returns 200 with valid:false for wrong response_type', async () => {
|
||||
it('OAUTH-020 — returns 200 with valid:false for wrong response_type (authenticated)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { challenge } = makePkce();
|
||||
const res = await request(app)
|
||||
.get('/api/oauth/authorize/validate')
|
||||
.query({ response_type: 'token', client_id: 'x', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read', code_challenge: 'c', code_challenge_method: 'S256' });
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.query({ response_type: 'token', client_id: 'x', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read', code_challenge: challenge, code_challenge_method: 'S256' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
expect(res.body.error).toBe('unsupported_response_type');
|
||||
@@ -499,27 +501,32 @@ describe('GET /api/oauth/authorize/validate', () => {
|
||||
expect(res.body.error).toBe('invalid_request');
|
||||
});
|
||||
|
||||
it('OAUTH-022 — returns 200 with valid:false for unknown client_id', async () => {
|
||||
it('OAUTH-022 — returns 200 with valid:false for unknown client_id (authenticated)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { challenge } = makePkce();
|
||||
const res = await request(app)
|
||||
.get('/api/oauth/authorize/validate')
|
||||
.query({ response_type: 'code', client_id: 'unknown-client', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read', code_challenge: 'abc', code_challenge_method: 'S256' });
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.query({ response_type: 'code', client_id: 'unknown-client', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read', code_challenge: challenge, code_challenge_method: 'S256' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
expect(res.body.error).toBe('invalid_client');
|
||||
});
|
||||
|
||||
it('OAUTH-023 — returns 200 with valid:false for mismatched redirect_uri', async () => {
|
||||
it('OAUTH-023 — returns 200 with valid:false for mismatched redirect_uri (authenticated)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
||||
const { challenge } = makePkce();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/oauth/authorize/validate')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.query({
|
||||
response_type: 'code',
|
||||
client_id: r.client!.client_id,
|
||||
redirect_uri: 'https://evil.example.com/cb',
|
||||
scope: 'trips:read',
|
||||
code_challenge: 'abc',
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
@@ -527,18 +534,20 @@ describe('GET /api/oauth/authorize/validate', () => {
|
||||
expect(res.body.error).toBe('invalid_redirect_uri');
|
||||
});
|
||||
|
||||
it('OAUTH-024 — returns 200 with valid:false for empty scope', async () => {
|
||||
it('OAUTH-024 — returns 200 with valid:false for empty scope (authenticated)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
||||
const { challenge } = makePkce();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/oauth/authorize/validate')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.query({
|
||||
response_type: 'code',
|
||||
client_id: r.client!.client_id,
|
||||
redirect_uri: 'https://app.example.com/cb',
|
||||
scope: '',
|
||||
code_challenge: 'abc',
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
@@ -546,18 +555,20 @@ describe('GET /api/oauth/authorize/validate', () => {
|
||||
expect(res.body.error).toBe('invalid_scope');
|
||||
});
|
||||
|
||||
it('OAUTH-025a — narrows scope to allowed intersection when client lacks some requested scopes', async () => {
|
||||
it('OAUTH-025a — narrows scope to allowed intersection when client lacks some requested scopes (authenticated)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
||||
const { challenge } = makePkce();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/oauth/authorize/validate')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.query({
|
||||
response_type: 'code',
|
||||
client_id: r.client!.client_id,
|
||||
redirect_uri: 'https://app.example.com/cb',
|
||||
scope: 'trips:read trips:delete',
|
||||
code_challenge: 'abc',
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
@@ -566,18 +577,20 @@ describe('GET /api/oauth/authorize/validate', () => {
|
||||
expect(res.body.scopes).toEqual(['trips:read']);
|
||||
});
|
||||
|
||||
it('OAUTH-025b — returns 200 with valid:false when no requested scope is allowed', async () => {
|
||||
it('OAUTH-025b — returns 200 with valid:false when no requested scope is allowed (authenticated)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
||||
const { challenge } = makePkce();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/oauth/authorize/validate')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.query({
|
||||
response_type: 'code',
|
||||
client_id: r.client!.client_id,
|
||||
redirect_uri: 'https://app.example.com/cb',
|
||||
scope: 'budget:write',
|
||||
code_challenge: 'abc',
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
@@ -585,9 +598,10 @@ describe('GET /api/oauth/authorize/validate', () => {
|
||||
expect(res.body.error).toBe('invalid_scope');
|
||||
});
|
||||
|
||||
it('OAUTH-026 — returns 200 with loginRequired=true when no cookie session', async () => {
|
||||
it('OAUTH-026 — unauthenticated valid request returns loginRequired=true (H3: minimal response, no client info)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
||||
const { challenge } = makePkce();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/oauth/authorize/validate')
|
||||
@@ -596,17 +610,21 @@ describe('GET /api/oauth/authorize/validate', () => {
|
||||
client_id: r.client!.client_id,
|
||||
redirect_uri: 'https://app.example.com/cb',
|
||||
scope: 'trips:read',
|
||||
code_challenge: 'abc',
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.loginRequired).toBe(true);
|
||||
// H3: client name and scopes must NOT be revealed to unauthenticated callers
|
||||
expect(res.body.client).toBeUndefined();
|
||||
expect(res.body.allowed_scopes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('OAUTH-027 — returns 200 with loginRequired or consentRequired when session present but no prior consent', async () => {
|
||||
it('OAUTH-027 — authenticated with no prior consent returns consentRequired=true with client details', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
||||
const { challenge } = makePkce();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/oauth/authorize/validate')
|
||||
@@ -616,13 +634,15 @@ describe('GET /api/oauth/authorize/validate', () => {
|
||||
client_id: r.client!.client_id,
|
||||
redirect_uri: 'https://app.example.com/cb',
|
||||
scope: 'trips:read',
|
||||
code_challenge: 'abc',
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
// Either loginRequired=true (cookie not decoded in test env) or consentRequired=true (full decode working)
|
||||
expect(res.body.loginRequired === true || res.body.consentRequired === true).toBe(true);
|
||||
expect(res.body.consentRequired).toBe(true);
|
||||
// Authenticated users get full client info (unlike unauthenticated H3 path)
|
||||
expect(res.body.client).toBeDefined();
|
||||
expect(res.body.scopes).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -669,6 +689,7 @@ describe('POST /api/oauth/authorize', () => {
|
||||
|
||||
it('OAUTH-031 — invalid params returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { challenge } = makePkce();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/oauth/authorize')
|
||||
@@ -678,7 +699,7 @@ describe('POST /api/oauth/authorize', () => {
|
||||
client_id: 'unknown-client',
|
||||
redirect_uri: 'https://app.example.com/cb',
|
||||
scope: 'trips:read',
|
||||
code_challenge: 'abc',
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
@@ -687,6 +708,7 @@ describe('POST /api/oauth/authorize', () => {
|
||||
it('OAUTH-032 — happy path: approve returns redirect with code', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
||||
const { challenge } = makePkce();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/oauth/authorize')
|
||||
@@ -696,7 +718,7 @@ describe('POST /api/oauth/authorize', () => {
|
||||
client_id: r.client!.client_id,
|
||||
redirect_uri: 'https://app.example.com/cb',
|
||||
scope: 'trips:read',
|
||||
code_challenge: 'abc',
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
@@ -876,3 +898,354 @@ describe('Sessions — /api/oauth/sessions', () => {
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Security behavior tests (M1, M2, H1, H3, H5, M5, M7, C3)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('M1 — Cache-Control headers on /oauth/token', () => {
|
||||
it('OAUTH-SEC-001 — token endpoint sets Cache-Control: no-store', async () => {
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' });
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
expect(res.headers['pragma']).toBe('no-cache');
|
||||
});
|
||||
});
|
||||
|
||||
describe('M2 — 404 when MCP disabled on discovery + revoke endpoints', () => {
|
||||
it('OAUTH-SEC-002 — /.well-known/oauth-authorization-server returns 404 when disabled', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
const res = await request(app).get('/.well-known/oauth-authorization-server');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('OAUTH-SEC-003 — /oauth/revoke returns 404 when disabled', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
const res = await request(app)
|
||||
.post('/oauth/revoke')
|
||||
.send({ token: 'x', client_id: 'y', client_secret: 'z' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('H1 — PKCE format validation', () => {
|
||||
it('OAUTH-SEC-004 — short code_challenge (<43 chars) rejected on /authorize/validate', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
||||
const res = await request(app)
|
||||
.get('/api/oauth/authorize/validate')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.query({
|
||||
response_type: 'code',
|
||||
client_id: r.client!.client_id,
|
||||
redirect_uri: 'https://app.example.com/cb',
|
||||
scope: 'trips:read',
|
||||
code_challenge: 'tooshort',
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
expect(res.body.error).toBe('invalid_request');
|
||||
});
|
||||
|
||||
it('OAUTH-SEC-005 — wrong code_verifier format rejected on /oauth/token (invalid_grant)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
||||
const { challenge } = makePkce();
|
||||
|
||||
const code = createAuthCode({
|
||||
clientId: r.client!.client_id as string,
|
||||
userId: user.id,
|
||||
redirectUri: 'https://app.example.com/cb',
|
||||
scopes: ['trips:read'],
|
||||
codeChallenge: challenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
|
||||
// Submit a valid-looking but wrong-format verifier (too short)
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
code,
|
||||
redirect_uri: 'https://app.example.com/cb',
|
||||
code_verifier: 'short',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('invalid_grant');
|
||||
});
|
||||
});
|
||||
|
||||
describe('H3 — Unauthenticated /authorize/validate returns minimal response', () => {
|
||||
it('OAUTH-SEC-006 — invalid request by unauthenticated caller returns generic error (no oracle)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
||||
const { challenge } = makePkce();
|
||||
|
||||
// Deliberately wrong redirect_uri — should get generic error, not invalid_redirect_uri
|
||||
const res = await request(app)
|
||||
.get('/api/oauth/authorize/validate')
|
||||
.query({
|
||||
response_type: 'code',
|
||||
client_id: r.client!.client_id,
|
||||
redirect_uri: 'https://evil.example.com/cb',
|
||||
scope: 'trips:read',
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
expect(res.body.error).toBe('invalid_request');
|
||||
// Must not leak specific error type or client details
|
||||
expect(res.body.error).not.toBe('invalid_redirect_uri');
|
||||
expect(res.body.client).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('H5 — All invalid_grant cases return identical response body', () => {
|
||||
it('OAUTH-SEC-007 — expired/bad code, client_id mismatch, redirect_uri mismatch all return same body', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
||||
const { verifier, challenge } = makePkce();
|
||||
|
||||
const code = createAuthCode({
|
||||
clientId: r.client!.client_id as string,
|
||||
userId: user.id,
|
||||
redirectUri: 'https://app.example.com/cb',
|
||||
scopes: ['trips:read'],
|
||||
codeChallenge: challenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
|
||||
// Bad code
|
||||
const res1 = await request(app).post('/oauth/token').send({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
code: 'bad-code-xyz',
|
||||
redirect_uri: 'https://app.example.com/cb',
|
||||
code_verifier: verifier,
|
||||
});
|
||||
|
||||
// Redirect URI mismatch (need fresh code since code is single-use)
|
||||
const code2 = createAuthCode({
|
||||
clientId: r.client!.client_id as string,
|
||||
userId: user.id,
|
||||
redirectUri: 'https://app.example.com/cb',
|
||||
scopes: ['trips:read'],
|
||||
codeChallenge: challenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
const res2 = await request(app).post('/oauth/token').send({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
code: code2,
|
||||
redirect_uri: 'https://wrong.example.com/cb',
|
||||
code_verifier: verifier,
|
||||
});
|
||||
|
||||
expect(res1.status).toBe(400);
|
||||
expect(res2.status).toBe(400);
|
||||
expect(res1.body.error).toBe('invalid_grant');
|
||||
expect(res2.body.error).toBe('invalid_grant');
|
||||
// Both must use exactly the same error_description (H5)
|
||||
expect(res1.body.error_description).toBe(res2.body.error_description);
|
||||
});
|
||||
});
|
||||
|
||||
describe('M5 — Consent scope union (re-authorize adds to existing consent)', () => {
|
||||
it('OAUTH-SEC-008 — second consent adds new scope without losing old scope', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read', 'places:read']);
|
||||
const { challenge: ch1 } = makePkce();
|
||||
const { challenge: ch2 } = makePkce();
|
||||
|
||||
// First consent: trips:read
|
||||
await request(app)
|
||||
.post('/api/oauth/authorize')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
approved: true,
|
||||
client_id: r.client!.client_id,
|
||||
redirect_uri: 'https://app.example.com/cb',
|
||||
scope: 'trips:read',
|
||||
code_challenge: ch1,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
// Second consent: places:read — should not drop trips:read
|
||||
await request(app)
|
||||
.post('/api/oauth/authorize')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
approved: true,
|
||||
client_id: r.client!.client_id,
|
||||
redirect_uri: 'https://app.example.com/cb',
|
||||
scope: 'places:read',
|
||||
code_challenge: ch2,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
// Re-validate with trips:read — should now be auto-approved (consentRequired=false)
|
||||
const { challenge: ch3 } = makePkce();
|
||||
const res = await request(app)
|
||||
.get('/api/oauth/authorize/validate')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.query({
|
||||
response_type: 'code',
|
||||
client_id: r.client!.client_id,
|
||||
redirect_uri: 'https://app.example.com/cb',
|
||||
scope: 'trips:read',
|
||||
code_challenge: ch3,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.consentRequired).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('M7 — Cookie-only auth on privileged OAuth endpoints', () => {
|
||||
it('OAUTH-SEC-009 — POST /api/oauth/authorize rejects Bearer JWT (no cookie)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// Use a valid JWT in Authorization header (no cookie) — must be rejected
|
||||
const jwt = require('jsonwebtoken');
|
||||
const token = jwt.sign({ id: user.id }, 'test-jwt-secret-for-trek-testing-only', { algorithm: 'HS256' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/oauth/authorize')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ approved: true, client_id: 'x', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read', code_challenge: 'c', code_challenge_method: 'S256' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe('COOKIE_AUTH_REQUIRED');
|
||||
});
|
||||
|
||||
it('OAUTH-SEC-010 — POST /api/oauth/clients rejects Bearer JWT (no cookie)', async () => {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { user } = createUser(testDb);
|
||||
const token = jwt.sign({ id: user.id }, 'test-jwt-secret-for-trek-testing-only', { algorithm: 'HS256' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/oauth/clients')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ name: 'App', redirect_uris: ['https://app.example.com/cb'], allowed_scopes: ['trips:read'] });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe('COOKIE_AUTH_REQUIRED');
|
||||
});
|
||||
|
||||
it('OAUTH-SEC-011 — DELETE /api/oauth/sessions/:id rejects Bearer JWT (no cookie)', async () => {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { user } = createUser(testDb);
|
||||
const token = jwt.sign({ id: user.id }, 'test-jwt-secret-for-trek-testing-only', { algorithm: 'HS256' });
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/oauth/sessions/1')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe('COOKIE_AUTH_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C3 — Refresh token replay detection', () => {
|
||||
it('OAUTH-SEC-012 — replaying a rotated (old) refresh token returns invalid_grant', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
||||
const { verifier, challenge } = makePkce();
|
||||
|
||||
const code = createAuthCode({
|
||||
clientId: r.client!.client_id as string,
|
||||
userId: user.id,
|
||||
redirectUri: 'https://app.example.com/cb',
|
||||
scopes: ['trips:read'],
|
||||
codeChallenge: challenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
|
||||
// Get initial tokens
|
||||
const t1 = await request(app).post('/oauth/token').send({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
code,
|
||||
redirect_uri: 'https://app.example.com/cb',
|
||||
code_verifier: verifier,
|
||||
});
|
||||
expect(t1.status).toBe(200);
|
||||
const originalRefreshToken = t1.body.refresh_token;
|
||||
|
||||
// Rotate once (legitimate use)
|
||||
const t2 = await request(app).post('/oauth/token').send({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
refresh_token: originalRefreshToken,
|
||||
});
|
||||
expect(t2.status).toBe(200);
|
||||
|
||||
// Replay the original (now rotated/revoked) refresh token — must be rejected
|
||||
const t3 = await request(app).post('/oauth/token').send({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
refresh_token: originalRefreshToken,
|
||||
});
|
||||
expect(t3.status).toBe(400);
|
||||
expect(t3.body.error).toBe('invalid_grant');
|
||||
});
|
||||
|
||||
it('OAUTH-SEC-013 — replaying old token also invalidates the new chain', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
||||
const { verifier, challenge } = makePkce();
|
||||
|
||||
const code = createAuthCode({
|
||||
clientId: r.client!.client_id as string,
|
||||
userId: user.id,
|
||||
redirectUri: 'https://app.example.com/cb',
|
||||
scopes: ['trips:read'],
|
||||
codeChallenge: challenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
|
||||
const t1 = await request(app).post('/oauth/token').send({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
code,
|
||||
redirect_uri: 'https://app.example.com/cb',
|
||||
code_verifier: verifier,
|
||||
});
|
||||
const originalRefreshToken = t1.body.refresh_token;
|
||||
|
||||
// Legitimate rotate — get new token
|
||||
const t2 = await request(app).post('/oauth/token').send({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
refresh_token: originalRefreshToken,
|
||||
});
|
||||
const newRefreshToken = t2.body.refresh_token;
|
||||
|
||||
// Replay original — triggers chain revocation
|
||||
await request(app).post('/oauth/token').send({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
refresh_token: originalRefreshToken,
|
||||
});
|
||||
|
||||
// New token (from legitimate rotation) must also be dead now
|
||||
const t4 = await request(app).post('/oauth/token').send({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
refresh_token: newRefreshToken,
|
||||
});
|
||||
expect(t4.status).toBe(400);
|
||||
expect(t4.body.error).toBe('invalid_grant');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||
decrypt_api_key: (v: string) => v,
|
||||
maybe_encrypt_api_key: (v: string) => v,
|
||||
}));
|
||||
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() }));
|
||||
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),
|
||||
@@ -44,6 +44,13 @@ 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,
|
||||
@@ -520,6 +527,9 @@ describe('listOAuthSessions + revokeSession', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
@@ -533,7 +543,7 @@ describe('validateAuthorizeRequest', () => {
|
||||
client_id: '',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
scope: 'trips:read',
|
||||
code_challenge: 'abc123',
|
||||
code_challenge: VALID_CHALLENGE,
|
||||
code_challenge_method: 'S256',
|
||||
...overrides,
|
||||
};
|
||||
@@ -699,3 +709,231 @@ describe('saveConsent + getConsent + isConsentSufficient', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user