// Pure OIDC-login state helpers (no imports) so they're unit-testable under the // tsx + node:test harness. The SDK-driven flow lives in oidcLoginUtil.ts. /** * Dynamic-registration cache. MAS issues a `client_id` per dynamic registration; * caching by issuer (scoped to the current redirectUri) avoids re-registering on * every login. Invalidated when the redirectUri changes or the provider later * rejects the cached id (`invalid_client`). */ const REG_CACHE_KEY = 'cinny_oidc_dynamic_clients'; type RegEntry = { clientId: string; redirectUri: string }; type RegCache = Record; const readRegCache = (): RegCache => { try { const raw = localStorage.getItem(REG_CACHE_KEY); return raw ? (JSON.parse(raw) as RegCache) : {}; } catch { return {}; } }; const writeRegCache = (cache: RegCache): void => { localStorage.setItem(REG_CACHE_KEY, JSON.stringify(cache)); }; export const getCachedClientId = (issuer: string, redirectUri: string): string | undefined => { const entry = readRegCache()[issuer]; return entry && entry.redirectUri === redirectUri ? entry.clientId : undefined; }; export const cacheClientId = (issuer: string, clientId: string, redirectUri: string): void => { const cache = readRegCache(); cache[issuer] = { clientId, redirectUri }; writeRegCache(cache); }; export const invalidateCachedClient = (issuer: string): void => { const cache = readRegCache(); if (cache[issuer]) { delete cache[issuer]; writeRegCache(cache); } }; // OAuth error codes that mean the provider no longer recognises our client id // (deleted/expired dynamic registration, or one issued by a different deployment). const STALE_CLIENT_ERROR_CODES = new Set(['invalid_client', 'unauthorized_client']); /** * #67 / #102 — pure: does an OIDC failure indicate the CACHED client id is * bad? Only then is dropping the registration cache justified; a transient * network error, an offline discovery fetch, or a local (`crypto.randomUUID`) * failure must leave it alone, otherwise every retry performs a fresh dynamic * registration and piles throwaway clients onto the provider. Recognised * shapes: an OAuth error response (`{ error: 'invalid_client' }`, as thrown by * oidc-client-ts `ErrorResponse`, OR as echoed back on the callback redirect's * `?error=` query param via {@link OidcCallbackParams}), or an HTTP 400/401 * carried as `httpStatus`/`status` (MatrixError-style) from the * registration/authorize step. */ export const isStaleClientError = (e: unknown): boolean => { if (!e || typeof e !== 'object') return false; const { error, httpStatus, status } = e as { error?: unknown; httpStatus?: unknown; status?: unknown; }; if (typeof error === 'string' && STALE_CLIENT_ERROR_CODES.has(error)) return true; const code = typeof httpStatus === 'number' ? httpStatus : status; return code === 400 || code === 401; }; /** Parsed shape of the provider's redirect back to our callback URL. */ export type OidcCallbackParams = | { kind: 'success'; code: string; state: string } | { kind: 'error'; error: string; errorDescription?: string; state?: string } | { kind: 'invalid' }; /** Pure: classify the callback query string into success / error / invalid. */ export const parseOidcCallbackParams = (search: string): OidcCallbackParams => { const params = new URLSearchParams(search); const error = params.get('error'); if (error) { return { kind: 'error', error, errorDescription: params.get('error_description') ?? undefined, // OAuth error redirects echo back the `state` we sent, same as a // success redirect would — needed to look up the pending sign-in's // issuer (see readStoredOidcIssuer) when the client id itself was // rejected. state: params.get('state') ?? undefined, }; } const code = params.get('code'); const state = params.get('state'); if (code && state) return { kind: 'success', code, state }; return { kind: 'invalid' }; }; /** * [Gitea #201, SDK 44] The app keeps a pending OAuth sign-in itself (the SDK's * OAuth2 class no longer persists anything). Saved in sessionStorage right * before the redirect, keyed by the `state` we send, which the provider echoes * back: a callback whose state has no entry is rejected (CSRF), and an entry is * used once. Holds the PKCE code verifier, so it never goes to localStorage. */ export type PendingOidcLogin = { issuer: string; clientId: string; deviceId: string; codeVerifier: string; homeserverUrl: string; redirectUri: string; }; const PENDING_PREFIX = 'lotus_oidc_pending_'; const isPending = (v: unknown): v is PendingOidcLogin => { if (!v || typeof v !== 'object') return false; const p = v as Record; return ['issuer', 'clientId', 'deviceId', 'codeVerifier', 'homeserverUrl', 'redirectUri'].every( (k) => typeof p[k] === 'string' && (p[k] as string).length > 0, ); }; export const savePendingOidcLogin = (state: string, pending: PendingOidcLogin): void => { sessionStorage.setItem(`${PENDING_PREFIX}${state}`, JSON.stringify(pending)); }; /** Read the pending sign-in for `state` without consuming it. */ export const readPendingOidcLogin = (state: string): PendingOidcLogin | undefined => { try { const raw = sessionStorage.getItem(`${PENDING_PREFIX}${state}`); if (!raw) return undefined; const parsed: unknown = JSON.parse(raw); return isPending(parsed) ? parsed : undefined; } catch { return undefined; } }; /** Read and remove: an authorization code can only be redeemed once anyway. */ export const takePendingOidcLogin = (state: string): PendingOidcLogin | undefined => { const pending = readPendingOidcLogin(state); try { sessionStorage.removeItem(`${PENDING_PREFIX}${state}`); } catch { // storage unavailable — nothing to clean up } return pending; }; /** * #102 — on the callback's error branch, the pending sign-in's issuer tells * us whose cached client id to evict when the provider rejected it. */ export const readStoredOidcIssuer = (state: string): string | undefined => readPendingOidcLogin(state)?.issuer;