mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(oidc): honor remember-me in SSO login (#1927)
This commit is contained in:
@@ -1086,7 +1086,13 @@ export default function LoginPage(): React.ReactElement {
|
||||
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
||||
</div>
|
||||
<a
|
||||
href={`/api/auth/oidc/login${inviteToken ? '?invite=' + encodeURIComponent(inviteToken) : ''}`}
|
||||
href={`/api/auth/oidc/login${
|
||||
inviteToken ? '?invite=' + encodeURIComponent(inviteToken) : ''
|
||||
}${
|
||||
// The remember-me toggle only renders in login mode; in
|
||||
// register mode omit the param so the server default applies.
|
||||
mode === 'login' ? (inviteToken ? '&' : '?') + 'remember=' + (rememberMe ? '1' : '0') : ''
|
||||
}`}
|
||||
style={{
|
||||
marginTop: 12,
|
||||
width: '100%',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// (OIDC-only, passkeys, forced password change) and the presentation handlers.
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '../../tests/helpers/render';
|
||||
import { render, screen, fireEvent, cleanup } from '../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import LoginPage from './LoginPage';
|
||||
@@ -425,13 +425,29 @@ describe('LoginPage — alternative sign-in buttons', () => {
|
||||
|
||||
expect(screen.getByText('or')).toBeInTheDocument();
|
||||
const link = screen.getByRole('link', { name: /sign in with keycloak/i });
|
||||
expect(link).toHaveAttribute('href', '/api/auth/oidc/login?invite=abc');
|
||||
expect(link).toHaveAttribute('href', '/api/auth/oidc/login?invite=abc&remember=0');
|
||||
|
||||
const background = hoverRoundTrip(link, 'background');
|
||||
expect(background.hovered).not.toBe(background.before);
|
||||
expect(background.after).toBe(background.before);
|
||||
});
|
||||
|
||||
it('FE-LOGIN-WIRE-026b: the SSO link carries remember=1 when the toggle is on, and no flag in register mode (#1927)', () => {
|
||||
fixture.appConfig.oidc_configured = true;
|
||||
fixture.appConfig.oidc_login = true;
|
||||
fixture.appConfig.oidc_display_name = 'Keycloak';
|
||||
fixture.rememberMe = true;
|
||||
renderPage();
|
||||
expect(screen.getByRole('link', { name: /sign in with keycloak/i }))
|
||||
.toHaveAttribute('href', '/api/auth/oidc/login?remember=1');
|
||||
|
||||
cleanup();
|
||||
fixture.mode = 'register';
|
||||
renderPage();
|
||||
expect(screen.getByRole('link', { name: /sign in with keycloak/i }))
|
||||
.toHaveAttribute('href', '/api/auth/oidc/login');
|
||||
});
|
||||
|
||||
it('FE-LOGIN-WIRE-027: the passkey button signs in and reacts to hover', () => {
|
||||
fixture.appConfig.passkey_login = true;
|
||||
fixture.appConfig.passkey_configured = true;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Get, HttpException, Put, Query, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { oidcLoginQuerySchema } from '@trek/shared';
|
||||
import { readEnv } from '../../app-config';
|
||||
import { OidcService, OIDC_STATE_TTL_MS } from './oidc.service';
|
||||
import { cookieOptions } from '../common/cookie';
|
||||
@@ -52,8 +53,12 @@ export class OidcController {
|
||||
return;
|
||||
}
|
||||
const redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`;
|
||||
const inviteToken = req.query.invite as string | undefined;
|
||||
const { state, codeChallenge } = this.oidc.createState(redirectUri, inviteToken);
|
||||
// Lenient parse: a malformed query must never 400 a login redirect, so
|
||||
// unknown values simply fall back to the raw invite + default duration.
|
||||
const query = oidcLoginQuerySchema.safeParse(req.query);
|
||||
const inviteToken = query.success ? query.data.invite : (req.query.invite as string | undefined);
|
||||
const remember = query.success && query.data.remember !== undefined ? query.data.remember === '1' : undefined;
|
||||
const { state, codeChallenge } = this.oidc.createState(redirectUri, inviteToken, remember);
|
||||
// Bind the state to THIS browser. The callback requires a matching cookie,
|
||||
// so an attacker-initiated login (whose callback URL carries a valid state
|
||||
// from the shared server map) cannot be replayed in a victim's browser to
|
||||
@@ -151,8 +156,8 @@ export class OidcController {
|
||||
if ('error' in result) return f('/login?oidc_error=' + result.error);
|
||||
|
||||
this.oidc.touchLastLogin(result.user.id);
|
||||
const jwtToken = this.oidc.generateToken(result.user);
|
||||
const authCode = this.oidc.createAuthCode(jwtToken);
|
||||
const jwtToken = this.oidc.generateToken(result.user, pending.remember === true);
|
||||
const authCode = this.oidc.createAuthCode(jwtToken, pending.remember);
|
||||
return f('/login?oidc_code=' + authCode);
|
||||
} catch (err: unknown) {
|
||||
console.error('[OIDC] Callback error:', err);
|
||||
@@ -171,7 +176,7 @@ export class OidcController {
|
||||
res.status(400).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
this.oidc.setAuthCookie(res, result.token, req);
|
||||
this.oidc.setAuthCookie(res, result.token, req, result.remember);
|
||||
res.json({ token: result.token });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import type { Request, Response } from 'express';
|
||||
import { readEnv, getAppUrl } from '../../app-config';
|
||||
import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../../config';
|
||||
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../../config';
|
||||
import { User } from '../../types';
|
||||
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { TripMembershipService } from '../trip-membership/trip-membership.service';
|
||||
import { setAuthCookie } from '../common/cookie';
|
||||
import { setAuthCookie, RememberOption } from '../common/cookie';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { safeFetchAdminConfigured } from '../../utils/ssrfGuard';
|
||||
@@ -164,13 +164,13 @@ export class OidcService implements OnModuleDestroy {
|
||||
// State management – pending OIDC states
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private readonly pendingStates = new Map<string, { createdAt: number; redirectUri: string; inviteToken?: string; codeVerifier: string }>();
|
||||
private readonly pendingStates = new Map<string, { createdAt: number; redirectUri: string; inviteToken?: string; codeVerifier: string; remember?: boolean }>();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auth code management – short-lived codes exchanged for JWT
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private readonly authCodes = new Map<string, { token: string; created: number }>();
|
||||
private readonly authCodes = new Map<string, { token: string; created: number; remember?: boolean }>();
|
||||
|
||||
// Discovery document cache (1 h TTL), keyed by discovery URL so two
|
||||
// configured issuers no longer thrash a single slot.
|
||||
@@ -209,16 +209,16 @@ export class OidcService implements OnModuleDestroy {
|
||||
|
||||
getAppUrl() { return getAppUrl(); }
|
||||
|
||||
setAuthCookie(res: Response, token: string, req: Request) { setAuthCookie(res, token, req); }
|
||||
setAuthCookie(res: Response, token: string, req: Request, remember?: RememberOption) { setAuthCookie(res, token, req, remember); }
|
||||
|
||||
// Creates the login state and a matching PKCE pair. The verifier stays server
|
||||
// side (in pendingStates); the S256 challenge goes to the provider so PKCE-
|
||||
// required setups (e.g. Pocket ID with PKCE = required) work.
|
||||
createState(redirectUri: string, inviteToken?: string): { state: string; codeChallenge: string } {
|
||||
createState(redirectUri: string, inviteToken?: string, remember?: boolean): { state: string; codeChallenge: string } {
|
||||
const state = crypto.randomBytes(32).toString('hex');
|
||||
const codeVerifier = base64url(crypto.randomBytes(32));
|
||||
const codeChallenge = base64url(crypto.createHash('sha256').update(codeVerifier).digest());
|
||||
this.pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken, codeVerifier });
|
||||
this.pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken, codeVerifier, remember });
|
||||
return { state, codeChallenge };
|
||||
}
|
||||
|
||||
@@ -229,18 +229,18 @@ export class OidcService implements OnModuleDestroy {
|
||||
return pending;
|
||||
}
|
||||
|
||||
createAuthCode(token: string): string {
|
||||
createAuthCode(token: string, remember?: boolean): string {
|
||||
const authCode: string = uuidv4();
|
||||
this.authCodes.set(authCode, { token, created: Date.now() });
|
||||
this.authCodes.set(authCode, { token, created: Date.now(), remember });
|
||||
return authCode;
|
||||
}
|
||||
|
||||
consumeAuthCode(code: string): { token: string } | { error: string } {
|
||||
consumeAuthCode(code: string): { token: string; remember?: boolean } | { error: string } {
|
||||
const entry = this.authCodes.get(code);
|
||||
if (!entry) return { error: 'Invalid or expired code' };
|
||||
this.authCodes.delete(code);
|
||||
if (Date.now() - entry.created > AUTH_CODE_TTL) return { error: 'Code expired' };
|
||||
return { token: entry.token };
|
||||
return { token: entry.token, remember: entry.remember };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -328,12 +328,20 @@ export class OidcService implements OnModuleDestroy {
|
||||
return base + path;
|
||||
}
|
||||
|
||||
generateToken(user: { id: number }): string {
|
||||
generateToken(user: { id: number }, remember?: boolean): string {
|
||||
// Embed the current password_version so an OIDC-issued session is invalidated
|
||||
// by a password change/reset exactly like a password-login session (the auth
|
||||
// middleware compares this `pv` against users.password_version).
|
||||
const pv = (this.db.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version?: number } | undefined)?.password_version ?? 0;
|
||||
return jwt.sign({ id: user.id, pv }, JWT_SECRET, { expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' });
|
||||
// "Remember me" mirrors the password flow: the JWT lifetime matches the
|
||||
// persistent cookie maxAge picked by the cookie service off the same flag,
|
||||
// and the claim lets sliding renewal preserve those semantics.
|
||||
const expiresIn = remember === true ? SESSION_DURATION_REMEMBER_SECONDS : SESSION_DURATION_SECONDS;
|
||||
return jwt.sign(
|
||||
{ id: user.id, pv, ...(typeof remember === 'boolean' ? { remember } : {}) },
|
||||
JWT_SECRET,
|
||||
{ expiresIn, algorithm: 'HS256' },
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -106,4 +106,21 @@ describe('OIDC e2e (real cookie service)', () => {
|
||||
const setCookie = res.headers['set-cookie'] as unknown as string[];
|
||||
expect(setCookie.some((c) => c.startsWith('trek_session=') && /HttpOnly/i.test(c))).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /exchange with a remembered code sets a persistent Max-Age cookie (#1927)', async () => {
|
||||
consumeAuthCode.mockReturnValue({ token: 'jwt.value', remember: true });
|
||||
const res = await request(server).get('/api/auth/oidc/exchange').query({ code: 'good' });
|
||||
expect(res.status).toBe(200);
|
||||
const cookie = (res.headers['set-cookie'] as unknown as string[]).find((c) => c.startsWith('trek_session='))!;
|
||||
expect(cookie).toContain('Max-Age=2592000');
|
||||
});
|
||||
|
||||
it('GET /exchange with remember=false sets a browser-session cookie (no Max-Age) (#1927)', async () => {
|
||||
consumeAuthCode.mockReturnValue({ token: 'jwt.value', remember: false });
|
||||
const res = await request(server).get('/api/auth/oidc/exchange').query({ code: 'good' });
|
||||
expect(res.status).toBe(200);
|
||||
const cookie = (res.headers['set-cookie'] as unknown as string[]).find((c) => c.startsWith('trek_session='))!;
|
||||
expect(cookie).not.toContain('Max-Age=');
|
||||
expect(cookie).not.toContain('Expires=');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,9 @@ vi.mock('../../src/config', () => ({
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
SESSION_DURATION_REMEMBER: '30d',
|
||||
SESSION_DURATION_REMEMBER_MS: 2592000000,
|
||||
SESSION_DURATION_REMEMBER_SECONDS: 2592000,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
@@ -342,3 +345,60 @@ describe('GET /api/auth/oidc/exchange', () => {
|
||||
expect(res2.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── remember-me across the full flow (#1927) ─────────────────────────────────
|
||||
|
||||
describe('OIDC remember-me (#1927)', () => {
|
||||
// Runs the real /login → /callback → /exchange chain, following the state the
|
||||
// controller minted, and returns the exchange response for cookie assertions.
|
||||
async function runFlow(loginQuery: string, sub: string, email: string) {
|
||||
mockDiscover.mockResolvedValue(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValue({ access_token: 'tok', id_token: 'fake.id.token', _ok: true, _status: 200 });
|
||||
mockVerifyIdToken.mockResolvedValue({ ok: true, claims: { sub } });
|
||||
mockGetUserInfo.mockResolvedValue({ sub, email, name: 'Flow User' });
|
||||
|
||||
const login = await request(app).get(`/api/auth/oidc/login${loginQuery}`);
|
||||
expect(login.status).toBe(302);
|
||||
const state = new URL(login.headers.location!).searchParams.get('state')!;
|
||||
|
||||
const cb = await request(app)
|
||||
.get(`/api/auth/oidc/callback?code=anycode&state=${state}`)
|
||||
.set('Cookie', `trek_oidc_state=${state}`);
|
||||
expect(cb.status).toBe(302);
|
||||
const oidcCode = new URL(cb.headers.location!, 'http://localhost').searchParams.get('oidc_code')!;
|
||||
expect(oidcCode).toBeTruthy();
|
||||
|
||||
return request(app).get(`/api/auth/oidc/exchange?code=${oidcCode}`);
|
||||
}
|
||||
|
||||
function sessionCookie(res: request.Response): string {
|
||||
const cookies = Array.isArray(res.headers['set-cookie']) ? res.headers['set-cookie'] : [res.headers['set-cookie']];
|
||||
return cookies.filter((c: string) => c.startsWith('trek_session=')).pop()!;
|
||||
}
|
||||
|
||||
it('OIDC-015: remember=1 issues a 30d JWT and a Max-Age=2592000 cookie', async () => {
|
||||
const res = await runFlow('?remember=1', 'sub-rm-1', 'rm1@example.com');
|
||||
expect(res.status).toBe(200);
|
||||
expect(sessionCookie(res)).toContain('Max-Age=2592000');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const decoded = jwt.decode(res.body.token) as { iat: number; exp: number };
|
||||
expect(decoded.exp - decoded.iat).toBe(2592000);
|
||||
});
|
||||
|
||||
it('OIDC-016: remember=0 issues a browser-session cookie (no Max-Age) with the default JWT', async () => {
|
||||
const res = await runFlow('?remember=0', 'sub-rm-0', 'rm0@example.com');
|
||||
expect(res.status).toBe(200);
|
||||
const cookie = sessionCookie(res);
|
||||
expect(cookie).not.toContain('Max-Age=');
|
||||
expect(cookie).not.toContain('Expires=');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const decoded = jwt.decode(res.body.token) as { iat: number; exp: number };
|
||||
expect(decoded.exp - decoded.iat).toBe(86400);
|
||||
});
|
||||
|
||||
it('OIDC-017: no remember param keeps the historical 24h persistent cookie', async () => {
|
||||
const res = await runFlow('', 'sub-rm-abs', 'rmabs@example.com');
|
||||
expect(res.status).toBe(200);
|
||||
expect(sessionCookie(res)).toContain('Max-Age=86400');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,14 +106,36 @@ describe('OidcController /login', () => {
|
||||
const createState = vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' });
|
||||
const reqInvite = { query: { invite: 'tok123' }, headers: {} } as unknown as Request;
|
||||
await new OidcController(svc({ createState })).login(reqInvite, res);
|
||||
expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', 'tok123');
|
||||
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 new OidcController(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 new OidcController(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 new OidcController(svc({ getAppUrl: vi.fn().mockReturnValue('https://app///'), createState })).login(req, res);
|
||||
expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', undefined);
|
||||
expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', undefined, undefined);
|
||||
});
|
||||
|
||||
it('500 when discovery throws', async () => {
|
||||
@@ -330,6 +352,29 @@ describe('OidcController /callback', () => {
|
||||
await new OidcController(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 into generateToken and createAuthCode', async () => {
|
||||
for (const [remember, expectedGenerate] of [
|
||||
[true, true],
|
||||
[false, false],
|
||||
[undefined, false],
|
||||
] as const) {
|
||||
const generateToken = vi.fn().mockReturnValue('jwt');
|
||||
const createAuthCode = vi.fn().mockReturnValue('ac');
|
||||
const res = makeRes();
|
||||
await new OidcController(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 }, expectedGenerate);
|
||||
expect(createAuthCode).toHaveBeenCalledWith('jwt', remember);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('OidcController /exchange', () => {
|
||||
@@ -347,7 +392,17 @@ describe('OidcController /exchange', () => {
|
||||
const r3 = makeRes();
|
||||
const setAuthCookie = vi.fn();
|
||||
new OidcController(svc({ consumeAuthCode: vi.fn().mockReturnValue({ token: 'jwt' }), setAuthCookie })).exchange('x', req, r3);
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(r3, 'jwt', req);
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(r3, 'jwt', req, 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();
|
||||
new OidcController(svc({ consumeAuthCode: vi.fn().mockReturnValue({ token: 'jwt', remember }), setAuthCookie })).exchange('x', req, res);
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'jwt', req, remember);
|
||||
expect(res.body).toEqual({ token: 'jwt' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,6 +177,15 @@ describe('createState / consumeState', () => {
|
||||
expect(svc.consumeState('not-a-real-state')).toBeNull();
|
||||
});
|
||||
|
||||
it('OIDC-SVC-055: createState stores the remember flag and consumeState returns it', () => {
|
||||
const { state: sTrue } = svc.createState('https://example.com/cb', undefined, true);
|
||||
const { state: sFalse } = svc.createState('https://example.com/cb', undefined, false);
|
||||
const { state: sAbsent } = svc.createState('https://example.com/cb');
|
||||
expect(svc.consumeState(sTrue)!.remember).toBe(true);
|
||||
expect(svc.consumeState(sFalse)!.remember).toBe(false);
|
||||
expect(svc.consumeState(sAbsent)!.remember).toBeUndefined();
|
||||
});
|
||||
|
||||
it('OIDC-SVC-004: two different states do not conflict', () => {
|
||||
const { state: s1 } = svc.createState('http://a.example.com');
|
||||
const { state: s2 } = svc.createState('http://b.example.com');
|
||||
@@ -213,6 +222,34 @@ describe('createAuthCode / consumeAuthCode', () => {
|
||||
const result = svc.consumeAuthCode('not-a-real-code');
|
||||
expect('error' in result).toBe(true);
|
||||
});
|
||||
|
||||
it('OIDC-SVC-056: auth code round-trips the remember flag', () => {
|
||||
const cTrue = svc.createAuthCode('t1', true);
|
||||
const cFalse = svc.createAuthCode('t2', false);
|
||||
const cAbsent = svc.createAuthCode('t3');
|
||||
expect((svc.consumeAuthCode(cTrue) as { remember?: boolean }).remember).toBe(true);
|
||||
expect((svc.consumeAuthCode(cFalse) as { remember?: boolean }).remember).toBe(false);
|
||||
expect((svc.consumeAuthCode(cAbsent) as { remember?: boolean }).remember).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateToken ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('generateToken', () => {
|
||||
it('OIDC-SVC-057: remember=true signs with the SESSION_DURATION_REMEMBER lifetime', () => {
|
||||
const { user } = createUser(testDb, { email: 'remember@example.com' });
|
||||
const token = svc.generateToken({ id: user.id }, true);
|
||||
const decoded = jwtLib.decode(token) as { iat: number; exp: number };
|
||||
expect(decoded.exp - decoded.iat).toBe(2592000);
|
||||
});
|
||||
|
||||
it('OIDC-SVC-058: remember=false or absent signs with the default SESSION_DURATION lifetime', () => {
|
||||
const { user } = createUser(testDb, { email: 'default@example.com' });
|
||||
for (const token of [svc.generateToken({ id: user.id }, false), svc.generateToken({ id: user.id })]) {
|
||||
const decoded = jwtLib.decode(token) as { iat: number; exp: number };
|
||||
expect(decoded.exp - decoded.iat).toBe(86400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveOidcRole ───────────────────────────────────────────────────────────
|
||||
@@ -839,7 +876,16 @@ describe('wrapper methods', () => {
|
||||
const res = {} as Response;
|
||||
const req = {} as Request;
|
||||
svc.setAuthCookie(res, 'jwt', req);
|
||||
expect(setAuthCookieMock).toHaveBeenCalledWith(res, 'jwt', req);
|
||||
expect(setAuthCookieMock).toHaveBeenCalledWith(res, 'jwt', req, undefined);
|
||||
});
|
||||
|
||||
it('OIDC-SVC-059: setAuthCookie forwards the remember option to the cookie helper', () => {
|
||||
const res = {} as Response;
|
||||
const req = {} as Request;
|
||||
svc.setAuthCookie(res, 'jwt', req, true);
|
||||
expect(setAuthCookieMock).toHaveBeenCalledWith(res, 'jwt', req, true);
|
||||
svc.setAuthCookie(res, 'jwt', req, false);
|
||||
expect(setAuthCookieMock).toHaveBeenCalledWith(res, 'jwt', req, false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { oidcCallbackQuerySchema, oidcExchangeQuerySchema } from './oidc.schema';
|
||||
import { oidcCallbackQuerySchema, oidcExchangeQuerySchema, oidcLoginQuerySchema } from './oidc.schema';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('oidcLoginQuerySchema', () => {
|
||||
it('accepts invite and remember flags, both optional', () => {
|
||||
expect(oidcLoginQuerySchema.safeParse({}).success).toBe(true);
|
||||
expect(oidcLoginQuerySchema.safeParse({ invite: 'tok' }).success).toBe(true);
|
||||
expect(oidcLoginQuerySchema.safeParse({ remember: '1' }).success).toBe(true);
|
||||
expect(oidcLoginQuerySchema.safeParse({ invite: 'tok', remember: '0' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects remember values other than the "0"/"1" wire flags', () => {
|
||||
expect(oidcLoginQuerySchema.safeParse({ remember: 'yes' }).success).toBe(false);
|
||||
expect(oidcLoginQuerySchema.safeParse({ remember: 'true' }).success).toBe(false);
|
||||
expect(oidcLoginQuerySchema.safeParse({ remember: 1 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('oidcCallbackQuerySchema', () => {
|
||||
it('accepts code+state, an error, or nothing (all optional)', () => {
|
||||
expect(oidcCallbackQuerySchema.safeParse({ code: 'c', state: 's' }).success).toBe(true);
|
||||
|
||||
@@ -8,6 +8,15 @@ import { z } from 'zod';
|
||||
* /login, and the auth-code on /exchange). These schemas pin those query shapes;
|
||||
* the cryptographic verification + provisioning live in the OIDC service.
|
||||
*/
|
||||
export const oidcLoginQuerySchema = z.object({
|
||||
invite: z.string().optional(),
|
||||
// '1' → remember-me session (SESSION_DURATION_REMEMBER), '0' → browser-session
|
||||
// cookie, absent → the historical default duration. String flags because the
|
||||
// value survives a redirect query, not a JSON body.
|
||||
remember: z.enum(['0', '1']).optional(),
|
||||
});
|
||||
export type OidcLoginQuery = z.infer<typeof oidcLoginQuerySchema>;
|
||||
|
||||
export const oidcCallbackQuerySchema = z.object({
|
||||
code: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
|
||||
Reference in New Issue
Block a user