feat(auth): OIDC phase 4/5/6 — token refresh, logout revocation, account link

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 16:12:13 -04:00
parent dd6b0bccb3
commit 67bd05fc96
5 changed files with 141 additions and 8 deletions
+19 -8
View File
@@ -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<MatrixClient> => {
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<MatrixClient> => {
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 {
+29
View File
@@ -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<void> => {
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<Response> =>
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<Response>[] = [];
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 */
}
};
+42
View File
@@ -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<void> {
setFallbackSession(tokens.accessToken, this.deviceIdRef, this.userIdRef, this.baseUrlRef, {
refreshToken: tokens.refreshToken,
oidc: this.oidcRef,
});
}
}