Files
TREK/server/tests/unit/nest/oidc.controller.test.ts
T
2026-10-06 21:50:59 +02:00

688 lines
28 KiB
TypeScript

import type { AuditService } from '../../../src/nest/audit/audit.service';
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 { Request, Response } from 'express';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// 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().mockReturnValue({ state: 'st', codeChallenge: 'cc' }),
consumeState: vi
.fn()
.mockReturnValue({
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().mockReturnValue({ 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().mockReturnValue({ 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().mockReturnValue({ 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().mockReturnValue({ 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().mockReturnValue({ state: 'st', codeChallenge: 'cc' });
await ctl(svc({ getAppUrl: vi.fn().mockReturnValue('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().mockReturnValue(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().mockReturnValue({ code: 'ac', binding: 'bnd' });
const res = makeRes();
await ctl(
svc({
consumeState: vi
.fn()
.mockReturnValue({
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);
expect(generateToken).toHaveBeenCalledWith({ id: 1 }, remember);
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', () => {
const r1 = makeRes();
ctl(svc()).exchange(undefined, reqEx(), r1);
expect(r1.statusCode).toBe(400);
expect(r1.body).toEqual({ error: 'Code required' });
const r2 = makeRes();
ctl(svc({ consumeAuthCode: vi.fn().mockReturnValue({ 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();
ctl(svc({ consumeAuthCode: vi.fn().mockReturnValue({ token: 'jwt' }), setAuthCookie })).exchange('x', req3, r3);
expect(setAuthCookie).toHaveBeenCalledWith(r3, 'jwt', req3, undefined);
expect(r3.body).toEqual({ token: 'jwt' });
});
it('forwards the stored remember flag to setAuthCookie', () => {
for (const remember of [true, false] as const) {
const res = makeRes();
const setAuthCookie = vi.fn();
const r = reqEx();
ctl(svc({ consumeAuthCode: vi.fn().mockReturnValue({ token: 'jwt', remember }), setAuthCookie })).exchange(
'x',
r,
res,
);
expect(setAuthCookie).toHaveBeenCalledWith(res, 'jwt', r, remember);
expect(res.body).toEqual({ token: 'jwt' });
}
});
it('hands the binding cookie to consumeAuthCode and clears it on the way out', () => {
const consumeAuthCode = vi.fn().mockReturnValue({ token: 'jwt' });
const res = makeRes();
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', () => {
const consumeAuthCode = vi.fn().mockReturnValue({ error: 'Invalid or expired code' });
const res = makeRes();
// A request from any other context: no cookie jar for this origin at all.
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', () => {
const res = makeRes();
ctl(svc()).exchange(undefined, reqEx(), res);
expect(res.clearCookie).toHaveBeenCalledWith('trek_oidc_exchange', expect.anything());
});
});