feat(oidc): honor remember-me in SSO login (#1927)

This commit is contained in:
jubnl
2026-08-15 22:48:03 +02:00
parent cb41d082be
commit 61e03fdfde
10 changed files with 263 additions and 26 deletions
+7 -1
View File
@@ -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%',
+18 -2
View File
@@ -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;
+10 -5
View File
@@ -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 });
}
}
+21 -13
View File
@@ -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' },
);
}
// -------------------------------------------------------------------------
+17
View File
@@ -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=');
});
});
+60
View File
@@ -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');
});
});
+58 -3
View File
@@ -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' });
}
});
});
+47 -1
View File
@@ -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);
});
});
+16 -1
View File
@@ -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);
+9
View File
@@ -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(),