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,
+ });
+ }
+}