mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
0dde01baa2
The token in the sign-in bodies was declared deprecated and "read by
nothing of ours", but the web client still decided an SSO sign-in by
reading data.token from GET /api/auth/oidc/exchange, an endpoint the
deprecation notice left out. Removing the field later, as documented,
would have broken SSO sign-in.
The exchange now answers { success: true, token } and the login hook
signs in when the response is OK and success is true, so it never reads
token. The field stays for API clients and is now on the deprecation
list in the API docs, the controller comments, server/CLAUDE.md and the
wiki (Security-Hardening, OIDC-SSO). Adding a field to the body breaks
no existing client.
529 lines
28 KiB
TypeScript
529 lines
28 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import type { Request, Response } from 'express';
|
|
|
|
import { OidcController } from '../../../src/nest/oidc/oidc.controller';
|
|
import { OIDC_STATE_TTL_MS } from '../../../src/nest/oidc/oidc.service';
|
|
import type { OidcService } from '../../../src/nest/oidc/oidc.service';
|
|
import type { AuditService } from '../../../src/nest/audit/audit.service';
|
|
|
|
// AuditService is constructor-injected since the OIDC role change started
|
|
// auditing; the wrapper keeps the historical construction sites positional.
|
|
const writeAudit = vi.fn();
|
|
const audit = { writeAudit } as unknown as AuditService;
|
|
const ctl = (o: OidcService) => new OidcController(o, audit);
|
|
|
|
function svc(o: Partial<OidcService> = {}): OidcService {
|
|
return {
|
|
oidcLoginEnabled: vi.fn().mockReturnValue(true),
|
|
getOidcConfig: vi.fn().mockReturnValue({ issuer: 'https://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }),
|
|
getAppUrl: vi.fn().mockReturnValue('https://app'),
|
|
discover: vi.fn().mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp' }),
|
|
createState: vi.fn().mockResolvedValue({ state: 'st', codeChallenge: 'cc' }),
|
|
consumeState: vi.fn().mockResolvedValue({ redirectUri: 'https://app/api/auth/oidc/callback', codeVerifier: 'cv', inviteToken: undefined }),
|
|
exchangeCodeForToken: vi.fn(),
|
|
verifyIdToken: vi.fn(),
|
|
getUserInfo: vi.fn(),
|
|
findOrCreateUser: vi.fn(),
|
|
touchLastLogin: vi.fn(),
|
|
generateToken: vi.fn().mockReturnValue('jwt'),
|
|
createAuthCode: vi.fn().mockResolvedValue({ code: 'ac', binding: 'bnd' }),
|
|
consumeAuthCode: vi.fn(),
|
|
frontendUrl: vi.fn((p: string) => 'https://app' + p),
|
|
setAuthCookie: vi.fn(),
|
|
...o,
|
|
} as unknown as OidcService;
|
|
}
|
|
|
|
function makeRes() {
|
|
const res = {
|
|
statusCode: 200,
|
|
redirectedTo: '' as string,
|
|
body: undefined as unknown,
|
|
status: vi.fn((c: number) => { res.statusCode = c; return res; }),
|
|
json: vi.fn((b: unknown) => { res.body = b; return res; }),
|
|
redirect: vi.fn((u: string) => { res.redirectedTo = u; }),
|
|
cookie: vi.fn(),
|
|
clearCookie: vi.fn(),
|
|
};
|
|
return res as unknown as Response & { statusCode: number; redirectedTo: string; body: unknown };
|
|
}
|
|
|
|
const req = { query: {}, headers: {} } as Request;
|
|
// Exchange request carrying the binding cookie the callback handed this browser.
|
|
const reqEx = (binding = 'bnd') => ({ query: {}, headers: {}, cookies: { trek_oidc_exchange: binding } } as unknown as Request);
|
|
// Callback request carrying the state-binding cookie a real browser would send
|
|
// after going through /login.
|
|
const reqCb = (state = 's') => ({ query: {}, headers: {}, cookies: { trek_oidc_state: state } } as unknown as Request);
|
|
|
|
beforeEach(() => vi.clearAllMocks());
|
|
afterEach(() => { delete process.env.NODE_ENV; });
|
|
|
|
describe('OidcController /login', () => {
|
|
it('403 when SSO is disabled', async () => {
|
|
const res = makeRes();
|
|
await ctl(svc({ oidcLoginEnabled: vi.fn().mockReturnValue(false) })).login(req, res);
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.body).toEqual({ error: 'SSO login is disabled.' });
|
|
});
|
|
|
|
it('400 when not configured', async () => {
|
|
const res = makeRes();
|
|
await ctl(svc({ getOidcConfig: vi.fn().mockReturnValue(null) })).login(req, res);
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body).toEqual({ error: 'OIDC not configured' });
|
|
});
|
|
|
|
it('redirects to the provider authorize endpoint with PKCE params', async () => {
|
|
const res = makeRes();
|
|
await ctl(svc()).login(req, res);
|
|
expect(res.redirect).toHaveBeenCalled();
|
|
expect(res.redirectedTo).toContain('https://idp/auth?');
|
|
expect(res.redirectedTo).toContain('code_challenge=cc');
|
|
expect(res.redirectedTo).toContain('code_challenge_method=S256');
|
|
});
|
|
|
|
it('binds the state cookie with a maxAge matching the server-side state TTL', async () => {
|
|
const res = makeRes();
|
|
await ctl(svc()).login(req, res);
|
|
expect(res.cookie).toHaveBeenCalledWith('trek_oidc_state', 'st', expect.objectContaining({ maxAge: OIDC_STATE_TTL_MS }));
|
|
});
|
|
|
|
it('400 when a non-HTTPS issuer is used in production', async () => {
|
|
process.env.NODE_ENV = 'production';
|
|
const res = makeRes();
|
|
await ctl(svc({ getOidcConfig: vi.fn().mockReturnValue({ issuer: 'http://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }) })).login(req, res);
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body).toEqual({ error: 'OIDC issuer must use HTTPS in production' });
|
|
});
|
|
|
|
it('allows a non-HTTPS issuer outside production', async () => {
|
|
process.env.NODE_ENV = 'development';
|
|
const res = makeRes();
|
|
await ctl(svc({ getOidcConfig: vi.fn().mockReturnValue({ issuer: 'http://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }) })).login(req, res);
|
|
expect(res.redirect).toHaveBeenCalled();
|
|
});
|
|
|
|
it('500 when APP_URL is not configured', async () => {
|
|
const res = makeRes();
|
|
await ctl(svc({ getAppUrl: vi.fn().mockReturnValue('') })).login(req, res);
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res.body).toEqual({ error: 'APP_URL is not configured. OIDC cannot be used.' });
|
|
});
|
|
|
|
it('passes the invite token from the query into createState', async () => {
|
|
const res = makeRes();
|
|
const createState = vi.fn().mockResolvedValue({ state: 'st', codeChallenge: 'cc' });
|
|
const reqInvite = { query: { invite: 'tok123' }, headers: {} } as unknown as Request;
|
|
await ctl(svc({ createState })).login(reqInvite, res);
|
|
expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', 'tok123', undefined);
|
|
});
|
|
|
|
it('threads remember=1 / remember=0 / absent into createState as true / false / undefined', async () => {
|
|
for (const [query, expected] of [
|
|
[{ remember: '1' }, true],
|
|
[{ remember: '0' }, false],
|
|
[{}, undefined],
|
|
] as const) {
|
|
const createState = vi.fn().mockResolvedValue({ state: 'st', codeChallenge: 'cc' });
|
|
const r = makeRes();
|
|
await ctl(svc({ createState })).login({ query, headers: {} } as unknown as Request, r);
|
|
expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', undefined, expected);
|
|
}
|
|
});
|
|
|
|
it('a malformed remember value never blocks the redirect and falls back to the default duration', async () => {
|
|
const createState = vi.fn().mockResolvedValue({ state: 'st', codeChallenge: 'cc' });
|
|
const res = makeRes();
|
|
const reqBad = { query: { invite: 'tok123', remember: 'yes' }, headers: {} } as unknown as Request;
|
|
await ctl(svc({ createState })).login(reqBad, res);
|
|
expect(res.redirect).toHaveBeenCalled();
|
|
expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', 'tok123', undefined);
|
|
});
|
|
|
|
it('trims a trailing slash off APP_URL when building the redirect uri', async () => {
|
|
const res = makeRes();
|
|
const createState = vi.fn().mockResolvedValue({ state: 'st', codeChallenge: 'cc' });
|
|
await ctl(svc({ getAppUrl: vi.fn().mockResolvedValue('https://app///'), createState })).login(req, res);
|
|
expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', undefined, undefined);
|
|
});
|
|
|
|
it('500 when discovery throws', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const res = makeRes();
|
|
await ctl(svc({ discover: vi.fn().mockRejectedValue(new Error('boom')) })).login(req, res);
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res.body).toEqual({ error: 'OIDC login failed' });
|
|
});
|
|
|
|
it('500 logs a non-Error rejection without crashing', async () => {
|
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const res = makeRes();
|
|
await ctl(svc({ discover: vi.fn().mockRejectedValue('plain string') })).login(req, res);
|
|
expect(res.statusCode).toBe(500);
|
|
expect(spy).toHaveBeenCalledWith('[OIDC] Login error:', 'plain string');
|
|
});
|
|
});
|
|
|
|
describe('OidcController /callback', () => {
|
|
it('redirects with sso_disabled when SSO is off', async () => {
|
|
const res = makeRes();
|
|
await ctl(svc({ oidcLoginEnabled: vi.fn().mockReturnValue(false) })).callback('c', 's', undefined, reqCb('s'), res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=sso_disabled');
|
|
});
|
|
|
|
it('redirects with the provider error', async () => {
|
|
const res = makeRes();
|
|
await ctl(svc()).callback(undefined, undefined, 'access_denied', reqCb('s'), res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=access_denied');
|
|
});
|
|
|
|
it('redirects missing_params / invalid_state', async () => {
|
|
const r1 = makeRes();
|
|
await ctl(svc()).callback(undefined, 's', undefined, reqCb('s'), r1);
|
|
expect(r1.redirectedTo).toBe('https://app/login?oidc_error=missing_params');
|
|
const r2 = makeRes();
|
|
await ctl(svc({ consumeState: vi.fn().mockResolvedValue(null) })).callback('c', 's', undefined, reqCb('s'), r2);
|
|
expect(r2.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
|
|
});
|
|
|
|
it('rejects a missing id_token, then completes with an auth code on success', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const noId = makeRes();
|
|
await ctl(svc({ exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at' }) })).callback('c', 's', undefined, reqCb('s'), noId);
|
|
expect(noId.redirectedTo).toBe('https://app/login?oidc_error=no_id_token');
|
|
|
|
const ok = makeRes();
|
|
const c = ctl(svc({
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
|
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 1 } }),
|
|
}));
|
|
await c.callback('c', 's', undefined, reqCb('s'), ok);
|
|
expect(ok.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
|
// The code alone is not the credential: its other half goes out as a cookie
|
|
// that only this browser holds, and expires with the code.
|
|
expect(ok.cookie).toHaveBeenCalledWith('trek_oidc_exchange', 'bnd', expect.objectContaining({ httpOnly: true, maxAge: 60000, sameSite: 'lax' }));
|
|
});
|
|
|
|
it('OIDC-CB-LOGIN-001: a successful login writes the user.login row every other method writes (#2417)', async () => {
|
|
const res = makeRes();
|
|
const reqIp = { query: {}, headers: {}, ip: '203.0.113.9', cookies: { trek_oidc_state: 's' } } as unknown as Request;
|
|
await ctl(svc({
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
|
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 7 } }),
|
|
})).callback('c', 's', undefined, reqIp, res);
|
|
|
|
expect(writeAudit).toHaveBeenCalledTimes(1);
|
|
expect(writeAudit).toHaveBeenCalledWith({ userId: 7, action: 'user.login', ip: '203.0.113.9', details: { method: 'oidc' } });
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
|
});
|
|
|
|
it('OIDC-CB-LOGIN-003: an account the callback creates writes the user.register row a password signup writes, then the login', async () => {
|
|
const res = makeRes();
|
|
const reqIp = { query: {}, headers: {}, ip: '203.0.113.9', cookies: { trek_oidc_state: 's' } } as unknown as Request;
|
|
await ctl(svc({
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
|
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 7, username: 'a', email: 'a@b.c', role: 'user' }, created: true }),
|
|
})).callback('c', 's', undefined, reqIp, res);
|
|
|
|
expect(writeAudit).toHaveBeenCalledTimes(2);
|
|
expect(writeAudit).toHaveBeenNthCalledWith(1, {
|
|
userId: 7, action: 'user.register', ip: '203.0.113.9',
|
|
details: { username: 'a', email: 'a@b.c', role: 'user', method: 'oidc' },
|
|
});
|
|
expect(writeAudit).toHaveBeenNthCalledWith(2, { userId: 7, action: 'user.login', ip: '203.0.113.9', details: { method: 'oidc' } });
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
|
});
|
|
|
|
it('OIDC-CB-LOGIN-002: a login refused by the user lookup writes no login row', async () => {
|
|
const res = makeRes();
|
|
await ctl(svc({
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
|
findOrCreateUser: vi.fn().mockReturnValue({ error: 'registration_disabled' }),
|
|
})).callback('c', 's', undefined, reqCb('s'), res);
|
|
|
|
expect(writeAudit).not.toHaveBeenCalled();
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=registration_disabled');
|
|
});
|
|
|
|
it('audits a role change the claim mapping made, with the client IP and the claim name only', async () => {
|
|
const res = makeRes();
|
|
const reqIp = { query: {}, headers: {}, ip: '203.0.113.9', cookies: { trek_oidc_state: 's' } } as unknown as Request;
|
|
await ctl(svc({
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
|
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 7 }, roleChange: { from: 'admin', to: 'user', claim: 'entitlements' } }),
|
|
})).callback('c', 's', undefined, reqIp, res);
|
|
|
|
expect(writeAudit).toHaveBeenCalledWith({
|
|
userId: 7,
|
|
action: 'oidc.role_change',
|
|
resource: '7',
|
|
ip: '203.0.113.9',
|
|
details: { from: 'admin', to: 'user', claim: 'entitlements' },
|
|
});
|
|
// The login row still follows, the role change does not replace it.
|
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ userId: 7, action: 'user.login' }));
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
|
});
|
|
|
|
it('writes no role-change row when the login left the role alone', async () => {
|
|
const res = makeRes();
|
|
await ctl(svc({
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
|
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 7 } }),
|
|
})).callback('c', 's', undefined, reqCb('s'), res);
|
|
|
|
expect(writeAudit).not.toHaveBeenCalledWith(expect.objectContaining({ action: 'oidc.role_change' }));
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
|
});
|
|
|
|
it('rejects a callback whose state cookie does not match the query state', async () => {
|
|
const res = makeRes();
|
|
// Browser presents a different (or no) state cookie than the callback URL —
|
|
// an attacker-initiated flow replayed in the victim's browser.
|
|
await ctl(svc()).callback('c', 's', undefined, reqCb('attacker-state'), res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
|
|
});
|
|
|
|
it('rejects a userinfo subject mismatch', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const res = makeRes();
|
|
const c = ctl(svc({
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'OTHER' }),
|
|
}));
|
|
await c.callback('c', 's', undefined, reqCb('s'), res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=subject_mismatch');
|
|
});
|
|
|
|
it('redirects invalid_state when there is no bound state cookie at all', async () => {
|
|
const res = makeRes();
|
|
const reqNoCookie = { query: {}, headers: {}, cookies: {} } as unknown as Request;
|
|
await ctl(svc()).callback('c', 's', undefined, reqNoCookie, res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
|
|
});
|
|
|
|
it('tolerates a request with no cookies object', async () => {
|
|
const res = makeRes();
|
|
const reqNoCookies = { query: {}, headers: {} } as unknown as Request;
|
|
await ctl(svc()).callback('c', 's', undefined, reqNoCookies, res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
|
|
});
|
|
|
|
it('redirects not_configured when the config disappears mid-flow', async () => {
|
|
const res = makeRes();
|
|
await ctl(svc({ getOidcConfig: vi.fn().mockReturnValue(null) })).callback('c', 's', undefined, reqCb('s'), res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=not_configured');
|
|
});
|
|
|
|
it('redirects issuer_not_https when a non-HTTPS issuer is used in production', async () => {
|
|
process.env.NODE_ENV = 'production';
|
|
const res = makeRes();
|
|
await ctl(svc({ getOidcConfig: vi.fn().mockReturnValue({ issuer: 'http://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }) })).callback('c', 's', undefined, reqCb('s'), res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=issuer_not_https');
|
|
});
|
|
|
|
it('redirects token_failed when the token exchange is not ok', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const res = makeRes();
|
|
await ctl(svc({ exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: false, _status: 401 }) })).callback('c', 's', undefined, reqCb('s'), res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=token_failed');
|
|
});
|
|
|
|
it('redirects token_failed when the access token is missing', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const res = makeRes();
|
|
await ctl(svc({ exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true }) })).callback('c', 's', undefined, reqCb('s'), res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=token_failed');
|
|
});
|
|
|
|
it('redirects id_token_invalid when verification fails with a reason', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const res = makeRes();
|
|
await ctl(svc({
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: false, error: 'bad_signature' }),
|
|
})).callback('c', 's', undefined, reqCb('s'), res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=id_token_invalid');
|
|
});
|
|
|
|
it('redirects id_token_invalid when verification fails without an error field', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const res = makeRes();
|
|
await ctl(svc({
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: false }),
|
|
})).callback('c', 's', undefined, reqCb('s'), res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=id_token_invalid');
|
|
});
|
|
|
|
it('falls back to config.issuer when the discovery doc has no issuer', async () => {
|
|
const verifyIdToken = vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } });
|
|
const res = makeRes();
|
|
await ctl(svc({
|
|
discover: vi.fn().mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui' }),
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken,
|
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
|
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 1 } }),
|
|
})).callback('c', 's', undefined, reqCb('s'), res);
|
|
// doc.issuer absent → (doc.issuer ?? '') is '' → falls back to config.issuer
|
|
expect(verifyIdToken).toHaveBeenCalledWith('it', expect.anything(), 'c', 'https://idp');
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
|
});
|
|
|
|
it('strips trailing slashes off the discovery doc issuer before verifying', async () => {
|
|
const verifyIdToken = vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } });
|
|
const res = makeRes();
|
|
await ctl(svc({
|
|
discover: vi.fn().mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp/' }),
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken,
|
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
|
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 1 } }),
|
|
})).callback('c', 's', undefined, reqCb('s'), res);
|
|
expect(verifyIdToken).toHaveBeenCalledWith('it', expect.anything(), 'c', 'https://idp');
|
|
});
|
|
|
|
it('redirects no_email when the userinfo has no email', async () => {
|
|
const res = makeRes();
|
|
await ctl(svc({
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
|
getUserInfo: vi.fn().mockResolvedValue({ sub: 'u1' }),
|
|
})).callback('c', 's', undefined, reqCb('s'), res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=no_email');
|
|
});
|
|
|
|
it('accepts when userinfo omits sub (no cross-check to run)', async () => {
|
|
const res = makeRes();
|
|
await ctl(svc({
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c' }),
|
|
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 1 } }),
|
|
})).callback('c', 's', undefined, reqCb('s'), res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
|
});
|
|
|
|
it('accepts when the id_token claims have a non-string sub (cross-check skipped)', async () => {
|
|
const res = makeRes();
|
|
await ctl(svc({
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 12345 } }),
|
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'something-else' }),
|
|
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 1 } }),
|
|
})).callback('c', 's', undefined, reqCb('s'), res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
|
});
|
|
|
|
it('surfaces a findOrCreateUser provisioning error', async () => {
|
|
const res = makeRes();
|
|
await ctl(svc({
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
|
findOrCreateUser: vi.fn().mockReturnValue({ error: 'registration_disabled' }),
|
|
})).callback('c', 's', undefined, reqCb('s'), res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=registration_disabled');
|
|
});
|
|
|
|
it('redirects server_error when the flow throws', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const res = makeRes();
|
|
await ctl(svc({ discover: vi.fn().mockRejectedValue(new Error('network down')) })).callback('c', 's', undefined, reqCb('s'), res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=server_error');
|
|
});
|
|
|
|
it('threads the pending remember flag unchanged into generateToken and createAuthCode', async () => {
|
|
// An absent flag must stay absent in the token: coercing it to `false`
|
|
// would let the sliding renewal downgrade the default persistent cookie
|
|
// to a browser-session cookie half a lifetime later.
|
|
for (const remember of [true, false, undefined] as const) {
|
|
const generateToken = vi.fn().mockReturnValue('jwt');
|
|
const createAuthCode = vi.fn().mockResolvedValue({ code: 'ac', binding: 'bnd' });
|
|
const res = makeRes();
|
|
await ctl(svc({
|
|
consumeState: vi.fn().mockResolvedValue({ redirectUri: 'https://app/api/auth/oidc/callback', codeVerifier: 'cv', inviteToken: undefined, remember }),
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
|
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 1 } }),
|
|
generateToken,
|
|
createAuthCode,
|
|
})).callback('c', 's', undefined, reqCb('s'), res);
|
|
// The device rides along so the session list can name it (none on this stub request).
|
|
expect(generateToken).toHaveBeenCalledWith({ id: 1 }, remember, { userAgent: null });
|
|
expect(createAuthCode).toHaveBeenCalledWith('jwt', remember);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('OidcController /exchange', () => {
|
|
it('400 without a code, 400 on an invalid code, else sets the cookie + returns the token', async () => {
|
|
const r1 = makeRes();
|
|
await ctl(svc()).exchange(undefined, reqEx(), r1);
|
|
expect(r1.statusCode).toBe(400);
|
|
expect(r1.body).toEqual({ error: 'Code required' });
|
|
|
|
const r2 = makeRes();
|
|
await ctl(svc({ consumeAuthCode: vi.fn().mockResolvedValue({ error: 'invalid_code' }) })).exchange('x', reqEx(), r2);
|
|
expect(r2.statusCode).toBe(400);
|
|
expect(r2.body).toEqual({ error: 'invalid_code' });
|
|
|
|
const r3 = makeRes();
|
|
const setAuthCookie = vi.fn();
|
|
const req3 = reqEx();
|
|
await ctl(svc({ consumeAuthCode: vi.fn().mockResolvedValue({ token: 'jwt' }), setAuthCookie })).exchange('x', req3, r3);
|
|
expect(setAuthCookie).toHaveBeenCalledWith(r3, 'jwt', req3, undefined);
|
|
expect(r3.body).toEqual({ success: true, token: 'jwt' });
|
|
});
|
|
|
|
it('forwards the stored remember flag to setAuthCookie', async () => {
|
|
for (const remember of [true, false] as const) {
|
|
const res = makeRes();
|
|
const setAuthCookie = vi.fn();
|
|
const r = reqEx();
|
|
await ctl(svc({ consumeAuthCode: vi.fn().mockResolvedValue({ token: 'jwt', remember }), setAuthCookie })).exchange('x', r, res);
|
|
expect(setAuthCookie).toHaveBeenCalledWith(res, 'jwt', r, remember);
|
|
expect(res.body).toEqual({ success: true, token: 'jwt' });
|
|
}
|
|
});
|
|
|
|
it('hands the binding cookie to consumeAuthCode and clears it on the way out', async () => {
|
|
const consumeAuthCode = vi.fn().mockResolvedValue({ token: 'jwt' });
|
|
const res = makeRes();
|
|
await ctl(svc({ consumeAuthCode })).exchange('x', reqEx('the-secret'), res);
|
|
expect(consumeAuthCode).toHaveBeenCalledWith('x', 'the-secret');
|
|
expect(res.clearCookie).toHaveBeenCalledWith('trek_oidc_exchange', expect.objectContaining({ httpOnly: true, path: '/' }));
|
|
});
|
|
|
|
it('passes undefined when the browser has no binding cookie, and still clears it', async () => {
|
|
const consumeAuthCode = vi.fn().mockResolvedValue({ error: 'Invalid or expired code' });
|
|
const res = makeRes();
|
|
// A request from any other context: no cookie jar for this origin at all.
|
|
await ctl(svc({ consumeAuthCode })).exchange('stolen', { query: {}, headers: {} } as Request, res);
|
|
expect(consumeAuthCode).toHaveBeenCalledWith('stolen', undefined);
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.clearCookie).toHaveBeenCalledWith('trek_oidc_exchange', expect.anything());
|
|
});
|
|
|
|
it('clears the binding cookie even when no code was presented', async () => {
|
|
const res = makeRes();
|
|
await ctl(svc()).exchange(undefined, reqEx(), res);
|
|
expect(res.clearCookie).toHaveBeenCalledWith('trek_oidc_exchange', expect.anything());
|
|
});
|
|
});
|