From 61e03fdfde657f3fb160b97242405bb7c3ba2508 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 15 Aug 2026 22:48:03 +0200 Subject: [PATCH] feat(oidc): honor remember-me in SSO login (#1927) --- client/src/pages/LoginPage.tsx | 8 ++- client/src/pages/LoginPage.wiring.test.tsx | 20 +++++- server/src/nest/oidc/oidc.controller.ts | 15 +++-- server/src/nest/oidc/oidc.service.ts | 34 +++++++---- server/tests/e2e/oidc.e2e.test.ts | 17 ++++++ server/tests/integration/oidc.test.ts | 60 ++++++++++++++++++ .../tests/unit/nest/oidc.controller.test.ts | 61 ++++++++++++++++++- server/tests/unit/nest/oidc.service.test.ts | 48 ++++++++++++++- shared/src/oidc/oidc.schema.spec.ts | 17 +++++- shared/src/oidc/oidc.schema.ts | 9 +++ 10 files changed, 263 insertions(+), 26 deletions(-) diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index fe5900824..453f8f1b5 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -1086,7 +1086,13 @@ export default function LoginPage(): React.ReactElement {
{ 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; diff --git a/server/src/nest/oidc/oidc.controller.ts b/server/src/nest/oidc/oidc.controller.ts index 47a31f8f5..c4d3bf96b 100644 --- a/server/src/nest/oidc/oidc.controller.ts +++ b/server/src/nest/oidc/oidc.controller.ts @@ -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 }); } } diff --git a/server/src/nest/oidc/oidc.service.ts b/server/src/nest/oidc/oidc.service.ts index 86d076f9c..c68a505d9 100644 --- a/server/src/nest/oidc/oidc.service.ts +++ b/server/src/nest/oidc/oidc.service.ts @@ -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(); + private readonly pendingStates = new Map(); // ------------------------------------------------------------------------- // Auth code management – short-lived codes exchanged for JWT // ------------------------------------------------------------------------- - private readonly authCodes = new Map(); + private readonly authCodes = new Map(); // 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' }, + ); } // ------------------------------------------------------------------------- diff --git a/server/tests/e2e/oidc.e2e.test.ts b/server/tests/e2e/oidc.e2e.test.ts index 9a946432d..8b8720c49 100644 --- a/server/tests/e2e/oidc.e2e.test.ts +++ b/server/tests/e2e/oidc.e2e.test.ts @@ -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='); + }); }); diff --git a/server/tests/integration/oidc.test.ts b/server/tests/integration/oidc.test.ts index 2f9cac175..70f55ae29 100644 --- a/server/tests/integration/oidc.test.ts +++ b/server/tests/integration/oidc.test.ts @@ -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'); + }); +}); diff --git a/server/tests/unit/nest/oidc.controller.test.ts b/server/tests/unit/nest/oidc.controller.test.ts index 3bfcfd6bb..c53eeeb7c 100644 --- a/server/tests/unit/nest/oidc.controller.test.ts +++ b/server/tests/unit/nest/oidc.controller.test.ts @@ -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' }); + } + }); }); diff --git a/server/tests/unit/nest/oidc.service.test.ts b/server/tests/unit/nest/oidc.service.test.ts index e9f9d6203..d707d954e 100644 --- a/server/tests/unit/nest/oidc.service.test.ts +++ b/server/tests/unit/nest/oidc.service.test.ts @@ -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); }); }); diff --git a/shared/src/oidc/oidc.schema.spec.ts b/shared/src/oidc/oidc.schema.spec.ts index 66bee6875..fe68554f6 100644 --- a/shared/src/oidc/oidc.schema.spec.ts +++ b/shared/src/oidc/oidc.schema.spec.ts @@ -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); diff --git a/shared/src/oidc/oidc.schema.ts b/shared/src/oidc/oidc.schema.ts index bbbaa8485..8a96a236e 100644 --- a/shared/src/oidc/oidc.schema.ts +++ b/shared/src/oidc/oidc.schema.ts @@ -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; + export const oidcCallbackQuerySchema = z.object({ code: z.string().optional(), state: z.string().optional(),