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
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
157 lines
6.0 KiB
TypeScript
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;
|