Files
TREK/server/tests/integration/oauth.test.ts
T
Maurice df3edac7b9 feat: six community requests — import dedup, accommodations in the feed, OAuth refresh race, visited-from-trip, journal jumps, phone packing rows (#1915)
* fix(places): match imports on the provider id so renamed places are not duplicated

Re-importing a Google Maps list duplicated every place the user had renamed.
buildDedupSet only ever collected names and, for unnamed places, coordinates,
and isPlaceDuplicate returns on the name before it reaches the coordinate
fallback, so a renamed place was findable by neither. The ids were stored all
along, and even backfilled onto matched places, they were simply never read.

Match on google_place_id / google_ftid / osm_id first, keeping name and
coordinates in their old roles below it. Widening the coordinate check instead
would have merged the restaurant and the bar in the same building.

The collection copy carried a hand-mirrored port of the same predicate; it now
uses the shared helpers and gets the fix with them.

Closes #1550

* feat(calendar): put accommodations into the subscribed feed as all-day stays

A hotel keeps its dates on the linked stay, not on the reservation: the booking
form writes reservation_time = NULL for type 'hotel', and day_accommodations
carries start day, end day and the check-in/out clock. The feed only ever read
the reservation, so a stay showed up once on the arrival day at best, and not at
all when it came from the booking form.

Join the stay in and emit the reservation as an all-day range over every night,
which also keeps it correct when days are reordered later — the dates now come
from the day rows rather than from the copy stamped at creation time. Check-in
and check-out become their own timed events when the stay records a clock,
because an all-day event cannot carry the time you actually need reminding of.
The check-in window, when set, becomes that event's DTEND.

Closes #1586

* fix(oauth): treat a concurrent refresh as a race, not as a stolen token

Refresh rotation is a race by construction. Several MCP clients share one
refresh token, both post it within the same second, and the loser was read as a
replay: chain revoked, MCP sessions torn down, browser window asking to log in
again. That is the daily re-authentication people were seeing, not a short
lifetime — access tokens last an hour and refresh tokens roll for thirty days.

A revoked refresh token now only counts as a replay once it is outside a
30-second grace window, and only when the rotation that revoked it left a
successor still alive. Inside the window the caller gets its own pair branched
off the same parent, so both clients walk away with a working token. Explicit
revocation leaves no live successor and a chain killed by a real replay has none
either, so neither can slip through the window.

Closes #1007

* feat(collections): mark a place visited from the trip it belongs to

A place saved in several lists had to be opened in each of them to say you had
been there, which is the wrong way round: you find out you have been somewhere
while looking at the trip, not while browsing the library.

The save picker now shows each list's own status and cycles it in place, with one
action for "visited, wherever this is saved". The planner's selection bar and the
mobile bulk toolbar carry the same thing for a whole selection — select all, one
tap, done. Matching happens on the server with the signals findMembership already
uses plus the source link a place saved from this trip carries, so a copy renamed
inside a list is still found. Lists you may only read are skipped rather than
refused; a shared list you cannot edit should not stop you marking your own.

Closes #1469

* feat(journey): jump to the top or back to the last entry in a long journal

Everything that adds to a journal sits at the top — the entry button, the
gallery upload — and where you left off sits at the bottom, so adding one photo
to a long journey meant scrolling the whole way twice.

Two buttons float over the middle of the feed, clear of the Add Entry column on
one side and the reorder arrows on the other, each appearing only when there is
more than a screenful to travel in that direction. They live in a zero-height
sticky box so they take no layout space and never push the timeline around.
While in the header, the status pill also picks up the height of the round
buttons beside it.

Closes #1088

* fix(packing): give the item name the row back on phones

An item name had roughly a third of the row to itself: a spelled-out sharing
sentence, an empty weight column and two round action buttons took the rest, so
anything longer than a word was cut off.

Sharing is now the icon it always was next to a label rather than a sentence in
the layout, an unset weight prints nothing instead of a dash, and edit/delete
sit behind one overflow button that opens the same inline menu the bag picker
already uses. The to-do list needed none of this — it wraps its chips onto a
second line already.

Closes #1525

* docs(wiki): cover the six community changes

- Calendar feeds: accommodations as an all-day range plus check-in/check-out
- Collections: setting the visited status from a trip, and how places are matched
- Journey: the jump buttons on a long journal
- Packing: what the phone row keeps and what moved behind the overflow button
- Places: re-importing a list no longer duplicates renamed places
- MCP setup: the rotation race no longer ends an OAuth session

* test(collections): assert the stored status instead of a private method

tests/ sits outside the build tsconfig, so reaching into getPlaceById only
showed up in the dedicated typecheck step. Reading the row is the better
assertion anyway — it pins what was persisted, not what a getter returns.
2026-08-12 17:43:22 +02:00

1595 lines
71 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 { ALL_SCOPES } from '../../src/mcp/scopes';
import { OauthService } from '../../src/nest/oauth/oauth.service';
import { DatabaseService } from '../../src/nest/database/database.service';
import { AddonsService } from '../../src/nest/addons/addons.service';
import { AuditService } from '../../src/nest/audit/audit.service';
// In production the consent controller writes pending codes through the
// container instance and the SDK routes read them back through the same
// injected singleton. These tests write codes the way the consent controller
// does: through a service instance. The pending-code map is module-scoped in
// oauth.pending-codes.ts, so the routes under test see every code written here.
const oauthDbs = new DatabaseService(testDb);
const containerSideOauth = new OauthService(oauthDbs, new AddonsService(oauthDbs), new AuditService(oauthDbs));
const createAuthCode = containerSideOauth.createAuthCode.bind(containerSideOauth);
const createOAuthClient = containerSideOauth.createOAuthClient.bind(containerSideOauth);
const getUserByAccessToken = containerSideOauth.getUserByAccessToken.bind(containerSideOauth);
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');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Platform/discovery parity pins — byte-level oracles for the MCP/OAuth mount
// migration (these must pass identically before AND after the routes move
// behind the Nest container).
// ─────────────────────────────────────────────────────────────────────────────
describe('platform/discovery parity pins', () => {
it('PLAT-PIN-001 — GET /api/health returns the exact probe body + Cache-Control', async () => {
const res = await request(app).get('/api/health');
expect(res.status).toBe(200);
expect(res.headers['cache-control']).toBe('no-store, must-revalidate');
expect(res.body).toEqual({ status: 'ok' });
});
it('PLAT-PIN-002 — openid-configuration is the AS metadata plus userinfo_endpoint, exactly', async () => {
const res = await request(app).get('/.well-known/openid-configuration');
expect(res.status).toBe(200);
expect(res.body).toEqual({
issuer: 'https://trek.example.com',
authorization_endpoint: 'https://trek.example.com/oauth/authorize',
token_endpoint: 'https://trek.example.com/oauth/token',
revocation_endpoint: 'https://trek.example.com/oauth/revoke',
registration_endpoint: 'https://trek.example.com/oauth/register',
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
scopes_supported: ALL_SCOPES,
userinfo_endpoint: 'https://trek.example.com/oauth/userinfo',
});
});
it('PLAT-PIN-003 — flat oauth-protected-resource is the exact 5-key RFC 9728 document', async () => {
const res = await request(app).get('/.well-known/oauth-protected-resource');
expect(res.status).toBe(200);
expect(res.body).toEqual({
resource: 'https://trek.example.com/mcp',
authorization_servers: ['https://trek.example.com'],
bearer_methods_supported: ['header'],
scopes_supported: ALL_SCOPES,
resource_name: 'TREK MCP',
});
});
it('PLAT-PIN-004 — every /.well-known/* path 404s with an EMPTY body when MCP is disabled', async () => {
setMcpEnabled(false);
for (const path of [
'/.well-known/openid-configuration',
'/.well-known/oauth-protected-resource',
'/.well-known/oauth-authorization-server',
]) {
const res = await request(app).get(path);
expect(res.status, path).toBe(404);
expect(res.text, path).toBe('');
}
});
it('PLAT-PIN-005 — an unhandled /.well-known path gets the JSON 404, never SPA HTML', async () => {
const res = await request(app).get('/.well-known/does-not-exist');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'not_found' });
});
it('PLAT-PIN-006 — /.well-known/ (trailing slash) also gets the JSON 404', async () => {
const res = await request(app).get('/.well-known/');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'not_found' });
});
it('PLAT-PIN-007 — bare /.well-known falls through to the router 404 envelope', async () => {
const res = await request(app).get('/.well-known');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Cannot GET /.well-known' });
});
it('PLAT-PIN-008 — /oauth/consent responses carry the relaxed COOP header', async () => {
const res = await request(app).get('/oauth/consent');
expect(res.headers['cross-origin-opener-policy']).toBe('unsafe-none');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 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',
resource: null,
});
// 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',
resource: null,
});
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',
resource: null,
});
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',
resource: null,
});
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',
resource: null,
});
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',
resource: null,
});
// 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',
resource: null,
});
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',
resource: null,
});
// 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',
resource: null,
});
// 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',
resource: null,
});
// 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',
resource: null,
});
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', () => {
/**
* Push a rotation out of the concurrency grace window (#1007) so a replay
* below still describes theft — a token used minutes later — rather than two
* clients refreshing at the same moment.
*/
function agePastGrace(rawRefreshToken: string) {
const hash = crypto.createHash('sha256').update(rawRefreshToken).digest('hex');
const old = new Date(Date.now() - 5 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19);
testDb.prepare('UPDATE oauth_tokens SET revoked_at = ? WHERE refresh_token_hash = ?').run(old, hash);
}
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',
resource: null,
});
// 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
agePastGrace(originalRefreshToken);
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-012b — two clients refreshing the same token at once both keep working (#1007)', 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',
resource: null,
});
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 shared = t1.body.refresh_token;
const refresh = () => request(app).post('/oauth/token').send({
grant_type: 'refresh_token',
client_id: r.client!.client_id,
client_secret: r.client!.client_secret,
refresh_token: shared,
});
// The second MCP session posts the token its sibling has just spent.
const first = await refresh();
const second = await refresh();
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(second.body.refresh_token).not.toBe(first.body.refresh_token);
});
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',
resource: null,
});
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
agePastGrace(originalRefreshToken);
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();
});
});