diff --git a/README.md b/README.md index 73008e2f..81180f06 100644 --- a/README.md +++ b/README.md @@ -406,10 +406,16 @@ window.config = { #### OAuth 2.0 configuration -Create an [OIDC client ID for web application](https://developers.google.com/identity/sign-in/web/sign-in). +Create an [OIDC client ID for web application](https://developers.google.com/identity/sign-in/web/sign-in) and register the app origin as an authorized redirect URI (same value as Slim's `path` / app root). Note that Google's OIDC implementation does not currently support the authorization code grant type with PKCE challenge for private clients. For the time being, the legacy implicit grant type has to be used. +Existing configs continue to work without changes: +- `grantType: "implicit"` (common for Google Cloud Healthcare setups) remains supported +- Omitting `grantType` uses the authorization code response type (`code`) + +Deep links are restored after login through the OIDC `state` parameter (not `localStorage`). Silent token renewal reuses the same registered redirect URI (no additional IdP redirect URI is required). + ## Development ### Prerequisites diff --git a/src/App.tsx b/src/App.tsx index 70f0cde4..d6a520d4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -184,17 +184,22 @@ interface AppState { redirectTo?: string wasAuthSuccessful: boolean error?: ErrorMessageSettings + /** Bumped after mid-session auth recovery so views remount and refetch. */ + authRecoveryKey: number } class App extends React.Component { private readonly auth?: AuthManager + private reauthInProgress = false + private unsubscribeAuthorization?: () => void private readonly handleDICOMwebError = ( error: dwc.api.DICOMwebClientError, serverSettings: ServerSettings, ): void => { if (error.status === 401) { - this.signIn() + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.ensureAuthorized() } else if (error.status === 403) { // eslint-disable-next-line @typescript-eslint/no-floating-promises NotificationMiddleware.onError( @@ -292,6 +297,7 @@ class App extends React.Component { defaultClients, isLoading: true, wasAuthSuccessful: false, + authRecoveryKey: 0, } } @@ -349,9 +355,9 @@ class App extends React.Component { tmpClient.updateHeaders(this.state.clients.default.headers) // Re-apply auth so the new client has the current token (avoids 401 when switching mid-session) if (this.auth != null && this.state.user != null) { - const token = await this.auth.getAuthorization() - if (token != null) { - tmpClient.updateHeaders({ Authorization: `Bearer ${token}` }) + const authorization = await this.auth.getAuthorization() + if (authorization != null) { + tmpClient.updateHeaders({ Authorization: authorization }) } } /** @@ -368,49 +374,106 @@ class App extends React.Component { }) } + applyAuthorization = (authorization: string): void => { + for (const key in this.state.clients) { + this.state.clients[key].updateHeaders({ Authorization: authorization }) + } + for (const key in this.state.defaultClients) { + this.state.defaultClients[key].updateHeaders({ + Authorization: authorization, + }) + } + } + /** * Handle successful authentication event. * * Authorizes the DICOMweb client to access the DICOMweb server and directs - * the user back to the App. - * - * @param user - Information about the user - * @param authorization - Value of the "Authorization" HTTP header field + * the user back to the pre-login route (via OIDC state). */ handleSignIn = ({ user, authorization, + returnUrl, }: { user: User authorization: string + returnUrl?: string }): void => { - for (const key in this.state.clients) { - const client = this.state.clients[key] - client.updateHeaders({ Authorization: authorization }) + this.applyAuthorization(authorization) + this.setState({ user }) + + if (returnUrl != null && returnUrl !== '') { + const current = `${window.location.pathname}${window.location.search}` + if (returnUrl !== current) { + window.location.assign(returnUrl) + } } - const storedPath = window.localStorage.getItem('slim_path') - const storedSearch = window.localStorage.getItem('slim_search') - if (storedPath !== null && storedPath !== '') { - const currentPath = window.location.pathname - if (storedPath !== currentPath) { - let path = storedPath - if (storedSearch !== null && storedSearch !== '') { - path += storedSearch - } - window.location.href = path + } + + /** + * Recover from an expired/missing access token without losing the route. + * Tries silent renew first; falls back to interactive redirect with returnUrl. + */ + ensureAuthorized = async (): Promise => { + if (this.auth == null || this.reauthInProgress) { + return + } + this.reauthInProgress = true + let redirectedToIdp = false + try { + const authorization = await this.auth.renewAuthorization() + if (authorization != null) { + this.applyAuthorization(authorization) + // Remount routed views so in-flight 401 failures refetch with the new token. + this.setState((state) => ({ + authRecoveryKey: state.authRecoveryKey + 1, + })) + return + } + console.info('silent renew unavailable; starting interactive sign-in') + const outcome = await this.auth.signIn({ + onSignIn: this.handleSignIn, + returnUrl: `${window.location.pathname}${window.location.search}`, + }) + redirectedToIdp = outcome === 'redirected' + if (outcome === 'completed') { + // Token was refreshed without leaving the page; remount views to refetch. + this.setState((state) => ({ + authRecoveryKey: state.authRecoveryKey + 1, + })) + } + } catch (error) { + console.error(error) + // eslint-disable-next-line @typescript-eslint/no-floating-promises + NotificationMiddleware.onError( + NotificationMiddlewareContext.AUTH, + new CustomError( + errorTypes.AUTHENTICATION, + 'Could not renew authorization.', + ), + ) + } finally { + // oidc-client resolves signinRedirect as soon as navigation is assigned. + // Keep the guard set until unload so concurrent 401s cannot start another redirect. + if (!redirectedToIdp) { + this.reauthInProgress = false } } - window.localStorage.removeItem('slim_path') - window.localStorage.removeItem('slim_search') - this.setState({ user }) } signIn(): void { if (this.auth !== undefined) { console.info('try to sign in user') this.auth - .signIn({ onSignIn: this.handleSignIn }) - .then(() => { + .signIn({ + onSignIn: this.handleSignIn, + returnUrl: `${window.location.pathname}${window.location.search}`, + }) + .then((outcome) => { + if (outcome === 'redirected') { + return + } console.info('sign-in was successful') this.setState({ isLoading: false, @@ -443,12 +506,6 @@ class App extends React.Component { } componentDidMount(): void { - const path = window.localStorage.getItem('slim_path') - if (path === null || path === undefined || path === '') { - window.localStorage.setItem('slim_path', window.location.pathname) - window.localStorage.setItem('slim_search', window.location.search) - } - // Restore cached server selection if it exists const cachedServerUrl = window.localStorage.getItem('slim_selected_server') if ( @@ -456,12 +513,25 @@ class App extends React.Component { cachedServerUrl !== undefined && cachedServerUrl !== '' ) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.handleServerSelection({ url: cachedServerUrl }) } + if (this.auth != null) { + this.unsubscribeAuthorization = this.auth.onAuthorizationChange( + (authorization) => { + this.applyAuthorization(authorization) + }, + ) + } + this.signIn() } + componentWillUnmount(): void { + this.unsubscribeAuthorization?.() + } + render(): React.ReactNode { const appInfo = { name: this.props.name, @@ -486,16 +556,10 @@ class App extends React.Component { let isLogoutPossible = false let onLogout: () => void - if ( - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain - this.props.config.oidc != null && - this.props.config.oidc.endSessionEndpoint != null - ) { + if (this.auth != null) { onLogout = (): void => { - if (this.auth != null) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.auth.signOut() - } + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.auth?.signOut() } isLogoutPossible = true } else { @@ -538,7 +602,7 @@ class App extends React.Component { } else { return ( - + { let profile: UserData['profile'] | undefined @@ -44,85 +58,213 @@ const createUser = (userData: UserData | null): User => { } } +const authorizationFromUser = (userData: UserData): string => { + const tokenType = userData.token_type || 'Bearer' + return `${tokenType} ${userData.access_token}` +} + +const clearAuthParamsFromUrl = (): void => { + const url = new URL(window.location.href) + const authParams = [ + 'code', + 'state', + 'session_state', + 'iss', + 'id_token', + 'access_token', + 'token_type', + 'expires_in', + 'scope', + 'error', + 'error_description', + ] + for (const key of authParams) { + url.searchParams.delete(key) + } + // Implicit / hybrid responses put tokens in the hash fragment. + url.hash = '' + const cleaned = `${url.pathname}${url.search}` + window.history.replaceState({}, document.title, cleaned) +} + +const readReturnUrl = (userData: UserData): string | undefined => { + const state = userData.state as ReturnUrlState | string | null | undefined + if (state == null) { + return undefined + } + if (typeof state === 'string') { + return state || undefined + } + if (typeof state.returnUrl === 'string' && state.returnUrl !== '') { + return state.returnUrl + } + return undefined +} + +/** Only allow same-origin relative paths (block open redirects). */ +export const isSafeReturnUrl = (returnUrl: string): boolean => { + if (!returnUrl.startsWith('/') || returnUrl.startsWith('//')) { + return false + } + try { + const parsed = new URL(returnUrl, window.location.origin) + return parsed.origin === window.location.origin + } catch { + return false + } +} + +const currentReturnUrl = (): string => { + return `${window.location.pathname}${window.location.search}` +} + +/** + * Complete an OIDC silent-renew callback when this window is an iframe. + * Returns true when the caller should skip mounting the React app. + * + * Must never mount the SPA inside a renew iframe: it shares sessionStorage + * with the parent and can corrupt in-flight interactive re-auth. This includes + * IdP error redirects such as `error=login_required` that do not carry a code. + */ +export const completeSilentRenewIfFrame = async (): Promise => { + if (window.parent === window) { + return false + } + // Embedded Slim (non-OIDC iframe) should still mount; only OIDC callbacks skip it. + if (!isOidcAuthorizeCallbackUrl(window.location)) { + return false + } + try { + await new UserManager({}).signinSilentCallback() + } catch (error) { + console.error('silent renew callback failed', error) + } + // Always skip SPA mount for OIDC iframe callbacks (success or error). + return true +} + export default class OidcManager implements AuthManager { private _oidc: UserManager + private readonly _ready: Promise + private readonly _authorizationListeners = new Set() - constructor(baseUri: string, settings: OidcSettings) { - let responseType = 'code' - if (settings.grantType !== undefined) { - if (settings.grantType === 'implicit') { - responseType = 'id_token token' - } - } - this._oidc = new UserManager({ + constructor(appUri: string, settings: OidcSettings) { + const isImplicit = settings.grantType === 'implicit' + const responseType = isImplicit ? 'id_token token' : 'code' + const redirectUri = appUri + /* + * Reuse the main redirect_uri for silent renew so existing IdP client + * registrations (app root only) keep working. The iframe path is handled + * in index.tsx via completeSilentRenewIfFrame() before React mounts. + */ + const silentRedirectUri = redirectUri + const postLogoutRedirectUri = joinUrl('logout', appUri) + + const baseSettings = { authority: settings.authority, client_id: settings.clientId, - redirect_uri: baseUri, + redirect_uri: redirectUri, + silent_redirect_uri: silentRedirectUri, + post_logout_redirect_uri: postLogoutRedirectUri, scope: settings.scope, response_type: responseType, loadUserInfo: true, automaticSilentRenew: true, revokeAccessTokenOnSignout: true, - post_logout_redirect_uri: `${baseUri}/logout`, + } + + this._oidc = new UserManager(baseSettings) + this._wireInternalEvents() + this._ready = this._applyOptionalMetadata(baseSettings, settings) + } + + private _wireInternalEvents(): void { + this._oidc.events.addUserLoaded((userData) => { + this._notifyAuthorization(authorizationFromUser(userData)) }) - if ( - settings.endSessionEndpoint !== null && - settings.endSessionEndpoint !== undefined - ) { - /* - * Unfortunately, the end session endpoint alone cannot be provided to - * the construction of UserManager and the other metadata parameters - * would need to be provided as well. However, configuring all of them - * individually would not be desirable and they will be automatically - * determined anyways. Therefore, we first construct an object, get the - * metadata, update the metadata, and then reconstruct an object with the - * updated metadata. - */ - this._oidc.metadataService - .getMetadata() - .then((metadata) => { - if ( - settings.endSessionEndpoint !== null && - settings.endSessionEndpoint !== undefined - ) { - metadata.end_session_endpoint = settings.endSessionEndpoint - this._oidc = new UserManager({ - authority: settings.authority, - client_id: settings.clientId, - redirect_uri: baseUri, - scope: settings.scope, - response_type: responseType, - loadUserInfo: true, - automaticSilentRenew: true, - revokeAccessTokenOnSignout: true, - post_logout_redirect_uri: `${baseUri}/logout`, - metadata, - }) - } - }) - .catch((error) => { - console.error( - 'failed to get metadata from authorization server: ', - error, - ) - }) + } + + private _notifyAuthorization(authorization: string): void { + for (const listener of this._authorizationListeners) { + listener(authorization) } } + private async _applyOptionalMetadata( + baseSettings: ConstructorParameters[0], + settings: OidcSettings, + ): Promise { + const needsMetadataPatch = + (settings.endSessionEndpoint != null && + settings.endSessionEndpoint !== '') || + (settings.authorizationEndpoint != null && + settings.authorizationEndpoint !== '') + + if (!needsMetadataPatch) { + return + } + + try { + const metadata = await this._oidc.metadataService.getMetadata() + if ( + settings.endSessionEndpoint != null && + settings.endSessionEndpoint !== '' + ) { + metadata.end_session_endpoint = settings.endSessionEndpoint + } + if ( + settings.authorizationEndpoint != null && + settings.authorizationEndpoint !== '' + ) { + metadata.authorization_endpoint = settings.authorizationEndpoint + } + this._oidc = new UserManager({ + ...baseSettings, + metadata, + }) + this._wireInternalEvents() + } catch (error) { + console.error('failed to get metadata from authorization server: ', error) + } + } + + private async _ensureReady(): Promise { + await this._ready + return this._oidc + } + /** * Sign-in to authenticate the user and obtain authorization. */ signIn = async ({ onSignIn, + returnUrl, }: { onSignIn?: SignInCallback - }): Promise => { - const handleSignIn = (userData: UserData): void => { + returnUrl?: string + }): Promise => { + const oidc = await this._ensureReady() + + const handleSignIn = ( + userData: UserData, + { includeReturnUrl }: { includeReturnUrl: boolean }, + ): void => { const user = createUser(userData) - const authorization = `${userData.token_type} ${userData.access_token}` + const authorization = authorizationFromUser(userData) + let resolvedReturnUrl: string | undefined + if (includeReturnUrl) { + const candidate = readReturnUrl(userData) + if (candidate != null && isSafeReturnUrl(candidate)) { + resolvedReturnUrl = candidate + } + } if (onSignIn != null) { console.info('handling sign-in using provided callback function') - onSignIn({ user, authorization }) + onSignIn({ + user, + authorization, + returnUrl: resolvedReturnUrl, + }) } else { console.warn('no callback function was provided to handle sign-in') } @@ -130,73 +272,123 @@ export default class OidcManager implements AuthManager { if (isAuthorizationCodeInUrl(window.location)) { /* Handle the callback from the authorization server: extract the code - * from the callback URL, obtain user information and the access token - * for the DICOMweb server. + * (or implicit tokens) from the callback URL, obtain user information + * and the access token for the DICOMweb server. */ console.info('obtaining authorization') - const userData = await this._oidc.signinCallback() - if (userData != null) { - console.info('obtained user data: ', userData) - handleSignIn(userData) - } - } else { - /* Redirect to the authorization server to authenticate the user - * and authorize the application to obtain user information and access - * the DICOMweb server. - */ - const userData = await this._oidc.getUser() - if (userData === null || userData === undefined || userData.expired) { - console.info('authenticating user') - await this._oidc.signinRedirect() - } else { - console.info('user has already been authenticated') - handleSignIn(userData) - } + const userData = await oidc.signinRedirectCallback() + clearAuthParamsFromUrl() + console.info('obtained user data: ', userData) + handleSignIn(userData, { includeReturnUrl: true }) + return 'completed' } + + /* Redirect to the authorization server to authenticate the user + * and authorize the application to obtain user information and access + * the DICOMweb server. + */ + const userData = await oidc.getUser() + if (userData === null || userData === undefined || userData.expired) { + console.info('authenticating user') + await oidc.signinRedirect({ + state: { + returnUrl: returnUrl ?? currentReturnUrl(), + }, + }) + // oidc-client resolves as soon as navigation is assigned; page unload follows. + return 'redirected' + } + + console.info('user has already been authenticated') + // Do not re-apply persisted returnUrl on warm sessions. + handleSignIn(userData, { includeReturnUrl: false }) + return 'completed' } /** * Sign-out to revoke authorization. + * Falls back to local session clear when the IdP has no end-session endpoint. */ signOut = async (): Promise => { console.log('signing out user and revoking authorization') - return await this._oidc.signoutRedirect() + const oidc = await this._ensureReady() + const logoutUri = joinUrl('logout', oidc.settings.redirect_uri ?? '/') + try { + const metadata = await oidc.metadataService.getMetadata() + if ( + metadata.end_session_endpoint == null || + metadata.end_session_endpoint === '' + ) { + await oidc.removeUser() + window.location.assign(logoutUri) + return + } + await oidc.signoutRedirect() + } catch (error) { + console.error('sign-out redirect failed; clearing local session', error) + await oidc.removeUser() + window.location.assign(logoutUri) + } } /** * Get authorization. Requires prior sign-in. + * Returns a full HTTP Authorization header value (e.g. "Bearer …"). */ getAuthorization = async (): Promise => { - return await this._oidc.getUser().then((userData) => { - if (userData !== null && userData !== undefined) { - return userData.access_token - } else { - NotificationMiddleware.onError( - NotificationMiddlewareContext.AUTH, - new CustomError( - errorTypes.AUTHENTICATION, - 'Failed to obtain user profile.', - ), - ) - } - }) + const oidc = await this._ensureReady() + const userData = await oidc.getUser() + if (userData !== null && userData !== undefined && !userData.expired) { + return authorizationFromUser(userData) + } + NotificationMiddleware.onError( + NotificationMiddlewareContext.AUTH, + new CustomError( + errorTypes.AUTHENTICATION, + 'Failed to obtain user profile.', + ), + ) + return undefined } /** * Get user information. Requires prior sign-in. */ getUser = async (): Promise => { - return await this._oidc.getUser().then((userData) => { - if (userData === null || userData === undefined) { - NotificationMiddleware.onError( - NotificationMiddlewareContext.AUTH, - new CustomError( - errorTypes.AUTHENTICATION, - 'Failed to obtain user information.', - ), - ) + const oidc = await this._ensureReady() + const userData = await oidc.getUser() + if (userData === null || userData === undefined) { + NotificationMiddleware.onError( + NotificationMiddlewareContext.AUTH, + new CustomError( + errorTypes.AUTHENTICATION, + 'Failed to obtain user information.', + ), + ) + } + return createUser(userData) + } + + renewAuthorization = async (): Promise => { + const oidc = await this._ensureReady() + try { + const userData = await oidc.signinSilent() + if (userData == null || userData.expired) { + return undefined } - return createUser(userData) - }) + const authorization = authorizationFromUser(userData) + this._notifyAuthorization(authorization) + return authorization + } catch (error) { + console.warn('silent authorization renew failed', error) + return undefined + } + } + + onAuthorizationChange = (callback: AuthorizationCallback): (() => void) => { + this._authorizationListeners.add(callback) + return () => { + this._authorizationListeners.delete(callback) + } } } diff --git a/src/auth/__tests__/isSafeReturnUrl.test.ts b/src/auth/__tests__/isSafeReturnUrl.test.ts new file mode 100644 index 00000000..19820724 --- /dev/null +++ b/src/auth/__tests__/isSafeReturnUrl.test.ts @@ -0,0 +1,33 @@ +import { isSafeReturnUrl } from '../OidcManager' + +describe('isSafeReturnUrl', () => { + const originalLocation = window.location + + beforeAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + origin: 'https://example.com', + }, + }) + }) + + afterAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }) + }) + + it('accepts same-origin relative paths', () => { + expect(isSafeReturnUrl('/studies/1.2.3')).toBe(true) + expect(isSafeReturnUrl('/studies/1.2.3?state=abc')).toBe(true) + }) + + it('rejects absolute and protocol-relative URLs', () => { + expect(isSafeReturnUrl('https://evil.example/phish')).toBe(false) + expect(isSafeReturnUrl('//evil.example/phish')).toBe(false) + expect(isSafeReturnUrl('https://example.com/studies/1')).toBe(false) + }) +}) diff --git a/src/auth/index.d.ts b/src/auth/index.d.ts index 2180fb11..9ec233a7 100644 --- a/src/auth/index.d.ts +++ b/src/auth/index.d.ts @@ -1,19 +1,36 @@ export type SignInCallback = ({ user, authorization, + returnUrl, }: { user: User authorization: string + returnUrl?: string }) => void +export type AuthorizationCallback = (authorization: string) => void + +/** Outcome of signIn: redirected means the page is navigating to the IdP. */ +export type SignInOutcome = 'completed' | 'redirected' + export interface User { name: string | undefined email: string | undefined } export interface AuthManager { - signIn: ({ onSignIn }: { onSignIn: SignInCallback }) => Promise + signIn: ({ + onSignIn, + returnUrl, + }: { + onSignIn?: SignInCallback + returnUrl?: string + }) => Promise signOut: () => Promise getAuthorization: () => Promise getUser: () => Promise + /** Attempt silent token renewal; returns a full Authorization header value. */ + renewAuthorization: () => Promise + /** Subscribe to authorization updates (e.g. after silent renew). */ + onAuthorizationChange: (callback: AuthorizationCallback) => () => void } diff --git a/src/index.tsx b/src/index.tsx index 85302049..4fd20d7a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -122,22 +122,42 @@ message.config({ duration: config.messages?.duration ?? 5, }) -const container = document.getElementById('root') -if (container == null) { - throw new Error('Root element not found') +const mountApp = (): void => { + const container = document.getElementById('root') + if (container == null) { + throw new Error('Root element not found') + } + const root = createRoot(container) + root.render( + /// / + Loading application...}> + + + + , + // + ) } -const root = createRoot(container) -root.render( - /// / - Loading application...}> - - - - , - // -) + +/* + * Silent renew reuses the app redirect_uri (no extra IdP registration). + * When oidc-client loads that URI in a hidden iframe (success or error), + * complete the callback here and skip mounting React so the iframe cannot + * share/corrupt the parent sessionStorage OIDC state. + */ +void import('./auth/OidcManager') + .then(async ({ completeSilentRenewIfFrame }) => { + const handled = await completeSilentRenewIfFrame() + if (!handled) { + mountApp() + } + }) + .catch((error) => { + console.error('failed to initialize auth bootstrap', error) + mountApp() + }) diff --git a/src/utils/__tests__/url.test.ts b/src/utils/__tests__/url.test.ts index 32da4b36..6f74697e 100644 --- a/src/utils/__tests__/url.test.ts +++ b/src/utils/__tests__/url.test.ts @@ -1,4 +1,9 @@ -import { GCP_HEALTHCARE_V1_BASE, normalizeServerUrl } from '../url' +import { + GCP_HEALTHCARE_V1_BASE, + isAuthorizationCodeInUrl, + isOidcAuthorizeCallbackUrl, + normalizeServerUrl, +} from '../url' const storePath = '/projects/idc-sandbox-000/locations/us-central1/datasets/fedorov-dev-healthcare/dicomStores/sardana-lut-test' @@ -61,3 +66,42 @@ describe('normalizeServerUrl', () => { expect(normalizeServerUrl(proxyUrl)).toBe(proxyUrl) }) }) + +describe('isOidcAuthorizeCallbackUrl', () => { + it('detects authorization code and implicit success responses', () => { + expect( + isOidcAuthorizeCallbackUrl({ search: '?code=abc&state=s', hash: '' }), + ).toBe(true) + expect( + isAuthorizationCodeInUrl({ search: '?code=abc&state=s', hash: '' }), + ).toBe(true) + expect( + isOidcAuthorizeCallbackUrl({ + search: '', + hash: '#access_token=tok&token_type=Bearer', + }), + ).toBe(true) + }) + + it('detects IdP error responses without code/id_token (silent renew failure)', () => { + expect( + isOidcAuthorizeCallbackUrl({ + search: '?error=login_required&state=s', + hash: '', + }), + ).toBe(true) + expect( + isAuthorizationCodeInUrl({ + search: '?error=login_required&state=s', + hash: '', + }), + ).toBe(false) + }) + + it('ignores ordinary app URLs', () => { + expect( + isOidcAuthorizeCallbackUrl({ search: '?state=presentation', hash: '' }), + ).toBe(false) + expect(isOidcAuthorizeCallbackUrl({ search: '', hash: '' })).toBe(false) + }) +}) diff --git a/src/utils/url.tsx b/src/utils/url.tsx index e148233f..de93b67a 100644 --- a/src/utils/url.tsx +++ b/src/utils/url.tsx @@ -89,3 +89,26 @@ export const isAuthorizationCodeInUrl = (location: { hashParams.get('session_state'), ) } + +/** + * True when the URL looks like an OIDC authorize redirect back to the app + * (success or error). Used to detect silent-renew iframe callbacks that must + * not boot the React SPA (including `error=login_required` responses that + * lack code/id_token/session_state). + */ +export const isOidcAuthorizeCallbackUrl = (location: { + search: string + hash: string +}): boolean => { + if (isAuthorizationCodeInUrl(location)) { + return true + } + const searchParams = new URLSearchParams(location.search) + const hashParams = new URLSearchParams(location.hash.replace('#', '?')) + return Boolean( + searchParams.get('error') ?? + searchParams.get('access_token') ?? + hashParams.get('error') ?? + hashParams.get('access_token'), + ) +}