feat(auth): OIDC phase 4a — session persistence for refresh/expiry/oidc metadata
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown> | 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user