From d3d2f9a4489c1d095a74cf2dcfef3bc3d94383c0 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 30 Jun 2026 15:55:30 -0400 Subject: [PATCH] =?UTF-8?q?feat(auth):=20OIDC=20phase=204a=20=E2=80=94=20s?= =?UTF-8?q?ession=20persistence=20for=20refresh/expiry/oidc=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setFallbackSession gains an optional `extra` arg (password call sites unchanged) persisting cinny_refresh_token, cinny_expires_at (absolute), and cinny_oidc_{issuer,client_id,redirect_uri,id_token_claims}. getFallbackSession reads them back (expiry as remaining lifetime); removeFallbackSession + re-save clear stale OIDC keys. Session type gains `oidc?: OidcSessionMeta`. +2 tests. Co-Authored-By: Claude Opus 4.8 --- src/app/state/sessions.test.ts | 42 ++++++++++++++++++ src/app/state/sessions.ts | 79 ++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/src/app/state/sessions.test.ts b/src/app/state/sessions.test.ts index b4b6e00f8..927872d5f 100644 --- a/src/app/state/sessions.test.ts +++ b/src/app/state/sessions.test.ts @@ -72,3 +72,45 @@ test('removeFallbackSession clears all keys', () => { assert.equal(store.size, 0); assert.equal(getFallbackSession(), undefined); }); + +test('round-trips an OIDC session (refresh token, expiry, oidc metadata)', () => { + installStorage(); + setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://matrix-client.mozilla.org', { + refreshToken: 'refresh-xyz', + expiresInMs: 3_600_000, + oidc: { + issuer: 'https://chat.mozilla.org/', + clientId: 'client-123', + redirectUri: 'https://chat.lotusguild.org/auth/oidc/callback', + idTokenClaims: { sub: '@bob:mozilla.org', aud: 'client-123' }, + }, + }); + + const s = getFallbackSession(); + assert.ok(s); + assert.equal(s.refreshToken, 'refresh-xyz'); + // stored as absolute expiry, read back as remaining lifetime + assert.ok(s.expiresInMs! > 0 && s.expiresInMs! <= 3_600_000); + assert.deepEqual(s.oidc, { + issuer: 'https://chat.mozilla.org/', + clientId: 'client-123', + redirectUri: 'https://chat.lotusguild.org/auth/oidc/callback', + idTokenClaims: { sub: '@bob:mozilla.org', aud: 'client-123' }, + }); +}); + +test('a password session carries no OIDC fields, and re-saving clears stale OIDC keys', () => { + installStorage(); + // first an OIDC session... + setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', { + refreshToken: 'r', + oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' }, + }); + assert.ok(getFallbackSession()?.oidc); + // ...overwritten by a plain password session must drop the OIDC state + setFallbackSession('tok2', 'DEV', '@alice:example.org', 'https://hs'); + const s = getFallbackSession(); + assert.ok(s); + assert.equal(s.oidc, undefined); + assert.equal(s.refreshToken, undefined); +}); diff --git a/src/app/state/sessions.ts b/src/app/state/sessions.ts index 33e4070e8..21c1a474c 100644 --- a/src/app/state/sessions.ts +++ b/src/app/state/sessions.ts @@ -5,6 +5,16 @@ // setLocalStorageItem, // } from './utils/atomWithLocalStorage'; +// OIDC/next-gen-auth (MSC3861) metadata needed to refresh + revoke tokens. Kept +// as a generic claims object so this core module stays decoupled from +// oidc-client-ts (the refresher casts idTokenClaims to IdTokenClaims). +export type OidcSessionMeta = { + issuer: string; + clientId: string; + redirectUri: string; + idTokenClaims?: Record; +}; + export type Session = { baseUrl: string; userId: string; @@ -13,6 +23,23 @@ export type Session = { expiresInMs?: number; refreshToken?: string; fallbackSdkStores?: boolean; + oidc?: OidcSessionMeta; +}; + +// OIDC-only localStorage keys (absent for password/legacy-SSO sessions). +const OIDC_KEYS = { + refreshToken: 'cinny_refresh_token', + expiresAt: 'cinny_expires_at', + issuer: 'cinny_oidc_issuer', + clientId: 'cinny_oidc_client_id', + redirectUri: 'cinny_oidc_redirect_uri', + idTokenClaims: 'cinny_oidc_id_token_claims', +} as const; + +export type FallbackSessionExtra = { + refreshToken?: string; + expiresInMs?: number; + oidc?: OidcSessionMeta; }; export type Sessions = Session[]; @@ -34,17 +61,43 @@ export function setFallbackSession( deviceId: string, userId: string, baseUrl: string, + extra?: FallbackSessionExtra, ) { localStorage.setItem('cinny_access_token', accessToken); localStorage.setItem('cinny_device_id', deviceId); localStorage.setItem('cinny_user_id', userId); localStorage.setItem('cinny_hs_base_url', baseUrl); + + // OIDC fields — written only when present; otherwise cleared so a password + // session never carries stale OIDC state. + if (extra?.refreshToken) localStorage.setItem(OIDC_KEYS.refreshToken, extra.refreshToken); + else localStorage.removeItem(OIDC_KEYS.refreshToken); + + if (typeof extra?.expiresInMs === 'number') { + // Store ABSOLUTE expiry to avoid drift across reloads. + localStorage.setItem(OIDC_KEYS.expiresAt, String(Date.now() + extra.expiresInMs)); + } else localStorage.removeItem(OIDC_KEYS.expiresAt); + + if (extra?.oidc) { + localStorage.setItem(OIDC_KEYS.issuer, extra.oidc.issuer); + localStorage.setItem(OIDC_KEYS.clientId, extra.oidc.clientId); + localStorage.setItem(OIDC_KEYS.redirectUri, extra.oidc.redirectUri); + if (extra.oidc.idTokenClaims) { + localStorage.setItem(OIDC_KEYS.idTokenClaims, JSON.stringify(extra.oidc.idTokenClaims)); + } else localStorage.removeItem(OIDC_KEYS.idTokenClaims); + } else { + localStorage.removeItem(OIDC_KEYS.issuer); + localStorage.removeItem(OIDC_KEYS.clientId); + localStorage.removeItem(OIDC_KEYS.redirectUri); + localStorage.removeItem(OIDC_KEYS.idTokenClaims); + } } export const removeFallbackSession = () => { localStorage.removeItem('cinny_hs_base_url'); localStorage.removeItem('cinny_user_id'); localStorage.removeItem('cinny_device_id'); localStorage.removeItem('cinny_access_token'); + Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key)); }; export const getFallbackSession = (): Session | undefined => { const baseUrl = localStorage.getItem('cinny_hs_base_url'); @@ -61,6 +114,32 @@ export const getFallbackSession = (): Session | undefined => { fallbackSdkStores: true, }; + const refreshToken = localStorage.getItem(OIDC_KEYS.refreshToken); + if (refreshToken) session.refreshToken = refreshToken; + + const expiresAtRaw = localStorage.getItem(OIDC_KEYS.expiresAt); + if (expiresAtRaw) { + const expiresAt = Number(expiresAtRaw); + // Expose the REMAINING lifetime (clamped at 0); the SDK refreshes on 401. + if (Number.isFinite(expiresAt)) session.expiresInMs = Math.max(0, expiresAt - Date.now()); + } + + const issuer = localStorage.getItem(OIDC_KEYS.issuer); + const clientId = localStorage.getItem(OIDC_KEYS.clientId); + const redirectUri = localStorage.getItem(OIDC_KEYS.redirectUri); + if (issuer && clientId && redirectUri) { + let idTokenClaims: Record | undefined; + const claimsRaw = localStorage.getItem(OIDC_KEYS.idTokenClaims); + if (claimsRaw) { + try { + idTokenClaims = JSON.parse(claimsRaw); + } catch { + /* corrupt claims — ignore, the refresher will re-validate on use */ + } + } + session.oidc = { issuer, clientId, redirectUri, idTokenClaims }; + } + return session; }