import { describe, it, expect, vi, beforeEach } from 'vitest'; import { HttpException } from '@nestjs/common'; import type { Request, Response } from 'express'; vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logWarn: vi.fn() })); import { OauthPublicController } from '../../../src/nest/oauth/oauth-public.controller'; import { OauthApiController } from '../../../src/nest/oauth/oauth-api.controller'; import { RateLimitService } from '../../../src/nest/auth/rate-limit.service'; import type { OauthService } from '../../../src/nest/oauth/oauth.service'; import type { User } from '../../../src/types'; function osvc(o: Partial = {}): OauthService { return { mcpEnabled: vi.fn().mockReturnValue(true), mcpSafeUrl: vi.fn().mockReturnValue('https://app'), ...o } as unknown as OauthService; } function rl(): RateLimitService { return new RateLimitService(); } function makeRes() { const res = { statusCode: 200, headers: {} as Record, body: undefined as unknown, ended: false, status: vi.fn((c: number) => { res.statusCode = c; return res; }), json: vi.fn((b: unknown) => { res.body = b; return res; }), set: vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }), end: vi.fn(() => { res.ended = true; return res; }), }; return res as unknown as Response & { statusCode: number; headers: Record; body: unknown; ended: boolean }; } function thrown(fn: () => unknown): { status: number; body: unknown } { try { fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; return { status: e.getStatus(), body: e.getResponse() }; } throw new Error('expected throw'); } const user = { id: 1, email: 'u@example.test' } as User; beforeEach(() => vi.clearAllMocks()); describe('OauthPublicController /token', () => { function reqWith(body: Record): Request { return { ip: '7.7.7.7', body } as Request; } it('404 (empty) when MCP is disabled', () => { const res = makeRes(); new OauthPublicController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).token(reqWith({}), res); expect(res.statusCode).toBe(404); expect(res.ended).toBe(true); }); it('sets no-store headers + 401 without client_id', () => { const res = makeRes(); new OauthPublicController(osvc(), rl()).token(reqWith({}), res); expect(res.headers['Cache-Control']).toBe('no-store'); expect(res.statusCode).toBe(401); expect(res.body).toEqual({ error: 'invalid_client', error_description: 'client_id is required' }); }); it('authorization_code: invalid_grant on a bad code, success issues tokens', () => { const bad = makeRes(); new OauthPublicController(osvc({ consumeAuthCode: vi.fn().mockReturnValue(null) }), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), bad); expect(bad.statusCode).toBe(400); expect(bad.body).toEqual({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' }); const ok = makeRes(); const svc = osvc({ consumeAuthCode: vi.fn().mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null }), authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), verifyPKCE: vi.fn().mockReturnValue(true), issueTokens: vi.fn().mockReturnValue({ access_token: 'at', token_type: 'Bearer' }), }); new OauthPublicController(svc, rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), ok); expect(ok.body).toEqual({ access_token: 'at', token_type: 'Bearer' }); }); it('authorization_code: maps client_id / redirect_uri / resource mismatches + pkce + client auth', () => { const base = { grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }; const mk = (pending: Record, extra: Partial = {}, body = base) => { const res = makeRes(); new OauthPublicController(osvc({ consumeAuthCode: vi.fn().mockReturnValue(pending), authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), verifyPKCE: vi.fn().mockReturnValue(true), ...extra }), rl()).token(reqWith(body), res); return res; }; expect(mk({ clientId: 'OTHER', redirectUri: 'u', userId: 1 }).statusCode).toBe(400); // client_id mismatch expect(mk({ clientId: 'c', redirectUri: 'OTHER', userId: 1 }).statusCode).toBe(400); // redirect_uri mismatch expect(mk({ clientId: 'c', redirectUri: 'u', userId: 1, resource: 'https://a' }, {}, { ...base, resource: 'https://b' }).statusCode).toBe(400); // resource mismatch expect(mk({ clientId: 'c', redirectUri: 'u', userId: 1 }, { authenticateClient: vi.fn().mockReturnValue(null) }).statusCode).toBe(401); // bad client secret expect(mk({ clientId: 'c', redirectUri: 'u', userId: 1, codeChallenge: 'cc' }, { verifyPKCE: vi.fn().mockReturnValue(false) }).statusCode).toBe(400); // pkce fail }); it('authorization_code: 400 when code/redirect/verifier missing', () => { const res = makeRes(); new OauthPublicController(osvc(), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c' }), res); expect(res.statusCode).toBe(400); expect(res.body).toEqual({ error: 'invalid_request', error_description: 'code, redirect_uri, and code_verifier are required' }); }); it('refresh_token: 400 without a refresh_token, maps a service error, success', () => { const miss = makeRes(); new OauthPublicController(osvc(), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c' }), miss); expect(miss.statusCode).toBe(400); const err = makeRes(); new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_grant', status: 400 }) }), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), err); expect(err.body).toEqual({ error: 'invalid_grant', error_description: 'Refresh token is invalid or expired' }); const ok = makeRes(); new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ tokens: { access_token: 'new' } }) }), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), ok); expect(ok.body).toEqual({ access_token: 'new' }); }); it('client_credentials: 401 without secret, invalid_scope for a disallowed scope', () => { const noSecret = makeRes(); new OauthPublicController(osvc(), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c' }), noSecret); expect(noSecret.statusCode).toBe(401); const badScope = makeRes(); new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a"]' }) }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', scope: 'a zzz' }), badScope); expect(badScope.statusCode).toBe(400); expect(badScope.body).toEqual({ error: 'invalid_scope', error_description: 'Scopes not allowed for this client: zzz' }); }); it('client_credentials: unauthorized_client for a public client, else issues a token', () => { const pub = makeRes(); new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: true, user_id: null, allows_client_credentials: false, allowed_scopes: '[]' }) }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), pub); expect(pub.statusCode).toBe(400); expect(pub.body).toEqual({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' }); const ok = makeRes(); new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a","b"]' }), issueClientCredentialsToken: vi.fn().mockReturnValue({ access_token: 'cc_at' }), }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), ok); expect(ok.body).toEqual({ access_token: 'cc_at' }); }); it('unsupported grant -> 400', () => { const res = makeRes(); new OauthPublicController(osvc(), rl()).token(reqWith({ grant_type: 'password', client_id: 'c' }), res); expect(res.statusCode).toBe(400); expect(res.body).toEqual({ error: 'unsupported_grant_type', error_description: 'Unsupported grant_type: password' }); }); it('429 when the token bucket is exhausted (per ip|client)', () => { const s = rl(); for (let i = 0; i < 30; i++) s.check('oauth_token', '7.7.7.7|c', 30, 60000, Date.now()); const res = makeRes(); new OauthPublicController(osvc(), s).token(reqWith({ client_id: 'c' }), res); expect(res.statusCode).toBe(429); }); }); describe('OauthPublicController /userinfo + /revoke', () => { it('userinfo: 401 challenge without a Bearer, returns claims with a valid token', () => { const r1 = makeRes(); new OauthPublicController(osvc(), rl()).userinfo(undefined, r1); expect(r1.statusCode).toBe(401); expect(r1.headers['WWW-Authenticate']).toBe('Bearer realm="TREK MCP"'); const r2 = makeRes(); new OauthPublicController(osvc({ getUserByAccessToken: vi.fn().mockReturnValue({ user: { id: 1, email: 'a@b.c', username: 'u' } }) }), rl()).userinfo('Bearer tok', r2); expect(r2.body).toEqual({ sub: '1', email: 'a@b.c', email_verified: true, preferred_username: 'u' }); }); it('revoke: 400 without token/client, always 200 once authenticated', () => { const r1 = makeRes(); new OauthPublicController(osvc(), rl()).revoke({ ip: '1', body: { client_id: 'c' } } as Request, r1); expect(r1.statusCode).toBe(400); const r2 = makeRes(); const revokeToken = vi.fn(); new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), revokeToken }), rl()).revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, r2); expect(r2.statusCode).toBe(200); expect(r2.body).toEqual({}); expect(revokeToken).toHaveBeenCalled(); }); }); describe('OauthApiController', () => { const req = { ip: '1.2.3.4', user: undefined as unknown } as Request; function makeRes2() { const r = { statusCode: 200, ended: false, status: vi.fn((c: number) => { r.statusCode = c; return r; }), end: vi.fn(() => { r.ended = true; }) }; return r as unknown as Response & { statusCode: number; ended: boolean }; } it('validate: 404 empty when MCP off, loginRequired when anonymous + valid', () => { const off = makeRes2(); new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).validate({ ...req } as Request, {}, off); expect(off.statusCode).toBe(404); expect(off.ended).toBe(true); const anon = makeRes2(); const r = new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true }) }), rl()).validate({ ...req, user: undefined } as Request, {}, anon); expect(r).toEqual({ valid: true, loginRequired: true }); }); it('authorize: denied returns a redirect with access_denied, approved issues a code', () => { const denied = new OauthApiController(osvc(), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req); expect((denied as { redirect: string }).redirect).toContain('error=access_denied'); const svc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'], resource: null }), saveConsent: vi.fn(), createAuthCode: vi.fn().mockReturnValue('the_code') }); const ok = new OauthApiController(svc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req); expect((ok as { redirect: string }).redirect).toContain('code=the_code'); }); it('clients/sessions: 403 when MCP off, else CRUD', () => { expect(thrown(() => new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).listClients(user))).toEqual({ status: 403, body: { error: 'MCP is not enabled' } }); expect(new OauthApiController(osvc({ listOAuthClients: vi.fn().mockReturnValue([{ id: 'c1' }]) }), rl()).listClients(user)).toEqual({ clients: [{ id: 'c1' }] }); expect(new OauthApiController(osvc({ createOAuthClient: vi.fn().mockReturnValue({ client_id: 'c1', client_secret: 's' }) }), rl()).createClient(user, { name: 'CLI', allowed_scopes: ['a'] }, req)).toEqual({ client_id: 'c1', client_secret: 's' }); expect(new OauthApiController(osvc({ deleteOAuthClient: vi.fn().mockReturnValue({}) }), rl()).deleteClient(user, 'c1', req)).toEqual({ success: true }); expect(new OauthApiController(osvc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) }), rl()).listSessions(user)).toEqual({ sessions: [{ id: 1 }] }); expect(new OauthApiController(osvc({ revokeSession: vi.fn().mockReturnValue({}) }), rl()).revokeSession(user, '1', req)).toEqual({ success: true }); }); it('rotate maps a service error, else returns the new secret', () => { expect(thrown(() => new OauthApiController(osvc({ rotateOAuthClientSecret: vi.fn().mockReturnValue({ error: 'not_found', status: 404 }) }), rl()).rotateClient(user, 'c1', req))).toEqual({ status: 404, body: { error: 'not_found' } }); expect(new OauthApiController(osvc({ rotateOAuthClientSecret: vi.fn().mockReturnValue({ client_secret: 'new' }) }), rl()).rotateClient(user, 'c1', req)).toEqual({ client_secret: 'new' }); }); it('validate: anonymous + invalid returns a generic error; create maps a service error', () => { const res = makeRes2(); const anon = new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: false, error: 'x' }) }), rl()).validate({ ...req, user: undefined } as Request, {}, res); expect(anon).toEqual({ valid: false, error: 'invalid_request', error_description: 'Invalid authorization request' }); expect(thrown(() => new OauthApiController(osvc({ createOAuthClient: vi.fn().mockReturnValue({ error: 'invalid_redirect_uri', status: 400 }) }), rl()).createClient(user, { name: 'X', allowed_scopes: ['a'] }, req))).toEqual({ status: 400, body: { error: 'invalid_redirect_uri' } }); }); it('authorize: 400 when re-validation fails, 503 when the auth code cannot be issued', () => { expect(thrown(() => new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: false, error: 'invalid_scope', error_description: 'bad' }) }), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req))).toEqual({ status: 400, body: { error: 'invalid_scope', error_description: 'bad' } }); expect(thrown(() => new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'], resource: null }), saveConsent: vi.fn(), createAuthCode: vi.fn().mockReturnValue(null) }), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req))).toEqual({ status: 503, body: { error: 'server_error', error_description: 'Authorization server is temporarily unavailable' } }); }); });