From a4cdd156c9c2124e2d02d259d353cc0a4b82f0af Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 13 Jul 2026 15:14:15 +0200 Subject: [PATCH] Add Native OIDC (OAuth 2.0) login --- .changeset/add-native-oidc-login.md | 5 + src/app/components/AuthFlowsLoader.tsx | 18 ++- src/app/components/LogoutDialog.tsx | 18 ++- src/app/features/settings/general/General.tsx | 8 +- src/app/hooks/useAuthFlows.ts | 8 +- src/app/hooks/useParsedLoginFlows.test.ts | 26 ++++ src/app/hooks/useParsedLoginFlows.ts | 7 + src/app/hooks/usePathWithOrigin.ts | 13 +- src/app/pages/Router.tsx | 6 +- src/app/pages/auth/login/Login.tsx | 74 +++++++--- src/app/pages/auth/login/OidcLogin.tsx | 134 ++++++++++++++++++ src/app/pages/auth/login/loginUtil.ts | 39 +++-- .../pages/auth/login/oidcLoginUtil.test.ts | 42 ++++++ src/app/pages/auth/login/oidcLoginUtil.ts | 118 +++++++++++++++ src/app/pages/auth/register/Register.tsx | 32 ++++- .../pages/client/BackgroundNotifications.tsx | 8 ++ src/app/state/sessions.ts | 46 ++++++ src/app/state/sessions.updateTokens.test.ts | 85 +++++++++++ src/app/utils/sentryScrubbers.test.ts | 20 +++ src/app/utils/sentryScrubbers.ts | 3 + src/client/initMatrix.ts | 51 ++++++- src/client/oidcTokenRefresher.ts | 53 +++++++ src/types/matrix-sdk.ts | 9 +- 23 files changed, 770 insertions(+), 53 deletions(-) create mode 100644 .changeset/add-native-oidc-login.md create mode 100644 src/app/hooks/useParsedLoginFlows.test.ts create mode 100644 src/app/pages/auth/login/OidcLogin.tsx create mode 100644 src/app/pages/auth/login/oidcLoginUtil.test.ts create mode 100644 src/app/pages/auth/login/oidcLoginUtil.ts create mode 100644 src/app/state/sessions.updateTokens.test.ts create mode 100644 src/client/oidcTokenRefresher.ts diff --git a/.changeset/add-native-oidc-login.md b/.changeset/add-native-oidc-login.md new file mode 100644 index 0000000000..557ee65626 --- /dev/null +++ b/.changeset/add-native-oidc-login.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add Native OIDC (OAuth 2.0) login so you can sign in to and register on homeservers that use delegated authentication, such as continuwuity and Synapse with the Matrix Authentication Service. Sessions stay signed in through automatic token refresh, and signing out revokes the tokens on the server. diff --git a/src/app/components/AuthFlowsLoader.tsx b/src/app/components/AuthFlowsLoader.tsx index 28478acf57..b25ff14244 100644 --- a/src/app/components/AuthFlowsLoader.tsx +++ b/src/app/components/AuthFlowsLoader.tsx @@ -21,9 +21,14 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr const [state, load] = useAsyncCallback( useCallback(async () => { - const result = await Promise.allSettled([mx.loginFlows(), mx.registerRequest({})]); + const result = await Promise.allSettled([ + mx.loginFlows(), + mx.registerRequest({}), + mx.getAuthMetadata(), + ]); const loginFlows = promiseFulfilledResult(result[0]); const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined; + const authMetadata = promiseFulfilledResult(result[2]); let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest, }; @@ -32,16 +37,17 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr registerFlows = parseRegisterErrResp(registerResp); } - if (!loginFlows) { + const validLoginFlows = loginFlows && !('errcode' in loginFlows) ? loginFlows : undefined; + + // OIDC-only servers reject GET /login; that is fine when the OAuth 2.0 API is available. + if (!validLoginFlows && !authMetadata) { throw new Error('Missing auth flow!'); } - if ('errcode' in loginFlows) { - throw new Error('Failed to load auth flow!'); - } const authFlows: AuthFlows = { - loginFlows, + loginFlows: validLoginFlows ?? { flows: [] }, registerFlows, + authMetadata, }; return authFlows; diff --git a/src/app/components/LogoutDialog.tsx b/src/app/components/LogoutDialog.tsx index c1cf9d1ce5..1a8aa52a04 100644 --- a/src/app/components/LogoutDialog.tsx +++ b/src/app/components/LogoutDialog.tsx @@ -1,6 +1,8 @@ import { forwardRef, useCallback } from 'react'; import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds'; +import { useAtomValue, useSetAtom } from 'jotai'; import { logoutClient } from '$client/initMatrix'; +import { activeSessionIdAtom, sessionsAtom } from '$state/sessions'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useCrossSigningActive } from '$hooks/useCrossSigning'; @@ -16,6 +18,11 @@ type LogoutDialogProps = { export const LogoutDialog = forwardRef( ({ handleClose }, ref) => { const mx = useMatrixClient(); + const sessions = useAtomValue(sessionsAtom); + const activeSessionId = useAtomValue(activeSessionIdAtom); + const setSessions = useSetAtom(sessionsAtom); + const setActiveSessionId = useSetAtom(activeSessionIdAtom); + const activeSession = sessions.find((s) => s.userId === activeSessionId) ?? sessions[0]; const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent()); const crossSigningActive = useCrossSigningActive(); const verificationStatus = useDeviceVerificationStatus( @@ -26,8 +33,15 @@ export const LogoutDialog = forwardRef( const [logoutState, logout] = useAsyncCallback( useCallback(async () => { - await logoutClient(mx); - }, [mx]) + await logoutClient(mx, activeSession); + if (activeSession) { + setSessions({ type: 'DELETE', session: activeSession }); + setActiveSessionId( + sessions.find((s) => s.userId !== activeSession.userId)?.userId ?? undefined + ); + } + window.location.reload(); + }, [mx, activeSession, sessions, setSessions, setActiveSessionId]) ); const ongoingLogout = logoutState.status === AsyncStatus.Loading; diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index a3fef67ac7..36a7c6d771 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -1393,11 +1393,9 @@ export function Sync() { const handleSetSlidingSync = (value: boolean) => { if (!activeSession) return; setSessions({ - type: 'PUT', - session: { - ...activeSession, - slidingSyncOptIn: value, - }, + type: 'UPDATE', + userId: activeSession.userId, + patch: { slidingSyncOptIn: value }, }); window.location.reload(); }; diff --git a/src/app/hooks/useAuthFlows.ts b/src/app/hooks/useAuthFlows.ts index 03af83c880..c046d86122 100644 --- a/src/app/hooks/useAuthFlows.ts +++ b/src/app/hooks/useAuthFlows.ts @@ -1,5 +1,10 @@ import { createContext, useContext } from 'react'; -import type { IAuthData, MatrixError, ILoginFlowsResponse } from '$types/matrix-sdk'; +import type { + IAuthData, + MatrixError, + ILoginFlowsResponse, + OidcClientConfig, +} from '$types/matrix-sdk'; export enum RegisterFlowStatus { FlowRequired = 401, @@ -43,6 +48,7 @@ export const parseRegisterErrResp = (matrixError: MatrixError): RegisterFlowsRes export type AuthFlows = { loginFlows: ILoginFlowsResponse; registerFlows: RegisterFlowsResponse; + authMetadata?: OidcClientConfig; }; const AuthFlowsContext = createContext(null); diff --git a/src/app/hooks/useParsedLoginFlows.test.ts b/src/app/hooks/useParsedLoginFlows.test.ts new file mode 100644 index 0000000000..913718a069 --- /dev/null +++ b/src/app/hooks/useParsedLoginFlows.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import type { ISSOFlow } from '$types/matrix-sdk'; +import { isOauthAwarePreferred } from './useParsedLoginFlows'; + +describe('isOauthAwarePreferred', () => { + it('is false for an undefined flow', () => { + expect(isOauthAwarePreferred(undefined)).toBe(false); + }); + + it('is false for a plain SSO flow', () => { + expect(isOauthAwarePreferred({ type: 'm.login.sso' } as ISSOFlow)).toBe(false); + }); + + it('reads the stable oauth_aware_preferred field', () => { + const flow = { type: 'm.login.sso', oauth_aware_preferred: true } as ISSOFlow; + expect(isOauthAwarePreferred(flow)).toBe(true); + }); + + it('reads the deprecated msc3824 field', () => { + const flow = { + type: 'm.login.sso', + 'org.matrix.msc3824.delegated_oidc_compatibility': true, + } as ISSOFlow; + expect(isOauthAwarePreferred(flow)).toBe(true); + }); +}); diff --git a/src/app/hooks/useParsedLoginFlows.ts b/src/app/hooks/useParsedLoginFlows.ts index f62a392493..55a9f66faf 100644 --- a/src/app/hooks/useParsedLoginFlows.ts +++ b/src/app/hooks/useParsedLoginFlows.ts @@ -1,11 +1,18 @@ import { useMemo } from 'react'; import type { ILoginFlow, IPasswordFlow, ISSOFlow, LoginFlow } from '$types/matrix-sdk'; +import { OAUTH_AWARE_PREFERRED_FLOW_FIELD } from '$types/matrix-sdk'; export const getSSOFlow = (loginFlows: LoginFlow[]): ISSOFlow | undefined => loginFlows.find((flow) => flow.type === 'm.login.sso' || flow.type === 'm.login.cas') as | ISSOFlow | undefined; +export const isOauthAwarePreferred = (ssoFlow: ISSOFlow | undefined): boolean => + Boolean( + ssoFlow?.[OAUTH_AWARE_PREFERRED_FLOW_FIELD.name] ?? + ssoFlow?.[OAUTH_AWARE_PREFERRED_FLOW_FIELD.altName] + ); + export const getPasswordFlow = (loginFlows: LoginFlow[]): IPasswordFlow | undefined => loginFlows.find((flow) => flow.type === 'm.login.password') as IPasswordFlow; export const getTokenFlow = (loginFlows: LoginFlow[]): LoginFlow | undefined => diff --git a/src/app/hooks/usePathWithOrigin.ts b/src/app/hooks/usePathWithOrigin.ts index 956153dcfe..4f28ac0ca6 100644 --- a/src/app/hooks/usePathWithOrigin.ts +++ b/src/app/hooks/usePathWithOrigin.ts @@ -2,9 +2,16 @@ import { useMemo } from 'react'; import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '$utils/common'; import { useClientConfig } from './useClientConfig'; -export const usePathWithOrigin = (path: string): string => { +export const usePathWithOrigin = ( + path: string, + options?: { + /** OAuth redirect URIs must not contain a fragment (RFC 6749). */ + ignoreHashRouter?: boolean; + } +): string => { const { hashRouter } = useClientConfig(); const { origin } = window.location; + const ignoreHashRouter = options?.ignoreHashRouter ?? false; const pathWithOrigin = useMemo(() => { let url: string = trimSlash(origin); @@ -12,7 +19,7 @@ export const usePathWithOrigin = (path: string): string => { url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`; url = trimTrailingSlash(url); - if (hashRouter?.enabled) { + if (hashRouter?.enabled && !ignoreHashRouter) { url += `/#/${trimSlash(hashRouter.basename ?? '')}`; url = trimTrailingSlash(url); } @@ -20,7 +27,7 @@ export const usePathWithOrigin = (path: string): string => { url += `/${trimLeadingSlash(path)}`; return url; - }, [path, hashRouter, origin]); + }, [path, hashRouter, origin, ignoreHashRouter]); return pathWithOrigin; }; diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index e52b07647f..fa1444ce2b 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -126,10 +126,14 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) /> { - // Allow reaching the login page with ?addAccount=1 even when already logged in + // Allow reaching the login page even when already logged in: + // - ?addAccount=1 to add another account (multi-account) + // - ?loginToken=... (delegated / SSO login token) + // - ?code=...&state=... (OIDC authorization-code callback, e.g. adding a second account) const url = new URL(request.url); if (url.searchParams.get('addAccount') === '1') return null; if (url.searchParams.has('loginToken')) return null; + if (url.searchParams.has('code') && url.searchParams.has('state')) return null; if (hasStoredSession()) return redirect(getHomePath()); return null; }} diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index 8598a25a04..c5b5b58422 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -4,7 +4,8 @@ import { Link, useSearchParams } from 'react-router-dom'; import { SSOAction } from '$types/matrix-sdk'; import { RegisterFlowStatus, useAuthFlows } from '$hooks/useAuthFlows'; import { useAuthServer } from '$hooks/useAuthServer'; -import { useParsedLoginFlows } from '$hooks/useParsedLoginFlows'; +import { useAutoDiscoveryInfo } from '$hooks/useAutoDiscoveryInfo'; +import { isOauthAwarePreferred, useParsedLoginFlows } from '$hooks/useParsedLoginFlows'; import { getLoginPath, getRegisterPath, withSearchParam } from '$pages/pathUtils'; import { usePathWithOrigin } from '$hooks/usePathWithOrigin'; import type { LoginPathSearchParams } from '$pages/paths'; @@ -13,15 +14,18 @@ import { SSOLogin } from '$pages/auth/SSOLogin'; import { OrDivider } from '$pages/auth/OrDivider'; import { PasswordLoginForm } from './PasswordLoginForm'; import { TokenLogin } from './TokenLogin'; +import { OidcLoginButton, OidcCallback } from './OidcLogin'; -const getLoginTokenSearchParam = () => { - // when using hasRouter query params in existing route - // gets ignored by react-router, so we need to read it ourself - // we only need to read loginToken as it's the only param that - // is provided by external entity. example: SSO login - const parmas = new URLSearchParams(window.location.search); - const loginToken = parmas.get('loginToken'); - return loginToken ?? undefined; +// react-router ignores the real query string in hashRouter mode, so read callback params direct. +const getExternalSearchParams = () => { + const params = new URLSearchParams(window.location.search); + return { + loginToken: params.get('loginToken') ?? undefined, + code: params.get('code') ?? undefined, + state: params.get('state') ?? undefined, + error: params.get('error') ?? undefined, + errorDescription: params.get('error_description') ?? undefined, + }; }; const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchParams => @@ -38,17 +42,21 @@ export function Login() { const server = useAuthServer(); const { hashRouter } = useClientConfig(); const { hideUsernamePasswordFields } = useClientConfig(); - const { loginFlows, registerFlows } = useAuthFlows(); + const { loginFlows, registerFlows, authMetadata } = useAuthFlows(); + const discovery = useAutoDiscoveryInfo(); + const baseUrl = discovery['m.homeserver'].base_url; const [searchParams] = useSearchParams(); const loginSearchParams = useLoginSearchParams(searchParams); const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server)); - const loginTokenForHashRouter = getLoginTokenSearchParam(); + const oidcRedirectUri = usePathWithOrigin(getLoginPath(server), { ignoreHashRouter: true }); + const external = getExternalSearchParams(); const absoluteLoginPath = usePathWithOrigin(getLoginPath(server)); - if (hashRouter?.enabled && loginTokenForHashRouter) { + if (hashRouter?.enabled && (external.loginToken || (external.code && external.state))) { window.location.replace( withSearchParam(absoluteLoginPath, { - loginToken: loginTokenForHashRouter, + ...(external.loginToken ? { loginToken: external.loginToken } : {}), + ...(external.code && external.state ? { code: external.code, state: external.state } : {}), }) ); } @@ -64,6 +72,22 @@ export function Login() { const registrationUnavailable = registerFlows.status === RegisterFlowStatus.RegistrationDisabled && !parsedFlows.sso; + const oidcCode = searchParams.get('code') ?? undefined; + const oidcState = searchParams.get('state') ?? undefined; + const oidcNotice = external.error + ? (external.errorDescription ?? `Sign-in was not completed (${external.error}).`) + : undefined; + + const showOidc = authMetadata !== undefined; + const hidePassword = + hideUsernamePasswordFields || (showOidc && isOauthAwarePreferred(parsedFlows.sso)); + const showPassword = !hidePassword && parsedFlows.password !== undefined; + const showLegacySso = !showOidc && parsedFlows.sso !== undefined; + + if (showOidc && oidcCode && oidcState) { + return ; + } + return ( @@ -72,28 +96,40 @@ export function Login() { {parsedFlows.token && loginSearchParams.loginToken && ( )} - {!hideUsernamePasswordFields && parsedFlows.password && ( + {showPassword && ( <> - {parsedFlows.sso && } + {(showLegacySso || showOidc) && } + + )} + {showOidc && ( + <> + + )} - {parsedFlows.sso && ( + {showLegacySso && ( <> )} - {!parsedFlows.password && !parsedFlows.sso && ( + {!showPassword && !showLegacySso && !showOidc && ( <> {`This client does not support login on "${server}" homeserver. Password and SSO based login method not found.`} diff --git a/src/app/pages/auth/login/OidcLogin.tsx b/src/app/pages/auth/login/OidcLogin.tsx new file mode 100644 index 0000000000..06ab23bad5 --- /dev/null +++ b/src/app/pages/auth/login/OidcLogin.tsx @@ -0,0 +1,134 @@ +import { Box, Button, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text } from 'folds'; +import { useCallback, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import type { OidcClientConfig } from '$types/matrix-sdk'; +import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; +import { useAuthServer } from '$hooks/useAuthServer'; +import { InfoCard } from '$components/info-card'; +import { getLoginPath } from '$pages/pathUtils'; +import { useCommitLoginSession } from './loginUtil'; +import type { OidcLoginResult } from './oidcLoginUtil'; +import { + OidcLoginError, + OidcLoginFailure, + completeOidcLogin, + startOidcLogin, +} from './oidcLoginUtil'; + +const ERROR_TITLE = 'Single sign-on'; + +const oidcErrorMessage = (error: unknown): string => { + const code = error instanceof OidcLoginFailure ? error.code : OidcLoginError.Unknown; + switch (code) { + case OidcLoginError.RegistrationFailed: + return 'Could not register with the homeserver for single sign-on.'; + case OidcLoginError.CodeExchangeFailed: + return 'Sign-in could not be completed. Please try again.'; + case OidcLoginError.MissingDeviceId: + return 'The homeserver did not grant a device for this session.'; + default: + return 'Failed to sign in with single sign-on.'; + } +}; + +type OidcLoginButtonProps = { + authMetadata: OidcClientConfig; + homeserverUrl: string; + redirectUri: string; + label: string; + prompt?: string; + notice?: string; +}; +export function OidcLoginButton({ + authMetadata, + homeserverUrl, + redirectUri, + label, + prompt, + notice, +}: OidcLoginButtonProps) { + const [state, start] = useAsyncCallback( + useCallback( + () => startOidcLogin(authMetadata, homeserverUrl, redirectUri, { prompt }), + [authMetadata, homeserverUrl, redirectUri, prompt] + ) + ); + + const loading = state.status === AsyncStatus.Loading || state.status === AsyncStatus.Success; + const errorMessage = state.status === AsyncStatus.Error ? oidcErrorMessage(state.error) : notice; + + return ( + + {errorMessage && ( + + )} + + + ); +} + +type OidcCallbackProps = { + code: string; + state: string; +}; +export function OidcCallback({ code, state }: OidcCallbackProps) { + const commitSession = useCommitLoginSession(); + const server = useAuthServer(); + const navigate = useNavigate(); + + const [loginState, complete] = useAsyncCallback( + useCallback(completeOidcLogin, []) + ); + + useEffect(() => { + complete(code, state); + }, [code, state, complete]); + + useEffect(() => { + if (loginState.status === AsyncStatus.Success) { + commitSession(loginState.data.session); + } + }, [loginState, commitSession]); + + return ( + <> + {loginState.status === AsyncStatus.Error && ( + + + + + )} + }> + + + + + + ); +} diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index 25031a8f24..d52da3b982 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -1,7 +1,7 @@ import to from 'await-to-js'; import type { LoginRequest, LoginResponse } from '$types/matrix-sdk'; import { createClient, MatrixError } from '$types/matrix-sdk'; -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useSetAtom } from 'jotai'; import * as Sentry from '@sentry/react'; @@ -12,6 +12,7 @@ import { getAfterLoginRedirectPath, } from '$pages/afterLoginRedirectPath'; import { getHomePath } from '$pages/pathUtils'; +import type { Session } from '$state/sessions'; import { activeSessionIdAtom, sessionsAtom } from '$state/sessions'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; @@ -145,11 +146,32 @@ export const login = async ( ); }; -export const useLoginComplete = (data?: CustomLoginResponse) => { +/** + * Commits a session to the store, makes it active, and navigates into the app. Shared by the + * password/token login path and the OIDC callback. + */ +export const useCommitLoginSession = () => { const navigate = useNavigate(); const setSessions = useSetAtom(sessionsAtom); const setActiveSessionId = useSetAtom(activeSessionIdAtom); + return useCallback( + (session: Session) => { + setSessions({ type: 'PUT', session }); + setActiveSessionId(session.userId); + const afterLoginRedirectUrl = getAfterLoginRedirectPath(); + deleteAfterLoginRedirectPath(); + const destination = afterLoginRedirectUrl ?? getHomePath(); + log.log('commitLoginSession: navigating to', destination); + navigate(destination, { replace: true }); + }, + [navigate, setSessions, setActiveSessionId] + ); +}; + +export const useLoginComplete = (data?: CustomLoginResponse) => { + const commitSession = useCommitLoginSession(); + useEffect(() => { if (data) { const { response: loginRes, baseUrl: loginBaseUrl } = data; @@ -157,19 +179,12 @@ export const useLoginComplete = (data?: CustomLoginResponse) => { userId: loginRes.user_id, baseUrl: loginBaseUrl, }); - const newSession = { + commitSession({ baseUrl: loginBaseUrl, userId: loginRes.user_id, deviceId: loginRes.device_id, accessToken: loginRes.access_token, - }; - setSessions({ type: 'PUT', session: newSession }); - setActiveSessionId(loginRes.user_id); - const afterLoginRedirectUrl = getAfterLoginRedirectPath(); - deleteAfterLoginRedirectPath(); - const destination = afterLoginRedirectUrl ?? getHomePath(); - log.log('useLoginComplete: navigating to', destination); - navigate(destination, { replace: true }); + }); } - }, [data, navigate, setSessions, setActiveSessionId]); + }, [data, commitSession]); }; diff --git a/src/app/pages/auth/login/oidcLoginUtil.test.ts b/src/app/pages/auth/login/oidcLoginUtil.test.ts new file mode 100644 index 0000000000..3729a5fa64 --- /dev/null +++ b/src/app/pages/auth/login/oidcLoginUtil.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { BearerTokenResponse } from '$types/matrix-sdk'; +import { deviceIdFromScope, expiresInMsFromToken } from './oidcLoginUtil'; + +describe('deviceIdFromScope', () => { + it('extracts the device id from the stable device scope', () => { + const scope = 'openid urn:matrix:client:api:* urn:matrix:client:device:ABC123'; + expect(deviceIdFromScope(scope)).toBe('ABC123'); + }); + + it('extracts the device id from the legacy msc2967 device scope', () => { + const scope = + 'openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:XYZ789'; + expect(deviceIdFromScope(scope)).toBe('XYZ789'); + }); + + it('returns undefined when no device scope is present', () => { + expect(deviceIdFromScope('openid urn:matrix:client:api:*')).toBeUndefined(); + }); +}); + +describe('expiresInMsFromToken', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('prefers expires_in (seconds → ms)', () => { + const token = { expires_in: 299 } as BearerTokenResponse; + expect(expiresInMsFromToken(token)).toBe(299_000); + }); + + it('derives ms from expires_at relative to now', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(1_000_000_000_000)); + const token = { expires_at: 1_000_000_000 + 300 } as BearerTokenResponse; + expect(expiresInMsFromToken(token)).toBe(300_000); + }); + + it('returns undefined when neither field is present', () => { + expect(expiresInMsFromToken({} as BearerTokenResponse)).toBeUndefined(); + }); +}); diff --git a/src/app/pages/auth/login/oidcLoginUtil.ts b/src/app/pages/auth/login/oidcLoginUtil.ts new file mode 100644 index 0000000000..7be1700274 --- /dev/null +++ b/src/app/pages/auth/login/oidcLoginUtil.ts @@ -0,0 +1,118 @@ +import to from 'await-to-js'; +import type { OidcClientConfig, BearerTokenResponse } from '$types/matrix-sdk'; +import { + createClient, + registerOidcClient, + generateOidcAuthorizationUrl, + completeAuthorizationCodeGrant, +} from '$types/matrix-sdk'; +import { createLogger } from '$utils/debug'; +import type { Session } from '$state/sessions'; + +const log = createLogger('oidcLogin'); + +const CLIENT_NAME = 'Sable'; + +export enum OidcLoginError { + RegistrationFailed = 'RegistrationFailed', + UserCancelled = 'UserCancelled', + CodeExchangeFailed = 'CodeExchangeFailed', + MissingDeviceId = 'MissingDeviceId', + Unknown = 'Unknown', +} + +export class OidcLoginFailure extends Error { + public constructor(public readonly code: OidcLoginError) { + super(code); + this.name = 'OidcLoginFailure'; + } +} + +export const startOidcLogin = async ( + authMetadata: OidcClientConfig, + homeserverUrl: string, + redirectUri: string, + opts?: { prompt?: string } +): Promise => { + const [registerErr, clientId] = await to( + registerOidcClient(authMetadata, { + clientName: CLIENT_NAME, + clientUri: window.location.origin, + applicationType: 'web', + redirectUris: [redirectUri], + contacts: undefined, + tosUri: undefined, + policyUri: undefined, + }) + ); + if (registerErr || !clientId) { + log.error('OIDC client registration failed', registerErr); + throw new OidcLoginFailure(OidcLoginError.RegistrationFailed); + } + + const authUrl = await generateOidcAuthorizationUrl({ + metadata: authMetadata, + clientId, + homeserverUrl, + redirectUri, + nonce: crypto.randomUUID(), + prompt: opts?.prompt, + }); + + window.location.assign(authUrl); +}; + +const DEVICE_SCOPE_RE = /urn:matrix:(?:org\.matrix\.msc2967\.)?client:device:(\S+)/; + +export const deviceIdFromScope = (scope: string): string | undefined => + DEVICE_SCOPE_RE.exec(scope)?.[1]; + +export const expiresInMsFromToken = (token: BearerTokenResponse): number | undefined => { + if (typeof token.expires_in === 'number') return token.expires_in * 1000; + if (typeof token.expires_at === 'number') return token.expires_at * 1000 - Date.now(); + return undefined; +}; + +export type OidcLoginResult = { + baseUrl: string; + session: Session; +}; + +export const completeOidcLogin = async (code: string, state: string): Promise => { + const [grantErr, grant] = await to(completeAuthorizationCodeGrant(code, state)); + if (grantErr || !grant) { + log.error('OIDC code exchange failed', grantErr); + throw new OidcLoginFailure(OidcLoginError.CodeExchangeFailed); + } + + const { tokenResponse, homeserverUrl, oidcClientSettings, idTokenClaims } = grant; + + const mx = createClient({ baseUrl: homeserverUrl, accessToken: tokenResponse.access_token }); + const [whoamiErr, whoami] = await to(mx.whoami()); + if (whoamiErr || !whoami?.user_id) { + log.error('OIDC whoami failed', whoamiErr); + throw new OidcLoginFailure(OidcLoginError.Unknown); + } + + // Prefer the server-authoritative device id; fall back to the one carried in the granted scope. + const deviceId = whoami.device_id ?? deviceIdFromScope(tokenResponse.scope); + if (!deviceId) { + throw new OidcLoginFailure(OidcLoginError.MissingDeviceId); + } + + const session: Session = { + baseUrl: homeserverUrl, + userId: whoami.user_id, + deviceId, + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + expiresInMs: expiresInMsFromToken(tokenResponse), + oidc: { + issuer: oidcClientSettings.issuer, + clientId: oidcClientSettings.clientId, + idTokenClaims, + }, + }; + + return { baseUrl: homeserverUrl, session }; +}; diff --git a/src/app/pages/auth/register/Register.tsx b/src/app/pages/auth/register/Register.tsx index df9aa123e9..f9294e4ec9 100644 --- a/src/app/pages/auth/register/Register.tsx +++ b/src/app/pages/auth/register/Register.tsx @@ -5,12 +5,14 @@ import { SSOAction } from '$types/matrix-sdk'; import { useAuthServer } from '$hooks/useAuthServer'; import { RegisterFlowStatus, useAuthFlows } from '$hooks/useAuthFlows'; import { useParsedLoginFlows } from '$hooks/useParsedLoginFlows'; +import { useAutoDiscoveryInfo } from '$hooks/useAutoDiscoveryInfo'; import { SupportedUIAFlowsLoader } from '$components/SupportedUIAFlowsLoader'; import { getLoginPath, withSearchParam } from '$pages/pathUtils'; import { usePathWithOrigin } from '$hooks/usePathWithOrigin'; import type { RegisterPathSearchParams } from '$pages/paths'; import { SSOLogin } from '$pages/auth/SSOLogin'; import { OrDivider } from '$pages/auth/OrDivider'; +import { OidcLoginButton } from '$pages/auth/login/OidcLogin'; import { PasswordRegisterForm, SUPPORTED_REGISTER_STAGES } from './PasswordRegisterForm'; const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams => @@ -25,19 +27,45 @@ const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSea export function Register() { const server = useAuthServer(); - const { loginFlows, registerFlows } = useAuthFlows(); + const { loginFlows, registerFlows, authMetadata } = useAuthFlows(); + const discovery = useAutoDiscoveryInfo(); + const baseUrl = discovery['m.homeserver'].base_url; const [searchParams] = useSearchParams(); const registerSearchParams = useRegisterSearchParams(searchParams); const { sso } = useParsedLoginFlows(loginFlows.flows); - // redirect to /login because only that path handle m.login.token + // redirect to /login because only that path handle m.login.token and the OIDC callback const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server)); + const oidcRedirectUri = usePathWithOrigin(getLoginPath(server), { ignoreHashRouter: true }); const isAddingAccount = searchParams.get('addAccount') === '1'; const loginUrl = isAddingAccount ? withSearchParam(getLoginPath(server), { addAccount: '1' }) : getLoginPath(server); + const showOidc = authMetadata !== undefined; + + if (showOidc) { + return ( + + + Register + + + + + Already have an account? Login + + + ); + } + return ( diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index e7fdff6d76..8831b208c6 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -43,6 +43,7 @@ import { } from '$utils/notificationStyle'; import * as Sentry from '@sentry/react'; import { startClient, stopClient } from '$client/initMatrix'; +import { SessionOidcTokenRefresher } from '$client/oidcTokenRefresher'; import { useClientConfig } from '$hooks/useClientConfig'; import { mobileOrTablet } from '$utils/user-agent'; @@ -71,13 +72,20 @@ const startBackgroundClient = async ( dbName: storeName.sync, }); + const tokenRefresher = + session.oidc && session.refreshToken ? new SessionOidcTokenRefresher(session) : undefined; + const mx = createClient({ baseUrl: session.baseUrl, accessToken: session.accessToken, + refreshToken: session.refreshToken, userId: session.userId, deviceId: session.deviceId, store: indexedDBStore, timelineSupport: false, + tokenRefreshFunction: tokenRefresher + ? (refreshToken) => tokenRefresher.doRefreshAccessToken(refreshToken) + : undefined, }); const startOpts = { diff --git a/src/app/state/sessions.ts b/src/app/state/sessions.ts index 387692f735..c4380d54a0 100644 --- a/src/app/state/sessions.ts +++ b/src/app/state/sessions.ts @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { atom } from 'jotai'; +import type { IdTokenClaims } from '$types/matrix-sdk'; import { createLogger } from '$utils/debug'; import { atomWithLocalStorage, @@ -9,6 +10,12 @@ import { const log = createLogger('sessions'); +export type OidcSessionInfo = { + issuer: string; + clientId: string; + idTokenClaims?: IdTokenClaims; +}; + export type Session = { baseUrl: string; userId: string; @@ -18,6 +25,7 @@ export type Session = { refreshToken?: string; fallbackSdkStores?: boolean; slidingSyncOptIn?: boolean; + oidc?: OidcSessionInfo; }; export type Sessions = Session[]; @@ -116,6 +124,12 @@ export type SessionsAction = type: 'PUT'; session: Session; } + | { + // Merge a field change into an existing session without clobbering rotated tokens. + type: 'UPDATE'; + userId: string; + patch: Partial; + } | { type: 'DELETE'; session: Session; @@ -139,6 +153,14 @@ export const sessionsAtom = atom( set(baseSessionsAtom, sessions); return; } + if (action.type === 'UPDATE') { + const sessions = [...get(baseSessionsAtom)]; + const sessionIndex = sessions.findIndex((session) => session.userId === action.userId); + if (sessionIndex === -1) return; + sessions.splice(sessionIndex, 1, { ...sessions[sessionIndex]!, ...action.patch }); + set(baseSessionsAtom, sessions); + return; + } if (action.type === 'DELETE') { log.log('DELETE session', action.session.userId); const sessions = get(baseSessionsAtom).filter( @@ -149,6 +171,30 @@ export const sessionsAtom = atom( } ); +// Runs outside React (token refresher), so it writes localStorage directly; the synthetic storage +// event makes the mounted atom re-read so its in-memory copy cannot later clobber the new tokens. +export const updateSessionTokens = ( + userId: string, + tokens: { accessToken: string; refreshToken?: string; expiresInMs?: number } +): void => { + const sessions = getLocalStorageItem(MATRIX_SESSIONS_KEY, []); + const index = sessions.findIndex((session) => session.userId === userId); + if (index === -1) return; + sessions[index] = { + ...sessions[index]!, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken ?? sessions[index]!.refreshToken, + expiresInMs: tokens.expiresInMs ?? sessions[index]!.expiresInMs, + }; + setLocalStorageItem(MATRIX_SESSIONS_KEY, sessions); + window.dispatchEvent(new StorageEvent('storage', { key: MATRIX_SESSIONS_KEY })); +}; + +export const getStoredSessionRefreshToken = (userId: string): string | undefined => + getLocalStorageItem(MATRIX_SESSIONS_KEY, []).find( + (session) => session.userId === userId + )?.refreshToken; + export const ACTIVE_SESSION_KEY = 'matrixActiveSession'; const baseActiveSessionAtom = atomWithLocalStorage( ACTIVE_SESSION_KEY, diff --git a/src/app/state/sessions.updateTokens.test.ts b/src/app/state/sessions.updateTokens.test.ts new file mode 100644 index 0000000000..9b528405bd --- /dev/null +++ b/src/app/state/sessions.updateTokens.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createStore } from 'jotai'; +import type { Session } from './sessions'; +import { MATRIX_SESSIONS_KEY, sessionsAtom, updateSessionTokens } from './sessions'; + +const baseSession: Session = { + baseUrl: 'https://hs.example', + userId: '@alice:hs.example', + deviceId: 'DEV1', + accessToken: 'old-access', + refreshToken: 'old-refresh', + expiresInMs: 100, + oidc: { issuer: 'https://issuer.example', clientId: 'client-1' }, +}; + +const readSessions = (): Session[] => JSON.parse(localStorage.getItem(MATRIX_SESSIONS_KEY) ?? '[]'); + +describe('updateSessionTokens', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('replaces tokens for the matching session only', () => { + const other: Session = { ...baseSession, userId: '@bob:hs.example', accessToken: 'bob-access' }; + localStorage.setItem(MATRIX_SESSIONS_KEY, JSON.stringify([baseSession, other])); + + updateSessionTokens('@alice:hs.example', { + accessToken: 'new-access', + refreshToken: 'new-refresh', + expiresInMs: 500, + }); + + const [alice, bob] = readSessions(); + expect(alice!.accessToken).toBe('new-access'); + expect(alice!.refreshToken).toBe('new-refresh'); + expect(alice!.expiresInMs).toBe(500); + expect(alice!.oidc?.clientId).toBe('client-1'); + expect(bob!.accessToken).toBe('bob-access'); + }); + + it('keeps the previous refresh token when the refresh response omits a new one', () => { + localStorage.setItem(MATRIX_SESSIONS_KEY, JSON.stringify([baseSession])); + + updateSessionTokens('@alice:hs.example', { accessToken: 'new-access' }); + + const [alice] = readSessions(); + expect(alice!.accessToken).toBe('new-access'); + expect(alice!.refreshToken).toBe('old-refresh'); + }); + + it('is a no-op when the user has no stored session', () => { + localStorage.setItem(MATRIX_SESSIONS_KEY, JSON.stringify([baseSession])); + + updateSessionTokens('@nobody:hs.example', { accessToken: 'x' }); + + expect(readSessions()).toHaveLength(1); + expect(readSessions()[0]!.accessToken).toBe('old-access'); + }); +}); + +describe("sessionsAtom 'UPDATE' action", () => { + it('merges a field change without clobbering freshly rotated tokens', () => { + const store = createStore(); + store.set(sessionsAtom, { type: 'PUT', session: baseSession }); + + // A background refresh rotated the tokens into the atom. + store.set(sessionsAtom, { + type: 'UPDATE', + userId: baseSession.userId, + patch: { accessToken: 'rotated-access', refreshToken: 'rotated-refresh' }, + }); + + // A later settings change must preserve the rotated tokens. + store.set(sessionsAtom, { + type: 'UPDATE', + userId: baseSession.userId, + patch: { slidingSyncOptIn: true }, + }); + + const session = store.get(sessionsAtom).find((s) => s.userId === baseSession.userId); + expect(session?.accessToken).toBe('rotated-access'); + expect(session?.refreshToken).toBe('rotated-refresh'); + expect(session?.slidingSyncOptIn).toBe(true); + }); +}); diff --git a/src/app/utils/sentryScrubbers.test.ts b/src/app/utils/sentryScrubbers.test.ts index f16884be6c..a8fbb5a2fd 100644 --- a/src/app/utils/sentryScrubbers.test.ts +++ b/src/app/utils/sentryScrubbers.test.ts @@ -239,6 +239,26 @@ describe('scrubMatrixUrl – preview_url', () => { }); }); +describe('scrubMatrixUrl – auth callback credentials', () => { + it('redacts OAuth code and state query params', () => { + expect(scrubMatrixUrl('https://app.example/login/hs?code=abc123&state=xyz789')).toBe( + 'https://app.example/login/hs?code=[REDACTED]&state=[REDACTED]' + ); + }); + + it('redacts params inside a hash-router fragment', () => { + expect(scrubMatrixUrl('https://app.example/#/login/hs?code=abc123')).toBe( + 'https://app.example/#/login/hs?code=[REDACTED]' + ); + }); + + it('redacts the legacy SSO loginToken', () => { + expect(scrubMatrixUrl('/login/hs?loginToken=syt_secret')).toBe( + '/login/hs?loginToken=[REDACTED]' + ); + }); +}); + describe('scrubMatrixUrl – safe inputs', () => { it('passes through a plain path with no Matrix IDs', () => { const safe = '/home/timeline'; diff --git a/src/app/utils/sentryScrubbers.ts b/src/app/utils/sentryScrubbers.ts index cc75fe369e..f98cead46b 100644 --- a/src/app/utils/sentryScrubbers.ts +++ b/src/app/utils/sentryScrubbers.ts @@ -144,5 +144,8 @@ export function scrubMatrixUrl(url: string): string { // The ?url= query parameter on preview_url contains the full external URL being // previewed — strip the entire query string so browsing habits cannot be inferred. .replace(/(\/preview_url)\?[^#\s]*/gi, '$1') + // ── Auth callback credentials ──────────────────────────────────────────────────── + // OAuth code/state and the legacy SSO loginToken are single-use login credentials. + .replace(/([?&#](?:code|state|loginToken)=)[^&#\s]+/gi, '$1[REDACTED]') ); } diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index f7f9edd9e6..808e947d9b 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -14,6 +14,7 @@ import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import * as Sentry from '@sentry/react'; import { pushSessionToSW } from '../sw-session'; +import { SessionOidcTokenRefresher } from './oidcTokenRefresher'; import { cryptoCallbacks } from './secretStorageKeys'; import type { SlidingSyncConfig, SlidingSyncDiagnostics } from './slidingSync'; import { SlidingSyncManager } from './slidingSync'; @@ -275,9 +276,13 @@ const buildClient = async (session: Session): Promise => { const legacyCryptoStore = new IndexedDBCryptoStore(global.indexedDB, storeName.crypto); + const tokenRefresher = + session.oidc && session.refreshToken ? new SessionOidcTokenRefresher(session) : undefined; + const mx = createClient({ baseUrl: session.baseUrl, accessToken: session.accessToken, + refreshToken: session.refreshToken, userId: session.userId, store: indexedDBStore, cryptoStore: legacyCryptoStore, @@ -285,6 +290,9 @@ const buildClient = async (session: Session): Promise => { timelineSupport: true, cryptoCallbacks: cryptoCallbacks as unknown as CryptoCallbacks, verificationMethods: ['m.sas.v1'], + tokenRefreshFunction: tokenRefresher + ? (refreshToken) => tokenRefresher.doRefreshAccessToken(refreshToken) + : undefined, }); await indexedDBStore.startup(); @@ -476,6 +484,43 @@ export const getClientSyncDiagnostics = (mx: MatrixClient): ClientSyncDiagnostic }; }; +const revokeOidcToken = async ( + endpoint: string, + token: string, + tokenTypeHint: 'access_token' | 'refresh_token', + clientId: string +): Promise => { + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token, token_type_hint: tokenTypeHint, client_id: clientId }), + }); + if (!res.ok) { + throw new Error(`OIDC token revocation failed (${res.status})`); + } +}; + +const revokeOidcSession = async (mx: MatrixClient, session: Session): Promise => { + const clientId = session.oidc?.clientId; + if (!clientId) return; + const metadata = await mx.getAuthMetadata(); + const endpoint = metadata.revocation_endpoint; + if (!endpoint) return; + + const accessToken = mx.getAccessToken() ?? undefined; + const results = await Promise.allSettled([ + session.refreshToken + ? revokeOidcToken(endpoint, session.refreshToken, 'refresh_token', clientId) + : Promise.resolve(), + accessToken + ? revokeOidcToken(endpoint, accessToken, 'access_token', clientId) + : Promise.resolve(), + ]); + if (results.some((r) => r.status === 'rejected')) { + debugLog.warn('general', 'OIDC token revocation had failures', { userId: session.userId }); + } +}; + /** * Logs out a Matrix client and cleans up its SDK stores + IndexedDB databases. * Does NOT touch the Jotai sessions atom — callers must do that themselves @@ -490,7 +535,11 @@ export const logoutClient = async (mx: MatrixClient, session?: Session) => { pushSessionToSW(); stopClient(mx); try { - await mx.logout(); + if (session?.oidc) { + await revokeOidcSession(mx, session); + } else { + await mx.logout(); + } debugLog.info('general', 'Logout successful', { userId: mx.getUserId() }); } catch { // ignore diff --git a/src/client/oidcTokenRefresher.ts b/src/client/oidcTokenRefresher.ts new file mode 100644 index 0000000000..0dc1f7f411 --- /dev/null +++ b/src/client/oidcTokenRefresher.ts @@ -0,0 +1,53 @@ +import type { AccessTokens } from '$types/matrix-sdk'; +import { OidcTokenRefresher } from '$types/matrix-sdk'; +import type { Session } from '$state/sessions'; +import { + ACTIVE_SESSION_KEY, + getStoredSessionRefreshToken, + updateSessionTokens, +} from '$state/sessions'; +import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage'; +import { pushSessionToSW } from '../sw-session'; + +export class SessionOidcTokenRefresher extends OidcTokenRefresher { + private readonly userId: string; + + private readonly baseUrl: string; + + public constructor(session: Session) { + if (!session.oidc) { + throw new Error('SessionOidcTokenRefresher requires an OIDC session'); + } + super( + session.oidc.issuer, + session.oidc.clientId, + window.location.origin, + session.deviceId, + session.oidc.idTokenClaims ?? ({} as never) + ); + this.userId = session.userId; + this.baseUrl = session.baseUrl; + } + + // Another tab may have rotated the token; reusing a consumed one revokes the session. + public override doRefreshAccessToken(refreshToken: string): Promise { + return super.doRefreshAccessToken(getStoredSessionRefreshToken(this.userId) ?? refreshToken); + } + + protected async persistTokens(tokens: { + accessToken: string; + refreshToken?: string; + }): Promise { + const { expiry } = tokens as { expiry?: Date }; + updateSessionTokens(this.userId, { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresInMs: expiry ? expiry.getTime() - Date.now() : undefined, + }); + // Only the active session owns the single service-worker session. + const activeSessionId = getLocalStorageItem(ACTIVE_SESSION_KEY, undefined); + if (activeSessionId === this.userId) { + pushSessionToSW(this.baseUrl, tokens.accessToken, this.userId); + } + } +} diff --git a/src/types/matrix-sdk.ts b/src/types/matrix-sdk.ts index d05073f2eb..1705580b01 100644 --- a/src/types/matrix-sdk.ts +++ b/src/types/matrix-sdk.ts @@ -44,7 +44,14 @@ export * from 'matrix-js-sdk/lib/@types/read_receipts'; export * from 'matrix-js-sdk/lib/@types/membership'; export * from 'matrix-js-sdk/lib/@types/registration'; -export * from 'matrix-js-sdk/lib/oidc/validate'; +export * from 'matrix-js-sdk/lib/oidc'; + +// oidc-client-ts is a transitive dep (not resolvable here), so derive its type from the SDK. +import type { completeAuthorizationCodeGrant } from 'matrix-js-sdk/lib/oidc'; + +export type IdTokenClaims = Awaited< + ReturnType +>['idTokenClaims']; export { VerificationMethod } from 'matrix-js-sdk/lib/types'; export * from 'matrix-js-sdk/lib/pushprocessor'; export * from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';