Files
cinny/src/app/pages/auth/oidc/oidcState.ts
T
Lotus CIandClaude Opus 5.5 70206d6033
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m24s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m50s
chore(deps): matrix-js-sdk 41.7 -> 44.0 (#201)
Picks up three releases of MatrixRTC / delayed-event fixes and
matrix-sdk-crypto-wasm 18.3.1 -> 18.9.0.

SDK 43 moved OAuth (MSC3861) into the SDK and dropped oidc-client-ts, so
the native-OAuth sign-in is ported:
- login discovers the auth server via /auth_metadata, registers with
  OAuth2.registerClient, and keeps the PKCE verifier + device in its own
  sessionStorage entry keyed by `state` (single use; CSRF check);
- refresh is done by the SDK (oauthClientId + onTokenRefresh); the #16
  cross-tab protection is kept by a narrow guard on refresh-token grants
  (the SDK calls the global fetch for OAuth, not fetchFn), which
  serialises them with a Web Lock and adopts a sibling tab's tokens;
- logout and forced logout revoke via mx.logout() (the SDK revokes at
  the auth server for OAuth sessions);
- the callback page parsed its params on every render, re-running the
  single-use code exchange in a loop; parsed once now.

Also: notification_type is deprecated in MSC4075 (ring in DMs, notify
elsewhere when omitted), _onRTCSessionMemberUpdate ->
ensureRecalculateSessionMembers, precache limit 6 -> 10 MB for the
7.8 MB crypto wasm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 00:32:13 -04:00

157 lines
6.0 KiB
TypeScript

// 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<string, RegEntry>;
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<string, unknown>;
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;