mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
8e5fdaa877
Permissions cached an empty map before it had loaded, so a DB error during the load cached permissive defaults and every later check answered from that cache - fail-open, and it never expired. The rate limiter grew its per-IP map forever; the GC the legacy version had was dropped in the fold. Idempotency did a check-then-insert, so two concurrent duplicates both ran the handler. Audit rows recorded whatever X-Forwarded-For said, which the client writes, and the request-body debug logger redacted by exact key match - it missed refresh tokens, api keys and PKCE verifiers. FORCE_HTTPS built its redirect from the client's Host header. CSP asked for unsafe-eval on top of the wasm-unsafe-eval it actually needs. The OAuth deny path redirected to an unvalidated URI, ws-token minting had no rate limit at all, and PKCE was skipped when code_verifier was absent rather than refused. An MCP session resumed with a token whose scopes had since been reduced kept the capability set it was created with, and the session cap was checked before the async registration that increments it. Plugin frame containment was lexical, so a symlink out of the directory was never noticed, and instance config accepted keys the manifest never declared.
448 lines
28 KiB
TypeScript
448 lines
28 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { HttpException } from '@nestjs/common';
|
|
import type { Request, Response } from 'express';
|
|
|
|
vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
|
|
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() }));
|
|
|
|
import { getClientIp } from '../../../src/nest/audit/client-ip';
|
|
const getClientIpMock = vi.mocked(getClientIp);
|
|
|
|
import { OauthPublicController } from '../../../src/nest/oauth/oauth-public.controller';
|
|
import { OauthApiController } from '../../../src/nest/oauth/oauth-api.controller';
|
|
import { RateLimitService } from '../../../src/nest/common/rate-limit.service';
|
|
import type { OauthService } from '../../../src/nest/oauth/oauth.service';
|
|
import type { AuditService } from '../../../src/nest/audit/audit.service';
|
|
import type { User } from '../../../src/types';
|
|
|
|
function osvc(o: Partial<OauthService> = {}): OauthService {
|
|
return { mcpEnabled: vi.fn().mockReturnValue(true), mcpSafeUrl: vi.fn().mockReturnValue('https://app'), ...o } as unknown as OauthService;
|
|
}
|
|
function rl(): RateLimitService { return new RateLimitService(); }
|
|
|
|
// AuditService is constructor-injected since the auditLog DI migration; the
|
|
// wrapper keeps the historical construction sites positional.
|
|
const writeAudit = vi.fn();
|
|
const audit = { writeAudit } as unknown as AuditService;
|
|
const opc = (s: OauthService, limiter: RateLimitService) => new OauthPublicController(s, limiter, audit);
|
|
function makeRes() {
|
|
const res = {
|
|
statusCode: 200, headers: {} as Record<string, string>, 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<string, string>; 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<string, string>): Request { return { ip: '7.7.7.7', body } as Request; }
|
|
|
|
it('404 (empty) when MCP is disabled', () => {
|
|
const res = makeRes();
|
|
opc(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();
|
|
opc(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();
|
|
opc(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' }),
|
|
});
|
|
opc(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' };
|
|
// body is the loose form the controller actually reads off req.body, not the
|
|
// shape of `base`, so a case can add a field such as `resource`.
|
|
const mk = (pending: Record<string, unknown>, extra: Partial<OauthService> = {}, body: Record<string, string> = base) => {
|
|
const res = makeRes();
|
|
opc(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();
|
|
opc(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();
|
|
opc(osvc(), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c' }), miss);
|
|
expect(miss.statusCode).toBe(400);
|
|
const err = makeRes();
|
|
opc(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();
|
|
opc(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();
|
|
opc(osvc(), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c' }), noSecret);
|
|
expect(noSecret.statusCode).toBe(401);
|
|
const badScope = makeRes();
|
|
opc(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();
|
|
opc(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();
|
|
opc(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();
|
|
opc(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();
|
|
opc(osvc(), s).token(reqWith({ client_id: 'c' }), res);
|
|
expect(res.statusCode).toBe(429);
|
|
});
|
|
|
|
it('falls back to {} when the body is not an object', () => {
|
|
const res = makeRes();
|
|
opc(osvc(), rl()).token({ ip: '7.7.7.7', body: 'not-an-object' } as unknown as Request, res);
|
|
// no client_id in the {} fallback -> 401
|
|
expect(res.statusCode).toBe(401);
|
|
expect(res.body).toEqual({ error: 'invalid_client', error_description: 'client_id is required' });
|
|
});
|
|
|
|
it('authorization_code: invalid client secret writes an audit + 401', () => {
|
|
const res = makeRes();
|
|
opc(osvc({
|
|
consumeAuthCode: vi.fn().mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null }),
|
|
authenticateClient: vi.fn().mockReturnValue(null),
|
|
}), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), res);
|
|
expect(res.statusCode).toBe(401);
|
|
expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
|
});
|
|
|
|
it('refresh_token: invalid_client maps to its specific 401 message', () => {
|
|
const res = makeRes();
|
|
opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_client', status: 401 }) }), rl())
|
|
.token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res);
|
|
expect(res.statusCode).toBe(401);
|
|
expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
|
});
|
|
|
|
it('refresh_token: defaults the status to 400 when the service omits it', () => {
|
|
const res = makeRes();
|
|
opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_grant' }) }), rl())
|
|
.token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res);
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it('client_credentials: 401 when the client cannot be authenticated', () => {
|
|
const res = makeRes();
|
|
opc(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl())
|
|
.token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), res);
|
|
expect(res.statusCode).toBe(401);
|
|
expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
|
});
|
|
|
|
it('client_credentials: honours a valid requested scope subset', () => {
|
|
const res = makeRes();
|
|
const issueClientCredentialsToken = vi.fn().mockReturnValue({ access_token: 'cc_at' });
|
|
opc(osvc({
|
|
authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a","b"]' }),
|
|
issueClientCredentialsToken,
|
|
}), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', scope: 'a' }), res);
|
|
expect(res.body).toEqual({ access_token: 'cc_at' });
|
|
expect(issueClientCredentialsToken).toHaveBeenCalledWith('c', 1, ['a'], expect.any(String));
|
|
});
|
|
|
|
it('client_credentials: derives the audience from an explicit resource', () => {
|
|
const res = makeRes();
|
|
const issueClientCredentialsToken = vi.fn().mockReturnValue({ access_token: 'cc_at' });
|
|
opc(osvc({
|
|
authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a"]' }),
|
|
issueClientCredentialsToken,
|
|
}), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', resource: 'https://aud/' }), res);
|
|
// trailing slashes are trimmed, not the mcpSafeUrl fallback
|
|
expect(issueClientCredentialsToken).toHaveBeenCalledWith('c', 1, ['a'], 'https://aud');
|
|
});
|
|
|
|
it('logs a dash for a missing ip on the authorization_code client-auth failure', () => {
|
|
getClientIpMock.mockReturnValueOnce(undefined);
|
|
const res = makeRes();
|
|
opc(osvc({
|
|
consumeAuthCode: vi.fn().mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null }),
|
|
authenticateClient: vi.fn().mockReturnValue(null),
|
|
}), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), res);
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it('logs a dash for a missing ip on the refresh invalid_client failure', () => {
|
|
getClientIpMock.mockReturnValueOnce(undefined);
|
|
const res = makeRes();
|
|
opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_client', status: 401 }) }), rl())
|
|
.token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res);
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it('logs a dash for a missing ip on the client_credentials auth failure', () => {
|
|
getClientIpMock.mockReturnValueOnce(undefined);
|
|
const res = makeRes();
|
|
opc(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl())
|
|
.token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), res);
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('OauthPublicController /userinfo + /revoke', () => {
|
|
it('userinfo: 401 challenge without a Bearer, returns claims with a valid token', () => {
|
|
const r1 = makeRes();
|
|
opc(osvc(), rl()).userinfo(undefined, r1);
|
|
expect(r1.statusCode).toBe(401);
|
|
expect(r1.headers['WWW-Authenticate']).toBe('Bearer realm="TREK MCP"');
|
|
const r2 = makeRes();
|
|
opc(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('userinfo: 404 empty when MCP is disabled', () => {
|
|
const res = makeRes();
|
|
opc(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).userinfo('Bearer tok', res);
|
|
expect(res.statusCode).toBe(404);
|
|
expect(res.ended).toBe(true);
|
|
});
|
|
|
|
it('userinfo: 401 with the error challenge when the token is unknown', () => {
|
|
const res = makeRes();
|
|
opc(osvc({ getUserByAccessToken: vi.fn().mockReturnValue(null) }), rl()).userinfo('Bearer tok', res);
|
|
expect(res.statusCode).toBe(401);
|
|
expect(res.headers['WWW-Authenticate']).toBe('Bearer realm="TREK MCP", error="invalid_token"');
|
|
expect(res.body).toEqual({ error: 'invalid_token' });
|
|
});
|
|
|
|
it('revoke: 400 without token/client, always 200 once authenticated', () => {
|
|
const r1 = makeRes();
|
|
opc(osvc(), rl()).revoke({ ip: '1', body: { client_id: 'c' } } as Request, r1);
|
|
expect(r1.statusCode).toBe(400);
|
|
const r2 = makeRes();
|
|
const revokeToken = vi.fn();
|
|
opc(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();
|
|
});
|
|
|
|
it('revoke: 404 empty when MCP is disabled', () => {
|
|
const res = makeRes();
|
|
opc(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).revoke({ ip: '1', body: {} } as Request, res);
|
|
expect(res.statusCode).toBe(404);
|
|
expect(res.ended).toBe(true);
|
|
});
|
|
|
|
it('revoke: 429 when the per-ip bucket is exhausted', () => {
|
|
const s = rl();
|
|
for (let i = 0; i < 10; i++) s.check('oauth_revoke', '1', 10, 60000, Date.now());
|
|
const res = makeRes();
|
|
opc(osvc(), s).revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res);
|
|
expect(res.statusCode).toBe(429);
|
|
});
|
|
|
|
it('revoke: falls back to a default ip key and {} body when both are missing', () => {
|
|
const res = makeRes();
|
|
opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), revokeToken: vi.fn() }), rl())
|
|
.revoke({ body: undefined } as unknown as Request, res);
|
|
// body fell back to {} -> token/client missing -> 400
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it('revoke: 401 when the client credentials are invalid', () => {
|
|
const res = makeRes();
|
|
opc(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl())
|
|
.revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res);
|
|
expect(res.statusCode).toBe(401);
|
|
expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
|
});
|
|
|
|
it('revoke: logs a dash for a missing ip on the invalid-client failure', () => {
|
|
getClientIpMock.mockReturnValueOnce(undefined);
|
|
const res = makeRes();
|
|
opc(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl())
|
|
.revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res);
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
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 deniedSvc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'] }) });
|
|
const denied = new OauthApiController(deniedSvc, 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' } });
|
|
});
|
|
|
|
it('validate: 429 when the per-ip bucket is exhausted', () => {
|
|
const s = rl();
|
|
for (let i = 0; i < 30; i++) s.check('oauth_validate', '1.2.3.4', 30, 60000, Date.now());
|
|
const res = makeRes2();
|
|
expect(thrown(() => new OauthApiController(osvc(), s).validate({ ...req } as Request, {}, res))).toEqual({
|
|
status: 429,
|
|
body: { error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' },
|
|
});
|
|
});
|
|
|
|
it('validate: falls back to the "unknown" rate-limit key when req.ip is absent', () => {
|
|
const res = makeRes2();
|
|
const out = new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true }) }), rl())
|
|
.validate({ user: undefined } as unknown as Request, {}, res);
|
|
expect(out).toEqual({ valid: true, loginRequired: true });
|
|
});
|
|
|
|
it('validate: forwards the resource + returns the raw result for a logged-in user', () => {
|
|
const res = makeRes2();
|
|
const validateAuthorizeRequest = vi.fn().mockReturnValue({ valid: true, scopes: ['s'] });
|
|
const out = new OauthApiController(osvc({ validateAuthorizeRequest }), rl())
|
|
.validate({ ...req, user: { id: 9 } } as unknown as Request, { resource: 'https://r' }, res);
|
|
expect(out).toEqual({ valid: true, scopes: ['s'] });
|
|
expect(validateAuthorizeRequest).toHaveBeenCalledWith(expect.objectContaining({ resource: 'https://r' }), 9);
|
|
});
|
|
|
|
it('authorize: 403 when MCP is disabled', () => {
|
|
expect(thrown(() => new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl())
|
|
.authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req)))
|
|
.toEqual({ status: 403, body: { error: 'MCP is not enabled' } });
|
|
});
|
|
|
|
it('authorize: carries the state through both the denied and approved redirects', () => {
|
|
const deniedSvc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'] }) });
|
|
const denied = new OauthApiController(deniedSvc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', state: 'xyz', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req);
|
|
expect((denied as { redirect: string }).redirect).toContain('state=xyz');
|
|
|
|
const svc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'], resource: 'https://aud' }), 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', state: 'xyz', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req);
|
|
expect((ok as { redirect: string }).redirect).toContain('code=the_code');
|
|
expect((ok as { redirect: string }).redirect).toContain('state=xyz');
|
|
});
|
|
|
|
it('authorize: a denial is validated too, so the redirect can only be a registered URI', () => {
|
|
const validateAuthorizeRequest = vi.fn().mockReturnValue({ valid: false, error: 'invalid_redirect_uri', error_description: 'Invalid redirect URI' });
|
|
const svc = osvc({ validateAuthorizeRequest, saveConsent: vi.fn(), createAuthCode: vi.fn() });
|
|
expect(thrown(() => new OauthApiController(svc, rl())
|
|
.authorize(user, { client_id: 'c', redirect_uri: 'not a url', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req)))
|
|
.toEqual({ status: 400, body: { error: 'invalid_redirect_uri', error_description: 'Invalid redirect URI' } });
|
|
expect(validateAuthorizeRequest).toHaveBeenCalled();
|
|
});
|
|
|
|
it('client/session errors default the status to 400 when the service omits it', () => {
|
|
expect(thrown(() => new OauthApiController(osvc({ createOAuthClient: vi.fn().mockReturnValue({ error: 'bad' }) }), rl()).createClient(user, { name: 'X', allowed_scopes: ['a'] }, req)))
|
|
.toEqual({ status: 400, body: { error: 'bad' } });
|
|
expect(thrown(() => new OauthApiController(osvc({ rotateOAuthClientSecret: vi.fn().mockReturnValue({ error: 'bad' }) }), rl()).rotateClient(user, 'c1', req)))
|
|
.toEqual({ status: 400, body: { error: 'bad' } });
|
|
expect(thrown(() => new OauthApiController(osvc({ deleteOAuthClient: vi.fn().mockReturnValue({ error: 'not_found', status: 404 }) }), rl()).deleteClient(user, 'c1', req)))
|
|
.toEqual({ status: 404, body: { error: 'not_found' } });
|
|
expect(thrown(() => new OauthApiController(osvc({ deleteOAuthClient: vi.fn().mockReturnValue({ error: 'bad' }) }), rl()).deleteClient(user, 'c1', req)))
|
|
.toEqual({ status: 400, body: { error: 'bad' } });
|
|
expect(thrown(() => new OauthApiController(osvc({ revokeSession: vi.fn().mockReturnValue({ error: 'not_found', status: 404 }) }), rl()).revokeSession(user, '1', req)))
|
|
.toEqual({ status: 404, body: { error: 'not_found' } });
|
|
expect(thrown(() => new OauthApiController(osvc({ revokeSession: vi.fn().mockReturnValue({ error: 'bad' }) }), rl()).revokeSession(user, '1', req)))
|
|
.toEqual({ status: 400, body: { error: 'bad' } });
|
|
});
|
|
|
|
it('sessions: 403 when MCP is off on the list', () => {
|
|
expect(thrown(() => new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).listSessions(user)))
|
|
.toEqual({ status: 403, body: { error: 'MCP is not enabled' } });
|
|
});
|
|
});
|