Files
TREK/server/tests/integration/oauth.test.ts
T
Maurice 03493a2edd refactor(server): fold webauthn, account erasure, oauth and the wiki out of services/ (#1840)
* refactor(server): move the webauthn config and the account erasure into nest/auth

webauthnConfig resolved the RP ID through its own getSetting() on the db
singleton; it is a WebauthnConfigService over DatabaseService now, injected into
AuthService (passkey_configured) and PasskeyService (four ceremonies). readEnv()
is still read per call, so an operator flipping the RP ID at runtime takes effect
on the next ceremony instead of after a restart.

userCleanupService follows as UserCleanupService. It keeps taking
removeUserFromBudgetItems from budget.bridge rather than injecting BudgetService:
BudgetModule imports AuthModule for the demo guard in BudgetMcp, so AuthModule
cannot import BudgetModule back, and breaking that edge the other way drags
BudgetController, BudgetMcp and its trips.bridge chain into every graph that
imports AuthModule.

The legacy module had no tests of its own - the erasure only ever ran through
AdminService.deleteUser and TripsService.deleteGuest - so USER-CLEANUP-001..009
pin both halves, including the orphan data dir that no permissions row can
describe and the all-or-nothing deletion transaction.

* refactor(server): fold the oauth 2.1 server out of services/

694 lines move into OauthService over DatabaseService, AddonsService and
AuditService. The thin wrapper that only forwarded is gone, and so is the second
path to the MCP addon flag - validateAuthorizeRequest read it through
addons.bridge while the wrapper read it through the injected service, which would
have let the two drift.

The pending authorization codes stay a module singleton in oauth.pending-codes.ts
instead of becoming instance state. The SDK still serves /oauth/authorize,
/oauth/register and /oauth/consent from its own router, mounted on Express before
app.init(), so it reaches the domain through the new oauth.bridge.ts - two maps
would break the authorization-code flow silently, since the SDK only ever answers
"Authorization grant is invalid.". The import-time sweep keeps its .unref() and is
a named function now so a test can drive it.

scopes and sessionManager are imported deep, never through the src/mcp barrel:
the barrel re-exports revokeUserSessionsForClient and pulls the domain bridges
back in, which is the nest -> mcp -> nest cycle. Same comment as admin.service and
auth.service carry.

The 70 cases of the legacy suite moved over unchanged, bound to a hand-built
service. Fifteen more cover what free functions never let anyone reach: the code
store at capacity, an expired code, an expired access token, refresh against a
public and an unknown client, the RFC 8707 resource checks, and that a code
written through the container is read back through the bridge.

* refactor(server): move the wiki reader into the help module

wikiService had exactly one consumer, HelpController, and touches no database -
it stays a plain module rather than becoming a provider, the way
auth/ephemeral-tokens.ts does, so the text and asset caches keep being one per
process without any ceremony around it.

The WIKI_DIR anchor needed one more level: src/services sits three below the repo
root, src/nest/help sits four. The existing path-resolution case caught it, which
is what it is there for - a wrong anchor makes help degrade to the GitHub fallback
without anybody noticing.

Also brings the nest README back in line: services/ is down to five top-level
files, and the passkey and budget entries no longer point at paths that were
deleted since they were written.
2026-08-08 11:14:24 +02:00

1433 lines
63 KiB
TypeScript

/**
* OAuth 2.1 integration tests.
* Covers oauthPublicRouter (/.well-known, /oauth/token, /oauth/revoke)
* and oauthApiRouter (/api/oauth/*).
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
import crypto from 'crypto';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
SESSION_DURATION: '24h',
SESSION_DURATION_MS: 86400000,
SESSION_DURATION_SECONDS: 86400,
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/app-config', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/app-config')>();
return { ...actual, getMcpSafeUrl: () => '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 { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { createOAuthClient, createAuthCode, getUserByAccessToken } from '../../src/nest/oauth/oauth.bridge';
let nestApp: INestApplication;
let app: Application;
// PKCE helpers
function makePkce() {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
// Since the admin-1 extraction, both OauthService (injected AddonsService) and
// the legacy oauthService (addons.bridge) resolve the MCP addon from the real
// addons row, so driving the DB row keeps mcpEnabled() AND
// validateAuthorizeRequest()/token/revoke consistent from one source.
function setMcpEnabled(enabled: boolean) {
testDb.prepare(
"INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('mcp', 'MCP', 'AI assistant integration', 'integration', 'Terminal', ?, 12)"
).run(enabled ? 1 : 0);
}
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
resetRateLimits(nestApp);
setMcpEnabled(true);
});
afterAll(async () => {
await nestApp.close();
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// Discovery document
// ─────────────────────────────────────────────────────────────────────────────
describe('GET /.well-known/oauth-authorization-server', () => {
it('OAUTH-001 — returns RFC 8414 discovery document', async () => {
const res = await request(app).get('/.well-known/oauth-authorization-server');
expect(res.status).toBe(200);
expect(res.body.issuer).toBe('https://trek.example.com');
expect(res.body.authorization_endpoint).toContain('/oauth/authorize');
expect(res.body.token_endpoint).toContain('/oauth/token');
expect(Array.isArray(res.body.scopes_supported)).toBe(true);
expect(res.body.scopes_supported.length).toBeGreaterThan(0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Issue #959 regression tests
// ─────────────────────────────────────────────────────────────────────────────
describe('RFC 9728 — path-based protected resource metadata (issue #959 bug 1)', () => {
it('OAUTH-959A — /.well-known/oauth-protected-resource/mcp returns JSON (not SPA HTML)', async () => {
const res = await request(app).get('/.well-known/oauth-protected-resource/mcp');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/json/);
expect(res.body.resource).toContain('/mcp');
expect(Array.isArray(res.body.authorization_servers)).toBe(true);
});
});
describe('DCR scope optional — ChatGPT compatibility (issue #959 bug 2)', () => {
it('OAUTH-959B — POST /oauth/register without scope field returns 201 with default scopes', async () => {
const res = await request(app)
.post('/oauth/register')
.set('Content-Type', 'application/json')
.send({ redirect_uris: ['https://chatgpt.example.com/cb'], token_endpoint_auth_method: 'none' });
expect(res.status).toBe(201);
expect(res.body.client_id).toBeDefined();
expect(typeof res.body.scope).toBe('string');
expect(res.body.scope.length).toBeGreaterThan(0);
});
it('OAUTH-959C — POST /oauth/register with explicit scope registers only requested scopes', async () => {
const res = await request(app)
.post('/oauth/register')
.set('Content-Type', 'application/json')
.send({ redirect_uris: ['https://example.com/cb'], token_endpoint_auth_method: 'none', scope: 'trips:read' });
expect(res.status).toBe(201);
expect(res.body.scope).toBe('trips:read');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// POST /oauth/token — authorization_code grant
// ─────────────────────────────────────────────────────────────────────────────
describe('POST /oauth/token — authorization_code grant', () => {
it('OAUTH-002 — missing client_id returns 401 invalid_client', async () => {
const res = await request(app)
.post('/oauth/token')
.send({ grant_type: 'authorization_code', code: 'x', redirect_uri: 'https://example.com/cb', code_verifier: 'y' });
expect(res.status).toBe(401);
expect(res.body.error).toBe('invalid_client');
});
it('OAUTH-003 — MCP addon disabled returns 404', async () => {
setMcpEnabled(false);
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.status).toBe(404);
});
it('OAUTH-004 — missing code/redirect_uri/code_verifier returns 400 invalid_request', async () => {
const res = await request(app)
.post('/oauth/token')
.send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid_request');
});
it('OAUTH-005 — invalid auth code returns 400 invalid_grant', async () => {
const { user } = createUser(testDb);
const clientResult = createOAuthClient(user.id, 'TestApp', ['https://app.example.com/cb'], ['trips:read']);
const client = clientResult.client!;
const res = await request(app)
.post('/oauth/token')
.send({
grant_type: 'authorization_code',
client_id: client.client_id,
client_secret: clientResult.client!.client_secret,
code: 'invalid-code-xyz',
redirect_uri: 'https://app.example.com/cb',
code_verifier: 'verifier',
});
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid_grant');
});
it('OAUTH-006 — client_id mismatch returns 400 invalid_grant', async () => {
const { user } = createUser(testDb);
const r1 = createOAuthClient(user.id, 'App1', ['https://app1.example.com/cb'], ['trips:read']);
const r2 = createOAuthClient(user.id, 'App2', ['https://app2.example.com/cb'], ['trips:read']);
const { verifier, challenge } = makePkce();
// Create code for client1
const code = createAuthCode({
clientId: r1.client!.client_id as string,
userId: user.id,
redirectUri: 'https://app1.example.com/cb',
scopes: ['trips:read'],
codeChallenge: challenge,
codeChallengeMethod: 'S256',
});
// Try to use it with client2
const res = await request(app)
.post('/oauth/token')
.send({
grant_type: 'authorization_code',
client_id: r2.client!.client_id,
client_secret: r2.client!.client_secret,
code,
redirect_uri: 'https://app1.example.com/cb',
code_verifier: verifier,
});
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid_grant');
});
it('OAUTH-007 — redirect_uri mismatch returns 400 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',
});
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://wrong.example.com/cb',
code_verifier: verifier,
});
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid_grant');
});
it('OAUTH-008 — wrong client_secret returns 401 invalid_client (timing-safe check)', 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 res = await request(app)
.post('/oauth/token')
.send({
grant_type: 'authorization_code',
client_id: r.client!.client_id,
client_secret: 'wrong-secret',
code,
redirect_uri: 'https://app.example.com/cb',
code_verifier: verifier,
});
expect(res.status).toBe(401);
expect(res.body.error).toBe('invalid_client');
});
it('OAUTH-009 — PKCE failure returns 400 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',
});
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: 'this-is-a-wrong-verifier',
});
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid_grant');
});
it('OAUTH-010 — happy path: exchange auth code for tokens', 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 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: verifier,
});
expect(res.status).toBe(200);
expect(res.body.access_token).toBeDefined();
expect(res.body.refresh_token).toBeDefined();
expect(res.body.token_type).toBe('Bearer');
expect(typeof res.body.expires_in).toBe('number');
expect(res.body.scope).toBe('trips:read');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// POST /oauth/token — refresh_token grant
// ─────────────────────────────────────────────────────────────────────────────
describe('POST /oauth/token — refresh_token grant', () => {
it('OAUTH-011 — missing refresh_token returns 400 invalid_request', async () => {
const res = await request(app)
.post('/oauth/token')
.send({ grant_type: 'refresh_token', client_id: 'x', client_secret: 'y' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid_request');
});
it('OAUTH-012 — invalid refresh token returns 400 invalid_grant', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
const res = 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: 'invalid-refresh-token',
});
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid_grant');
});
it('OAUTH-013 — happy path: issue then refresh tokens', 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',
});
// Exchange code for tokens
const tokenRes = 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(tokenRes.status).toBe(200);
const { refresh_token } = tokenRes.body;
// Use refresh token to get new tokens
const refreshRes = 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,
});
expect(refreshRes.status).toBe(200);
expect(refreshRes.body.access_token).toBeDefined();
expect(refreshRes.body.refresh_token).toBeDefined();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// POST /oauth/token — unsupported grant_type
// ─────────────────────────────────────────────────────────────────────────────
describe('POST /oauth/token — unsupported grant_type', () => {
it('OAUTH-014 — returns 400 unsupported_grant_type', async () => {
const res = await request(app)
.post('/oauth/token')
.send({ grant_type: 'password', client_id: 'x', client_secret: 'y' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('unsupported_grant_type');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// POST /oauth/revoke
// ─────────────────────────────────────────────────────────────────────────────
describe('POST /oauth/revoke', () => {
it('OAUTH-015 — missing params returns 400 invalid_request', async () => {
const res = await request(app)
.post('/oauth/revoke')
.send({ token: 'x' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid_request');
});
it('OAUTH-016 — wrong client_secret returns 401 invalid_client', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
const res = await request(app)
.post('/oauth/revoke')
.send({ token: 'sometoken', client_id: r.client!.client_id, client_secret: 'wrong' });
expect(res.status).toBe(401);
expect(res.body.error).toBe('invalid_client');
});
it('OAUTH-017 — valid revoke returns 200 even for unknown token (RFC 7009)', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
const res = await request(app)
.post('/oauth/revoke')
.send({ token: 'nonexistent-token', client_id: r.client!.client_id, client_secret: r.client!.client_secret });
expect(res.status).toBe(200);
});
it('OAUTH-018 — happy path: issue token, revoke it, verify refresh no longer works', 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 tokenRes = 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(tokenRes.status).toBe(200);
const { refresh_token } = tokenRes.body;
// Revoke the refresh token
const revokeRes = await request(app)
.post('/oauth/revoke')
.send({ token: refresh_token, client_id: r.client!.client_id, client_secret: r.client!.client_secret });
expect(revokeRes.status).toBe(200);
// Try to use the revoked token — should fail
const retryRes = 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,
});
expect(retryRes.status).toBe(400);
expect(retryRes.body.error).toBe('invalid_grant');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// GET /api/oauth/authorize/validate
// ─────────────────────────────────────────────────────────────────────────────
describe('GET /api/oauth/authorize/validate', () => {
it('OAUTH-019 — returns 404 when MCP addon disabled (M2: prevents feature fingerprinting)', async () => {
setMcpEnabled(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(404);
});
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')
.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');
});
it('OAUTH-021 — returns 200 with valid:false for missing PKCE', async () => {
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' });
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
expect(res.body.error).toBe('invalid_request');
});
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')
.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 (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: challenge,
code_challenge_method: 'S256',
});
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
expect(res.body.error).toBe('invalid_redirect_uri');
});
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: challenge,
code_challenge_method: 'S256',
});
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
expect(res.body.error).toBe('invalid_scope');
});
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: challenge,
code_challenge_method: 'S256',
});
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
// trips:delete was dropped — only trips:read granted
expect(res.body.scopes).toEqual(['trips:read']);
});
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: challenge,
code_challenge_method: 'S256',
});
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
expect(res.body.error).toBe('invalid_scope');
});
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')
.query({
response_type: 'code',
client_id: r.client!.client_id,
redirect_uri: 'https://app.example.com/cb',
scope: 'trips:read',
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 — 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')
.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: challenge,
code_challenge_method: 'S256',
});
expect(res.status).toBe(200);
expect(res.body.valid).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();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// POST /api/oauth/authorize
// ─────────────────────────────────────────────────────────────────────────────
describe('POST /api/oauth/authorize', () => {
it('OAUTH-028 — unauthenticated returns 401', async () => {
const res = await request(app)
.post('/api/oauth/authorize')
.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);
});
it('OAUTH-029 — 403 when MCP disabled', async () => {
setMcpEnabled(false);
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/oauth/authorize')
.set('Cookie', authCookie(user.id))
.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(403);
});
it('OAUTH-030 — user denied returns redirect with error=access_denied', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/oauth/authorize')
.set('Cookie', authCookie(user.id))
.send({
approved: false,
client_id: 'any',
redirect_uri: 'https://app.example.com/cb',
scope: 'trips:read',
code_challenge: 'c',
code_challenge_method: 'S256',
});
expect(res.status).toBe(200);
expect(res.body.redirect).toContain('error=access_denied');
});
it('OAUTH-031 — invalid params returns 400', async () => {
const { user } = createUser(testDb);
const { challenge } = makePkce();
const res = await request(app)
.post('/api/oauth/authorize')
.set('Cookie', authCookie(user.id))
.send({
approved: true,
client_id: 'unknown-client',
redirect_uri: 'https://app.example.com/cb',
scope: 'trips:read',
code_challenge: challenge,
code_challenge_method: 'S256',
});
expect(res.status).toBe(400);
});
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')
.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: challenge,
code_challenge_method: 'S256',
});
expect(res.status).toBe(200);
expect(res.body.redirect).toBeDefined();
expect(res.body.redirect).toContain('code=');
expect(res.body.redirect).not.toContain('error=');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Client CRUD
// ─────────────────────────────────────────────────────────────────────────────
describe('Client CRUD — /api/oauth/clients', () => {
it('OAUTH-033 — GET returns 403 when addon disabled', async () => {
setMcpEnabled(false);
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/oauth/clients')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(403);
});
it('OAUTH-034 — GET returns 200 with clients list', async () => {
const { user } = createUser(testDb);
createOAuthClient(user.id, 'MyApp', ['https://app.example.com/cb'], ['trips:read']);
const res = await request(app)
.get('/api/oauth/clients')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.clients)).toBe(true);
expect(res.body.clients).toHaveLength(1);
expect(res.body.clients[0].name).toBe('MyApp');
});
it('OAUTH-035 — POST creates client and returns 201 with client_secret', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/oauth/clients')
.set('Cookie', authCookie(user.id))
.send({ name: 'NewApp', redirect_uris: ['https://newapp.example.com/cb'], allowed_scopes: ['trips:read'] });
expect(res.status).toBe(201);
expect(res.body.client).toBeDefined();
expect(res.body.client.client_id).toBeDefined();
expect(res.body.client.client_secret).toBeDefined();
expect(res.body.client.name).toBe('NewApp');
});
it('OAUTH-036 — POST returns 403 when addon disabled', async () => {
setMcpEnabled(false);
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/oauth/clients')
.set('Cookie', authCookie(user.id))
.send({ name: 'App', redirect_uris: ['https://app.example.com/cb'], allowed_scopes: ['trips:read'] });
expect(res.status).toBe(403);
});
it('OAUTH-037 — POST /clients/:id/rotate rotates secret', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
const res = await request(app)
.post(`/api/oauth/clients/${r.client!.id}/rotate`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.client_secret).toBeDefined();
expect(res.body.client_secret).not.toBe(r.client!.client_secret);
});
it('OAUTH-038 — DELETE /clients/:id deletes client', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
const res = await request(app)
.delete(`/api/oauth/clients/${r.client!.id}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('OAUTH-039 — DELETE /clients/:id returns 404 for non-existent', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.delete('/api/oauth/clients/nonexistent-id')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Sessions
// ─────────────────────────────────────────────────────────────────────────────
describe('Sessions — /api/oauth/sessions', () => {
it('OAUTH-040 — GET returns 403 when addon disabled', async () => {
setMcpEnabled(false);
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/oauth/sessions')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(403);
});
it('OAUTH-041 — GET returns 200 with sessions list', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/oauth/sessions')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.sessions)).toBe(true);
});
it('OAUTH-042 — DELETE /sessions/:id revokes session', 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 a token so there's a session to revoke
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 sessionsRes = await request(app)
.get('/api/oauth/sessions')
.set('Cookie', authCookie(user.id));
expect(sessionsRes.body.sessions).toHaveLength(1);
const sessionId = sessionsRes.body.sessions[0].id;
const deleteRes = await request(app)
.delete(`/api/oauth/sessions/${sessionId}`)
.set('Cookie', authCookie(user.id));
expect(deleteRes.status).toBe(200);
expect(deleteRes.body.success).toBe(true);
});
it('OAUTH-043 — DELETE /sessions/:id returns 404 for non-existent', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.delete('/api/oauth/sessions/99999')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
it('OAUTH-044 — DELETE /sessions/:id returns 403 when addon disabled', async () => {
setMcpEnabled(false);
const { user } = createUser(testDb);
const res = await request(app)
.delete('/api/oauth/sessions/1')
.set('Cookie', authCookie(user.id));
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');
});
});
describe('M2 — 404 when MCP disabled on discovery + revoke endpoints', () => {
it('OAUTH-SEC-002 — /.well-known/oauth-authorization-server returns 404 when disabled', async () => {
setMcpEnabled(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 () => {
setMcpEnabled(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');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// POST /oauth/token — client_credentials grant
// ─────────────────────────────────────────────────────────────────────────────
describe('POST /oauth/token — client_credentials grant', () => {
it('OAUTH-CC-001 — happy path: issues access token with no refresh_token', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
const res = await request(app)
.post('/oauth/token')
.send({
grant_type: 'client_credentials',
client_id: r.client!.client_id,
client_secret: r.client!.client_secret,
});
expect(res.status).toBe(200);
expect(res.body.access_token).toBeDefined();
expect(res.body.token_type).toBe('Bearer');
expect(typeof res.body.expires_in).toBe('number');
expect(res.body.scope).toBe('trips:read');
expect(res.body.refresh_token).toBeUndefined();
});
it('OAUTH-CC-002 — issued token resolves to the client owner user', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
const res = await request(app)
.post('/oauth/token')
.send({
grant_type: 'client_credentials',
client_id: r.client!.client_id,
client_secret: r.client!.client_secret,
});
expect(res.status).toBe(200);
const info = getUserByAccessToken(res.body.access_token);
expect(info).not.toBeNull();
expect(info!.user.id).toBe(user.id);
expect(info!.scopes).toEqual(['trips:read']);
});
it('OAUTH-CC-003 — wrong client_secret returns 401 invalid_client', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
const res = await request(app)
.post('/oauth/token')
.send({
grant_type: 'client_credentials',
client_id: r.client!.client_id,
client_secret: 'trekcs_wrong',
});
expect(res.status).toBe(401);
expect(res.body.error).toBe('invalid_client');
});
it('OAUTH-CC-004 — missing client_secret returns 401 invalid_client', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
const res = await request(app)
.post('/oauth/token')
.send({
grant_type: 'client_credentials',
client_id: r.client!.client_id,
});
expect(res.status).toBe(401);
expect(res.body.error).toBe('invalid_client');
});
it('OAUTH-CC-005 — non-machine client returns 400 unauthorized_client', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'BrowserApp', ['https://app.example.com/cb'], ['trips:read']);
const res = await request(app)
.post('/oauth/token')
.send({
grant_type: 'client_credentials',
client_id: r.client!.client_id,
client_secret: r.client!.client_secret,
});
expect(res.status).toBe(400);
expect(res.body.error).toBe('unauthorized_client');
});
it('OAUTH-CC-006 — scope narrowing: requested subset is honoured', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read', 'places:read'], null, { allowsClientCredentials: true });
const res = await request(app)
.post('/oauth/token')
.send({
grant_type: 'client_credentials',
client_id: r.client!.client_id,
client_secret: r.client!.client_secret,
scope: 'trips:read',
});
expect(res.status).toBe(200);
expect(res.body.scope).toBe('trips:read');
});
it('OAUTH-CC-007 — scope outside allowed_scopes returns 400 invalid_scope', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
const res = await request(app)
.post('/oauth/token')
.send({
grant_type: 'client_credentials',
client_id: r.client!.client_id,
client_secret: r.client!.client_secret,
scope: 'places:write',
});
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid_scope');
});
it('OAUTH-CC-008 — createOAuthClient with allowsClientCredentials succeeds without redirect URIs', () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
expect(r.error).toBeUndefined();
expect(r.client).toBeDefined();
expect(r.client!.allows_client_credentials).toBe(true);
expect((r.client!.redirect_uris as string[]).length).toBe(0);
expect(r.client!.client_secret).toBeDefined();
});
});