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:
@@ -5,6 +5,7 @@ import { MatrixId } from './MatrixId';
|
|||||||
import { Profile } from './Profile';
|
import { Profile } from './Profile';
|
||||||
import { ContactInformation } from './ContactInfo';
|
import { ContactInformation } from './ContactInfo';
|
||||||
import { IgnoredUserList } from './IgnoredUserList';
|
import { IgnoredUserList } from './IgnoredUserList';
|
||||||
|
import { OidcManageAccount } from './OidcManageAccount';
|
||||||
|
|
||||||
type AccountProps = {
|
type AccountProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -32,6 +33,7 @@ export function Account({ requestClose }: AccountProps) {
|
|||||||
<Box direction="Column" gap="700">
|
<Box direction="Column" gap="700">
|
||||||
<Profile />
|
<Profile />
|
||||||
<MatrixId />
|
<MatrixId />
|
||||||
|
<OidcManageAccount />
|
||||||
<ContactInformation />
|
<ContactInformation />
|
||||||
<IgnoredUserList />
|
<IgnoredUserList />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Account Management</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Manage account"
|
||||||
|
description="Your profile, password, and sessions are managed by your single sign-on provider."
|
||||||
|
after={
|
||||||
|
<Chip variant="Secondary" radii="Pill" onClick={open}>
|
||||||
|
<Text size="T200">Open</Text>
|
||||||
|
</Chip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,16 +2,11 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
|
|||||||
|
|
||||||
import { cryptoCallbacks } from './secretStorageKeys';
|
import { cryptoCallbacks } from './secretStorageKeys';
|
||||||
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
|
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';
|
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.
|
// 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).
|
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
|
||||||
export const IDB_VERSION_CONFLICT = 'IDB_VERSION_CONFLICT';
|
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');
|
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({
|
const mx = createClient({
|
||||||
baseUrl: session.baseUrl,
|
baseUrl: session.baseUrl,
|
||||||
accessToken: session.accessToken,
|
accessToken: session.accessToken,
|
||||||
|
refreshToken: session.refreshToken,
|
||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
store: indexedDBStore,
|
store: indexedDBStore,
|
||||||
cryptoStore: legacyCryptoStore,
|
cryptoStore: legacyCryptoStore,
|
||||||
@@ -35,6 +38,9 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
|
|||||||
timelineSupport: true,
|
timelineSupport: true,
|
||||||
cryptoCallbacks: cryptoCallbacks as any,
|
cryptoCallbacks: cryptoCallbacks as any,
|
||||||
verificationMethods: ['m.sas.v1'],
|
verificationMethods: ['m.sas.v1'],
|
||||||
|
tokenRefreshFunction: oidcRefresher
|
||||||
|
? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -70,6 +76,11 @@ export const clearCacheAndReload = async (mx: MatrixClient) => {
|
|||||||
export const logoutClient = async (mx: MatrixClient) => {
|
export const logoutClient = async (mx: MatrixClient) => {
|
||||||
pushSessionToSW();
|
pushSessionToSW();
|
||||||
mx.stopClient();
|
mx.stopClient();
|
||||||
|
// For OIDC sessions, revoke the tokens at the issuer too (best-effort).
|
||||||
|
const session = getFallbackSession();
|
||||||
|
if (session?.oidc) {
|
||||||
|
await revokeOidcTokens(session);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await mx.logout();
|
await mx.logout();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user