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(),