mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
1651 lines
69 KiB
TypeScript
1651 lines
69 KiB
TypeScript
/**
|
|
* OAuth 2.1 integration tests.
|
|
* Covers oauthPublicRouter (/.well-known, /oauth/token, /oauth/revoke)
|
|
* and oauthApiRouter (/api/oauth/*).
|
|
*/
|
|
import { buildApp } from '../../src/bootstrap';
|
|
import { db as testDb } from '../../src/db/database';
|
|
import { Addons } from '../../src/db/entities/Addons.entity';
|
|
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
|
import { OauthClients } from '../../src/db/entities/OauthClients.entity';
|
|
import { OauthConsents } from '../../src/db/entities/OauthConsents.entity';
|
|
import { OauthTokens } from '../../src/db/entities/OauthTokens.entity';
|
|
import { Users } from '../../src/db/entities/Users.entity';
|
|
import { ALL_SCOPES } from '../../src/mcp/scopes';
|
|
import { AuditService } from '../../src/nest/audit/audit.service';
|
|
import { UnitOfWork } from '../../src/nest/database/unit-of-work';
|
|
import { OauthService } from '../../src/nest/oauth/oauth.service';
|
|
import { authCookie } from '../helpers/auth';
|
|
import { createUser } from '../helpers/factories';
|
|
import { updateRows, upsertRow } from '../helpers/factories/rows';
|
|
import { createTestAddonsService } from '../helpers/test-addons';
|
|
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
|
import { createTestOrm, type TestOrm } from '../helpers/test-orm';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
|
|
import crypto from 'crypto';
|
|
import type { Application } from 'express';
|
|
import request from 'supertest';
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
vi.mock('../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
|
|
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/mcp/sessionManager', () => ({
|
|
revokeUserSessions: vi.fn(),
|
|
revokeUserSessionsForClient: vi.fn(),
|
|
sessions: new Map(),
|
|
}));
|
|
|
|
// 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.
|
|
let containerSideOauth: OauthService;
|
|
// Arrow wrappers rather than `.bind`: with `strictBindCallApply: false` a bound
|
|
// alias is typed `any`, which hides a missing `await` on the now-async methods.
|
|
const createAuthCode = (...args: Parameters<OauthService['createAuthCode']>) =>
|
|
containerSideOauth.createAuthCode(...args);
|
|
const createOAuthClient = (...args: Parameters<OauthService['createOAuthClient']>) =>
|
|
containerSideOauth.createOAuthClient(...args);
|
|
const getUserByAccessToken = (...args: Parameters<OauthService['getUserByAccessToken']>) =>
|
|
containerSideOauth.getUserByAccessToken(...args);
|
|
|
|
let nestApp: INestApplication;
|
|
let app: Application;
|
|
let t: TestOrm;
|
|
|
|
// 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.
|
|
async function setMcpEnabled(enabled: boolean) {
|
|
await upsertRow(t, Addons, {
|
|
id: 'mcp',
|
|
name: 'MCP',
|
|
description: 'AI assistant integration',
|
|
type: 'integration',
|
|
icon: 'Terminal',
|
|
enabled,
|
|
sort_order: 12,
|
|
});
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
nestApp = await buildApp();
|
|
app = nestApp.getHttpAdapter().getInstance();
|
|
t = await createTestOrm(testDb);
|
|
containerSideOauth = new OauthService(
|
|
t.repo(OauthClients),
|
|
t.repo(OauthTokens),
|
|
t.repo(OauthConsents),
|
|
await createTestAddonsService(testDb),
|
|
new AuditService(t.repo(AuditLog), t.repo(Users)),
|
|
new UnitOfWork(t.em),
|
|
);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
resetTestDb(testDb);
|
|
await resetRateLimits(nestApp);
|
|
await setMcpEnabled(true);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await nestApp.close();
|
|
await t.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');
|
|
});
|
|
});
|
|
|
|
// DCR runs two validators in sequence: the clients store's own check and
|
|
// createOAuthClient's. Every unit test of the first one stubs the second away,
|
|
// which is how both of these shipped rejected (#2227).
|
|
describe('DCR redirect URI policy (issue #2227)', () => {
|
|
it('OAUTH-2227A: POST /oauth/register accepts the [::1] loopback URI', async () => {
|
|
const res = await request(app)
|
|
.post('/oauth/register')
|
|
.set('Content-Type', 'application/json')
|
|
.send({
|
|
redirect_uris: ['http://[::1]:8080/oauth/callback'],
|
|
token_endpoint_auth_method: 'none',
|
|
scope: 'trips:read',
|
|
});
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.redirect_uris).toEqual(['http://[::1]:8080/oauth/callback']);
|
|
});
|
|
|
|
it('OAUTH-2227B: POST /oauth/register accepts a single-label private-use scheme, unmodified', async () => {
|
|
const uri = 'workbuddy://workbuddy/mcp/connector%3A/oauth/callback';
|
|
const res = await request(app)
|
|
.post('/oauth/register')
|
|
.set('Content-Type', 'application/json')
|
|
.send({ redirect_uris: [uri], token_endpoint_auth_method: 'none', scope: 'trips:read' });
|
|
expect(res.status).toBe(201);
|
|
// Stored verbatim: the authorize and token endpoints compare it byte for byte.
|
|
expect(res.body.redirect_uris).toEqual([uri]);
|
|
});
|
|
|
|
// The SDK's own metadata schema only blocks javascript:, data: and vbscript:,
|
|
// so these two are TREK's to refuse, one per verdict branch.
|
|
it('OAUTH-2227C: POST /oauth/register still refuses dangerous and non-navigable schemes on a loopback host', async () => {
|
|
for (const uri of ['blob://localhost/x', 'ws://localhost/cb']) {
|
|
const res = await request(app)
|
|
.post('/oauth/register')
|
|
.set('Content-Type', 'application/json')
|
|
.send({ redirect_uris: [uri], token_endpoint_auth_method: 'none', scope: 'trips:read' });
|
|
expect(res.status).toBe(400);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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 () => {
|
|
await 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 () => {
|
|
await 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 = await 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 = await createOAuthClient(user.id, 'App1', ['https://app1.example.com/cb'], ['trips:read']);
|
|
const r2 = await createOAuthClient(user.id, 'App2', ['https://app2.example.com/cb'], ['trips:read']);
|
|
const { verifier, challenge } = makePkce();
|
|
|
|
// Create code for client1
|
|
const code = await 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 = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
|
const { verifier, challenge } = makePkce();
|
|
|
|
const code = await 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 = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
|
const { verifier, challenge } = makePkce();
|
|
|
|
const code = await 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 = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
|
const { challenge } = makePkce();
|
|
|
|
const code = await 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 = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
|
const { verifier, challenge } = makePkce();
|
|
|
|
const code = await 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 = await 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 = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
|
const { verifier, challenge } = makePkce();
|
|
|
|
const code = await 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 = await 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 = await 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 = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
|
const { verifier, challenge } = makePkce();
|
|
|
|
const code = await 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 () => {
|
|
await 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-2227D: a loopback client that registered a placeholder port validates on the port the OS gave it', async () => {
|
|
// The reporter's shape end to end: register http://[::1]:8080/oauth/callback,
|
|
// then authorize from the ephemeral port the OS actually handed the client.
|
|
// Before #2227 registration refused the URI outright; once it was accepted,
|
|
// this route still answered invalid_redirect_uri because it compared byte
|
|
// for byte while RFC 8252 §7.3 frees the port for loopback redirects.
|
|
const { user } = createUser(testDb);
|
|
const { challenge } = makePkce();
|
|
const reg = await request(app)
|
|
.post('/oauth/register')
|
|
.set('Content-Type', 'application/json')
|
|
.send({
|
|
redirect_uris: ['http://[::1]:8080/oauth/callback'],
|
|
token_endpoint_auth_method: 'none',
|
|
scope: 'trips:read',
|
|
});
|
|
expect(reg.status).toBe(201);
|
|
|
|
const res = await request(app).get('/api/oauth/authorize/validate').set('Cookie', authCookie(user.id)).query({
|
|
response_type: 'code',
|
|
client_id: reg.body.client_id,
|
|
redirect_uri: 'http://[::1]:54321/oauth/callback',
|
|
scope: 'trips:read',
|
|
code_challenge: challenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.valid).toBe(true);
|
|
});
|
|
|
|
it('OAUTH-2227E: the port is all that is free, so a different callback path is still refused', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { challenge } = makePkce();
|
|
const reg = await request(app)
|
|
.post('/oauth/register')
|
|
.set('Content-Type', 'application/json')
|
|
.send({
|
|
redirect_uris: ['http://127.0.0.1:8080/oauth/callback'],
|
|
token_endpoint_auth_method: 'none',
|
|
scope: 'trips:read',
|
|
});
|
|
expect(reg.status).toBe(201);
|
|
|
|
const res = await request(app).get('/api/oauth/authorize/validate').set('Cookie', authCookie(user.id)).query({
|
|
response_type: 'code',
|
|
client_id: reg.body.client_id,
|
|
redirect_uri: 'http://127.0.0.1:54321/stolen',
|
|
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-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 = await 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 = await 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 = await 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 = await 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 = await 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 = await 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 () => {
|
|
await 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 r = await 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: false,
|
|
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).toContain('error=access_denied');
|
|
});
|
|
|
|
it('OAUTH-030b — a denial for an unregistered redirect_uri is refused, not redirected', async () => {
|
|
const { user } = createUser(testDb);
|
|
const r = await 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: false,
|
|
client_id: r.client!.client_id,
|
|
redirect_uri: 'https://attacker.example.com/cb',
|
|
scope: 'trips:read',
|
|
code_challenge: challenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toBe('invalid_redirect_uri');
|
|
});
|
|
|
|
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 = await 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 () => {
|
|
await 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);
|
|
await 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 () => {
|
|
await 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 = await 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 = await 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 () => {
|
|
await 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 = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
|
const { verifier, challenge } = makePkce();
|
|
|
|
const code = await 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-043B — DELETE /sessions/abc (non-numeric id) returns the legacy 404, not a 500 (Plan 3b Task 4 controller addendum, per Task 2 review F1)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const res = await request(app).delete('/api/oauth/sessions/abc').set('Cookie', authCookie(user.id));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Session not found' });
|
|
});
|
|
|
|
it('OAUTH-044 — DELETE /sessions/:id returns 403 when addon disabled', async () => {
|
|
await 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 () => {
|
|
await 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 () => {
|
|
await 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 = await 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 = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
|
const { challenge } = makePkce();
|
|
|
|
const code = await 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 = await 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 = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
|
const { verifier, challenge } = makePkce();
|
|
|
|
const code = await 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 = await 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 = await 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.
|
|
*/
|
|
async 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);
|
|
await updateRows(t, OauthTokens, { refresh_token_hash: hash }, { revoked_at: old });
|
|
}
|
|
|
|
it('OAUTH-SEC-012 — replaying a rotated (old) refresh token returns invalid_grant', async () => {
|
|
const { user } = createUser(testDb);
|
|
const r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
|
const { verifier, challenge } = makePkce();
|
|
|
|
const code = await 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
|
|
await 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 = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
|
const { verifier, challenge } = makePkce();
|
|
|
|
const code = await 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 = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
|
const { verifier, challenge } = makePkce();
|
|
|
|
const code = await 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
|
|
await 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 = await 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 = await 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 = await 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 = await 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 = await 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 = await 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 = await 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 = await 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', async () => {
|
|
const { user } = createUser(testDb);
|
|
const r = await 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();
|
|
});
|
|
});
|