mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
1693 lines
76 KiB
TypeScript
1693 lines
76 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';
|
|
|
|
vi.mock('../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
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 { db as testDb } from '../../src/db/database';
|
|
import { buildApp } from '../../src/bootstrap';
|
|
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 { createTestAddonsService } from '../helpers/test-addons';
|
|
import { AuditService } from '../../src/nest/audit/audit.service';
|
|
import { createTestOrm, type TestOrm } from '../helpers/test-orm';
|
|
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
|
import { Users } from '../../src/db/entities/Users.entity';
|
|
|
|
// 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);
|
|
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.
|
|
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 () => {
|
|
nestApp = await buildApp();
|
|
app = nestApp.getHttpAdapter().getInstance();
|
|
t = await createTestOrm(testDb);
|
|
containerSideOauth = new OauthService(oauthDbs, await createTestAddonsService(testDb, oauthDbs), new AuditService(t.repo(AuditLog), t.repo(Users)));
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
resetRateLimits(nestApp);
|
|
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 () => {
|
|
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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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-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 () => {
|
|
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 () => {
|
|
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 () => {
|
|
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 () => {
|
|
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 = 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 = 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 = 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 = 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 = 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.
|
|
*/
|
|
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 = await 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 = await 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 = await 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 = 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();
|
|
});
|
|
}); |