From 67bd05fc96defeb3d48234ecc4c13f605e680de3 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 30 Jun 2026 16:12:13 -0400 Subject: [PATCH] =?UTF-8?q?feat(auth):=20OIDC=20phase=204/5/6=20=E2=80=94?= =?UTF-8?q?=20token=20refresh,=20logout=20revocation,=20account=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - initMatrix.ts: import the shared Session type; when a session has a refresh token + oidc metadata, wire a LotusOidcTokenRefresher via createClient's refreshToken + tokenRefreshFunction (reactive 401 refresh). Rust crypto is unaffected (still keyed on userId/deviceId). - client/oidcTokenRefresher.ts: OidcTokenRefresher subclass that persists rotated tokens back to the fallback session. - client/oidcLogout.ts + logoutClient: best-effort revoke access+refresh tokens at the issuer's revocation_endpoint on logout (tolerant of failure). - settings/account/OidcManageAccount.tsx: MSC2965 "Manage account" deep-link, shown only when authMetadata is present (OIDC servers); mirrors OtherDevices. Co-Authored-By: Claude Opus 4.8 --- src/app/features/settings/account/Account.tsx | 2 + .../settings/account/OidcManageAccount.tsx | 49 +++++++++++++++++++ src/client/initMatrix.ts | 27 +++++++--- src/client/oidcLogout.ts | 29 +++++++++++ src/client/oidcTokenRefresher.ts | 42 ++++++++++++++++ 5 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 src/app/features/settings/account/OidcManageAccount.tsx create mode 100644 src/client/oidcLogout.ts create mode 100644 src/client/oidcTokenRefresher.ts diff --git a/src/app/features/settings/account/Account.tsx b/src/app/features/settings/account/Account.tsx index bf3e60324..06f814e4e 100644 --- a/src/app/features/settings/account/Account.tsx +++ b/src/app/features/settings/account/Account.tsx @@ -5,6 +5,7 @@ import { MatrixId } from './MatrixId'; import { Profile } from './Profile'; import { ContactInformation } from './ContactInfo'; import { IgnoredUserList } from './IgnoredUserList'; +import { OidcManageAccount } from './OidcManageAccount'; type AccountProps = { requestClose: () => void; @@ -32,6 +33,7 @@ export function Account({ requestClose }: AccountProps) { + diff --git a/src/app/features/settings/account/OidcManageAccount.tsx b/src/app/features/settings/account/OidcManageAccount.tsx new file mode 100644 index 000000000..1fb3dbff0 --- /dev/null +++ b/src/app/features/settings/account/OidcManageAccount.tsx @@ -0,0 +1,49 @@ +import React, { useCallback } from 'react'; +import { Box, Chip, Text } from 'folds'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { SettingTile } from '../../../components/setting-tile'; +import { useAuthMetadata } from '../../../hooks/useAuthMetadata'; +import { useAccountManagementActions } from '../../../hooks/useAccountManagement'; +import { withSearchParam } from '../../../pages/pathUtils'; + +/** + * On OIDC/next-gen-auth servers, profile/password/sessions are managed by the + * authentication service — surface a deep-link to its account page (MSC2965 + * account-management URL). Renders nothing on password/legacy-SSO servers, where + * `useAuthMetadata()` is undefined. + */ +export function OidcManageAccount() { + const authMetadata = useAuthMetadata(); + const accountManagementActions = useAccountManagementActions(); + + const open = useCallback(() => { + const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer; + if (!authUrl) return; + window.open(withSearchParam(authUrl, { action: accountManagementActions.profile }), '_blank'); + }, [authMetadata, accountManagementActions]); + + if (!authMetadata) return null; + + return ( + + Account Management + + + Open + + } + /> + + + ); +} diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 50190bfbc..9b27c1642 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -2,16 +2,11 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; -import { removeFallbackSession } from '../app/state/sessions'; +import { getFallbackSession, removeFallbackSession, Session } from '../app/state/sessions'; +import { LotusOidcTokenRefresher } from './oidcTokenRefresher'; +import { revokeOidcTokens } from './oidcLogout'; import { pushSessionToSW } from '../sw-session'; -type Session = { - baseUrl: string; - accessToken: string; - userId: string; - deviceId: string; -}; - // Thrown when the local IndexedDB has a higher schema version than this SDK expects. // This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted). export const IDB_VERSION_CONFLICT = 'IDB_VERSION_CONFLICT'; @@ -25,9 +20,17 @@ export const initClient = async (session: Session): Promise => { const legacyCryptoStore = new IndexedDBCryptoStore(globalThis.indexedDB, 'crypto-store'); + // OIDC/next-gen-auth sessions carry a refresh token; wire automatic refresh + // (the client calls this reactively on a 401) and persist rotated tokens. + const oidcRefresher = + session.refreshToken && session.oidc + ? new LotusOidcTokenRefresher(session.oidc, session.deviceId, session.userId, session.baseUrl) + : undefined; + const mx = createClient({ baseUrl: session.baseUrl, accessToken: session.accessToken, + refreshToken: session.refreshToken, userId: session.userId, store: indexedDBStore, cryptoStore: legacyCryptoStore, @@ -35,6 +38,9 @@ export const initClient = async (session: Session): Promise => { timelineSupport: true, cryptoCallbacks: cryptoCallbacks as any, verificationMethods: ['m.sas.v1'], + tokenRefreshFunction: oidcRefresher + ? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken) + : undefined, }); try { @@ -70,6 +76,11 @@ export const clearCacheAndReload = async (mx: MatrixClient) => { export const logoutClient = async (mx: MatrixClient) => { pushSessionToSW(); mx.stopClient(); + // For OIDC sessions, revoke the tokens at the issuer too (best-effort). + const session = getFallbackSession(); + if (session?.oidc) { + await revokeOidcTokens(session); + } try { await mx.logout(); } catch { diff --git a/src/client/oidcLogout.ts b/src/client/oidcLogout.ts new file mode 100644 index 000000000..c137a45b6 --- /dev/null +++ b/src/client/oidcLogout.ts @@ -0,0 +1,29 @@ +import { discoverAndValidateOIDCIssuerWellKnown } from 'matrix-js-sdk'; +import { Session } from '../app/state/sessions'; + +/** + * Best-effort revoke the OIDC access + refresh tokens at the issuer's revocation + * endpoint during logout. Tolerant of any failure — logout proceeds regardless + * (the local session is cleared by the caller either way). + */ +export const revokeOidcTokens = async (session: Session): Promise => { + if (!session.oidc) return; + try { + const config = await discoverAndValidateOIDCIssuerWellKnown(session.oidc.issuer); + const endpoint = config.revocation_endpoint; + if (!endpoint) return; + const { clientId } = session.oidc; + const revoke = (token: string, hint: string): Promise => + fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token, token_type_hint: hint, client_id: clientId }), + }); + const requests: Promise[] = []; + if (session.refreshToken) requests.push(revoke(session.refreshToken, 'refresh_token')); + if (session.accessToken) requests.push(revoke(session.accessToken, 'access_token')); + await Promise.allSettled(requests); + } catch { + /* issuer unreachable / no revocation endpoint — ignore */ + } +}; diff --git a/src/client/oidcTokenRefresher.ts b/src/client/oidcTokenRefresher.ts new file mode 100644 index 000000000..fe5f74b72 --- /dev/null +++ b/src/client/oidcTokenRefresher.ts @@ -0,0 +1,42 @@ +import { OidcTokenRefresher } from 'matrix-js-sdk'; +import type { IdTokenClaims } from 'oidc-client-ts'; +import { OidcSessionMeta, setFallbackSession } from '../app/state/sessions'; + +/** + * OidcTokenRefresher that persists rotated tokens back to the fallback session, + * so a page reload keeps the freshest access/refresh token. The matrix client + * calls this automatically (reactively on a 401) when a refresh token is set. + */ +export class LotusOidcTokenRefresher extends OidcTokenRefresher { + private readonly deviceIdRef: string; + + private readonly userIdRef: string; + + private readonly baseUrlRef: string; + + private readonly oidcRef: OidcSessionMeta; + + constructor(oidc: OidcSessionMeta, deviceId: string, userId: string, baseUrl: string) { + super( + oidc.issuer, + oidc.clientId, + oidc.redirectUri, + deviceId, + (oidc.idTokenClaims ?? {}) as unknown as IdTokenClaims, + ); + this.deviceIdRef = deviceId; + this.userIdRef = userId; + this.baseUrlRef = baseUrl; + this.oidcRef = oidc; + } + + protected async persistTokens(tokens: { + accessToken: string; + refreshToken?: string; + }): Promise { + setFallbackSession(tokens.accessToken, this.deviceIdRef, this.userIdRef, this.baseUrlRef, { + refreshToken: tokens.refreshToken, + oidc: this.oidcRef, + }); + } +}