mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
9975f9f80f
The userinfo endpoint answered email_verified: true for every account, although nobody had confirmed the address. It now follows users.email_self_asserted, the same mark the SSO login uses before it links an account by email.
904 lines
33 KiB
TypeScript
904 lines
33 KiB
TypeScript
import type { AuditService } from '../../../src/nest/audit/audit.service';
|
|
import { getClientIp } from '../../../src/nest/audit/client-ip';
|
|
import { RateLimitService } from '../../../src/nest/common/rate-limit.service';
|
|
import { OauthApiController } from '../../../src/nest/oauth/oauth-api.controller';
|
|
import { OauthPublicController } from '../../../src/nest/oauth/oauth-public.controller';
|
|
import type { OauthService } from '../../../src/nest/oauth/oauth.service';
|
|
import type { User } from '../../../src/types';
|
|
import { HttpException } from '@nestjs/common';
|
|
|
|
import type { Request, Response } from 'express';
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
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(),
|
|
}));
|
|
|
|
const getClientIpMock = vi.mocked(getClientIp);
|
|
|
|
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;
|
|
};
|
|
}
|
|
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
|
try {
|
|
await 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', async () => {
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
const bad = makeRes();
|
|
await 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' }),
|
|
});
|
|
await 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', async () => {
|
|
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 = async (
|
|
pending: Record<string, unknown>,
|
|
extra: Partial<OauthService> = {},
|
|
body: Record<string, string> = base,
|
|
) => {
|
|
const res = makeRes();
|
|
await 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((await mk({ clientId: 'OTHER', redirectUri: 'u', userId: 1 })).statusCode).toBe(400); // client_id mismatch
|
|
expect((await mk({ clientId: 'c', redirectUri: 'OTHER', userId: 1 })).statusCode).toBe(400); // redirect_uri mismatch
|
|
expect(
|
|
(
|
|
await mk(
|
|
{ clientId: 'c', redirectUri: 'u', userId: 1, resource: 'https://a' },
|
|
{},
|
|
{ ...base, resource: 'https://b' },
|
|
)
|
|
).statusCode,
|
|
).toBe(400); // resource mismatch
|
|
expect(
|
|
(await mk({ clientId: 'c', redirectUri: 'u', userId: 1 }, { authenticateClient: vi.fn().mockReturnValue(null) }))
|
|
.statusCode,
|
|
).toBe(401); // bad client secret
|
|
expect(
|
|
(
|
|
await 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', async () => {
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
const miss = makeRes();
|
|
await opc(osvc(), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c' }), miss);
|
|
expect(miss.statusCode).toBe(400);
|
|
const err = makeRes();
|
|
await 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();
|
|
await 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', async () => {
|
|
const noSecret = makeRes();
|
|
await opc(osvc(), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c' }), noSecret);
|
|
expect(noSecret.statusCode).toBe(401);
|
|
const badScope = makeRes();
|
|
await 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', async () => {
|
|
const pub = makeRes();
|
|
await 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();
|
|
await 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', async () => {
|
|
const res = makeRes();
|
|
await 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)', async () => {
|
|
const s = rl();
|
|
for (let i = 0; i < 30; i++) await s.check('oauth_token', '7.7.7.7|c', 30, 60000, Date.now());
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
const res = makeRes();
|
|
const issueClientCredentialsToken = vi.fn().mockReturnValue({ access_token: 'cc_at' });
|
|
await 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', async () => {
|
|
const res = makeRes();
|
|
const issueClientCredentialsToken = vi.fn().mockReturnValue({ access_token: 'cc_at' });
|
|
await 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', async () => {
|
|
getClientIpMock.mockReturnValueOnce(undefined);
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
getClientIpMock.mockReturnValueOnce(undefined);
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
getClientIpMock.mockReturnValueOnce(undefined);
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
const r1 = makeRes();
|
|
await opc(osvc(), rl()).userinfo(undefined, r1);
|
|
expect(r1.statusCode).toBe(401);
|
|
expect(r1.headers['WWW-Authenticate']).toBe('Bearer realm="TREK MCP"');
|
|
const r2 = makeRes();
|
|
await opc(
|
|
osvc({
|
|
getUserByAccessToken: vi
|
|
.fn()
|
|
.mockReturnValue({ user: { id: 1, email: 'a@b.c', username: 'u' }, emailVerified: true }),
|
|
}),
|
|
rl(),
|
|
).userinfo('Bearer tok', r2);
|
|
expect(r2.body).toEqual({ sub: '1', email: 'a@b.c', email_verified: true, preferred_username: 'u' });
|
|
});
|
|
|
|
it('userinfo: an email the account typed in itself is not reported as verified', async () => {
|
|
const res = makeRes();
|
|
await opc(
|
|
osvc({
|
|
getUserByAccessToken: vi
|
|
.fn()
|
|
.mockReturnValue({ user: { id: 2, email: 'x@y.z', username: 'v' }, emailVerified: false }),
|
|
}),
|
|
rl(),
|
|
).userinfo('Bearer tok', res);
|
|
expect(res.body).toEqual({ sub: '2', email: 'x@y.z', email_verified: false, preferred_username: 'v' });
|
|
});
|
|
|
|
it('userinfo: 404 empty when MCP is disabled', async () => {
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
const r1 = makeRes();
|
|
await 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();
|
|
await 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', async () => {
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
const s = rl();
|
|
for (let i = 0; i < 10; i++) await s.check('oauth_revoke', '1', 10, 60000, Date.now());
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
getClientIpMock.mockReturnValueOnce(undefined);
|
|
const res = makeRes();
|
|
await 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', async () => {
|
|
const off = makeRes2();
|
|
await 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 = await 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', async () => {
|
|
const deniedSvc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'] }) });
|
|
const denied = await 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 = await 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', async () => {
|
|
expect(
|
|
await thrownAsync(() =>
|
|
new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).listClients(user),
|
|
),
|
|
).toEqual({ status: 403, body: { error: 'MCP is not enabled' } });
|
|
expect(
|
|
await new OauthApiController(
|
|
osvc({ listOAuthClients: vi.fn().mockReturnValue([{ id: 'c1' }]) }),
|
|
rl(),
|
|
).listClients(user),
|
|
).toEqual({ clients: [{ id: 'c1' }] });
|
|
expect(
|
|
await 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(
|
|
await new OauthApiController(osvc({ deleteOAuthClient: vi.fn().mockReturnValue({}) }), rl()).deleteClient(
|
|
user,
|
|
'c1',
|
|
req,
|
|
),
|
|
).toEqual({ success: true });
|
|
expect(
|
|
await new OauthApiController(
|
|
osvc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) }),
|
|
rl(),
|
|
).listSessions(user),
|
|
).toEqual({ sessions: [{ id: 1 }] });
|
|
expect(
|
|
await 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', async () => {
|
|
expect(
|
|
await thrownAsync(() =>
|
|
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(
|
|
await 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', async () => {
|
|
const res = makeRes2();
|
|
const anon = await 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(
|
|
await thrownAsync(() =>
|
|
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', async () => {
|
|
expect(
|
|
await thrownAsync(() =>
|
|
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(
|
|
await thrownAsync(() =>
|
|
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', async () => {
|
|
const s = rl();
|
|
for (let i = 0; i < 30; i++) await s.check('oauth_validate', '1.2.3.4', 30, 60000, Date.now());
|
|
const res = makeRes2();
|
|
expect(await thrownAsync(() => 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', async () => {
|
|
const res = makeRes2();
|
|
const out = await 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', async () => {
|
|
const res = makeRes2();
|
|
const validateAuthorizeRequest = vi.fn().mockReturnValue({ valid: true, scopes: ['s'] });
|
|
const out = await 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', async () => {
|
|
expect(
|
|
await thrownAsync(() =>
|
|
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', async () => {
|
|
const deniedSvc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'] }) });
|
|
const denied = await 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 = await 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', async () => {
|
|
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(
|
|
await thrownAsync(() =>
|
|
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', async () => {
|
|
expect(
|
|
await thrownAsync(() =>
|
|
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(
|
|
await thrownAsync(() =>
|
|
new OauthApiController(
|
|
osvc({ rotateOAuthClientSecret: vi.fn().mockReturnValue({ error: 'bad' }) }),
|
|
rl(),
|
|
).rotateClient(user, 'c1', req),
|
|
),
|
|
).toEqual({ status: 400, body: { error: 'bad' } });
|
|
expect(
|
|
await thrownAsync(() =>
|
|
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(
|
|
await thrownAsync(() =>
|
|
new OauthApiController(
|
|
osvc({ deleteOAuthClient: vi.fn().mockReturnValue({ error: 'bad' }) }),
|
|
rl(),
|
|
).deleteClient(user, 'c1', req),
|
|
),
|
|
).toEqual({ status: 400, body: { error: 'bad' } });
|
|
expect(
|
|
await thrownAsync(() =>
|
|
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(
|
|
await thrownAsync(() =>
|
|
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', async () => {
|
|
expect(
|
|
await thrownAsync(() =>
|
|
new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).listSessions(user),
|
|
),
|
|
).toEqual({ status: 403, body: { error: 'MCP is not enabled' } });
|
|
});
|
|
});
|