From 27d356eadacd4919d02436cca854bf4e2a97dca5 Mon Sep 17 00:00:00 2001 From: VargaJoe Date: Thu, 29 Oct 2026 15:37:14 +0100 Subject: [PATCH 1/2] Fix authentication lifecycle and browser-bound callbacks across providers --- apps/sensenet/AUTHENTICATION-TESTING.md | 38 ++ apps/sensenet/README.md | 5 + .../sensenet/src/components/app-providers.tsx | 16 +- .../src/context/local-repository-provider.tsx | 33 +- .../src/context/repository-provider.tsx | 44 +- .../context/sn-auth-repository-provider.tsx | 88 +++- .../src/services/local-authentication.ts | 158 +++++-- .../local-authentication-provider.test.tsx | 79 ++++ .../local-authentication-routing.test.tsx | 78 +++- .../test/local-authentication.test.ts | 258 ++++++++++- .../test/oidc-event-lifecycle.test.ts | 104 +++++ .../test/oidc-operation-lifecycle.test.tsx | 334 ++++++++++++++ .../test/oidc-provider-lifecycle.test.tsx | 85 ++++ .../test/oidc-repository-recovery.test.tsx | 197 +++++++++ .../test/snauth-repository-recovery.test.tsx | 212 +++++++++ .../components/authentication-provider.tsx | 414 ++++++++---------- packages/sn-auth-react/src/external-login.ts | 98 +++++ packages/sn-auth-react/src/index.ts | 1 + .../sn-auth-react/src/models/login-request.ts | 1 + .../src/models/multi-factor-login-request.ts | 1 + packages/sn-auth-react/src/server-actions.ts | 8 +- .../sn-auth-react/test/external-login.test.ts | 98 +++++ .../sn-auth-react/test/lifecycle.test.tsx | 297 +++++++++++++ .../sn-auth-react/test/server-actions.test.ts | 42 ++ .../src/authentication-service.ts | 3 +- .../components/authentication-provider.tsx | 50 ++- .../src/components/callback.tsx | 9 +- .../src/oidc-events.ts | 128 ++++-- .../src/oidc-service.ts | 19 +- .../src/user-manager-lifecycle.ts | 180 ++++++++ .../test/authentication-provider.test.tsx | 14 +- .../test/events.test.tsx | 54 ++- 32 files changed, 2726 insertions(+), 420 deletions(-) create mode 100644 apps/sensenet/AUTHENTICATION-TESTING.md create mode 100644 apps/sensenet/test/oidc-event-lifecycle.test.ts create mode 100644 apps/sensenet/test/oidc-operation-lifecycle.test.tsx create mode 100644 apps/sensenet/test/oidc-provider-lifecycle.test.tsx create mode 100644 apps/sensenet/test/oidc-repository-recovery.test.tsx create mode 100644 apps/sensenet/test/snauth-repository-recovery.test.tsx create mode 100644 packages/sn-auth-react/src/external-login.ts create mode 100644 packages/sn-auth-react/test/external-login.test.ts create mode 100644 packages/sn-auth-react/test/lifecycle.test.tsx create mode 100644 packages/sn-auth-react/test/server-actions.test.ts create mode 100644 packages/sn-authentication-oidc-react/src/user-manager-lifecycle.ts diff --git a/apps/sensenet/AUTHENTICATION-TESTING.md b/apps/sensenet/AUTHENTICATION-TESTING.md new file mode 100644 index 0000000000..402a1c7c81 --- /dev/null +++ b/apps/sensenet/AUTHENTICATION-TESTING.md @@ -0,0 +1,38 @@ +# Authentication acceptance tests + +Changes are on `codex/auth-lifecycle-fixes`, based on merged PR #1655. +The separate `SenseNet/sn-auth` branch with the same name is required for secure +SNAuth external login. Deploy the server first; it still supports older clients. +The new client reports an error for an old server's unbound callback. + +Use the usual Chrome profile with existing repository history and sessions. +Record the AdminUI commit/build, browser, repository, authentication mode and +result for each case. Do not clear all browser storage as a test prerequisite. +Use test users and avoid recording tokens or passwords in evidence. + +| Case | IdentityServer | SNAuth | Local/internal | Expected result | +| ------------------------------------------------------- | ------------------------ | ------ | ---------------- | ------------------------------------------------------------------------------------------ | +| Login, reload, logout, login again | Yes | Yes | Yes | Correct identity and usable signed-out view; logout does not hang. | +| Switch A → B → A | Yes | Yes | Yes | Correct repository and identity; other sessions remain available. | +| Local → external login and callback | Yes | Yes | Source is Local | Correct auth mode survives callback/reload; old Local session remains available. | +| Let an access token expire | Yes | Yes | Yes | One refresh operation; UI and requests use the refreshed token. | +| Logout while refresh/current-user request is delayed | Yes | Yes | Yes | Old response cannot restore a logged-out session or overwrite a later login. | +| A → B → A while A's request is delayed, then logout A | Yes | Yes | Yes | A stays logged out when the old request completes; B is unaffected. | +| Network/issuer failure at startup or refresh | Yes | Yes | Yes | Loading finishes with an understandable error and a usable retry/selection action. | +| Logout endpoint fails or stalls | Yes | Yes | Yes | Recoverable UI; SNAuth clears locally immediately and warns if revocation is unconfirmed. | +| Login while an earlier Local logout is pending | — | — | Yes | Completing the old logout does not clear the new identity. | +| Repeated mount/switch/expiry | Yes | — | — | Event subscriptions are removed; one expiry does not trigger duplicate silent renewals. | +| Unsolicited, wrong-state or replayed callback | OIDC's existing protocol | Yes | — | SNAuth does not convert/persist tokens; it shows an error and allows repository selection. | +| Password login, remembered login, MFA retry/success | Existing provider | Yes | Where configured | Same intended browser transaction completes, with the correct user. | +| Registration and forgotten-password navigation/recovery | Existing provider | Yes | Where configured | Return to login preserves the pending transaction; completed login succeeds. | +| Two browser sessions using the same user | Existing provider | Yes | Yes | Logging out one preserves the other when server policy allows parallel sessions. | + +The existing bare-root automatic restoration of a previously selected repository +is intentionally unchanged. The explicit Choose another repository/Switch action +must remain usable. An old SNAuth server is an expected compatibility error for +the new client, not a reason to weaken callback validation. + +Automated checks use the app Jest configuration for the Local/OIDC/SNAuth wrapper +and package regression suites, plus both IdentityServer and SNAuth production +webpack builds. The server has independent HTTP, proof, MFA, expiry and session +tests. These checks support review; live provider acceptance is a separate step. diff --git a/apps/sensenet/README.md b/apps/sensenet/README.md index 34f29dc03e..ed87c0f12b 100644 --- a/apps/sensenet/README.md +++ b/apps/sensenet/README.md @@ -156,3 +156,8 @@ To build the application: ```bash yarn build ``` + +## Authentication validation + +See [the authentication acceptance matrix](AUTHENTICATION-TESTING.md) for the +IdentityServer, SNAuth and Local/internal test round and SNAuth server-first rollout. diff --git a/apps/sensenet/src/components/app-providers.tsx b/apps/sensenet/src/components/app-providers.tsx index 1c230baca4..5c1e81f765 100644 --- a/apps/sensenet/src/components/app-providers.tsx +++ b/apps/sensenet/src/components/app-providers.tsx @@ -59,8 +59,15 @@ export default function AppProviders({ children }: AppProvidersProps) { }) // Retain only the initial destination for startup routing, never a consumed token. const initialResetRepository = useRef(resetLink?.repositoryUrl).current + // This callback path belongs to SNAuth. Another tab may have changed the global + // mode meanwhile; the SNAuth wrapper validates its tab-local state before use. + const initialSnAuthCallback = useRef(window.location.pathname === '/authentication/callback').current const [authType, setAuthType] = useState( - resetLink ? 'Local' : (window.localStorage.getItem('authType') as AuthServerType) ?? defaultAuthConfig.authType, + resetLink + ? 'Local' + : initialSnAuthCallback + ? 'SNAuth' + : (window.localStorage.getItem('authType') as AuthServerType) ?? defaultAuthConfig.authType, ) const [url, setUrl] = useState(resetLink?.repositoryUrl || '') const [choice, setChoice] = useState() @@ -104,7 +111,9 @@ export default function AppProviders({ children }: AppProvidersProps) { clearActiveRepositorySelection() startSnAuthRepositoryLogin(normalized) setChoice(undefined) - setAuthType(defaultAuthConfig.authType === 'Local' ? 'SNAuth' : defaultAuthConfig.authType) + const externalAuthType = defaultAuthConfig.authType === 'Local' ? 'SNAuth' : defaultAuthConfig.authType + window.localStorage.setItem('authType', externalAuthType) + setAuthType(externalAuthType) setUrl(normalized) }, []) @@ -135,6 +144,7 @@ export default function AppProviders({ children }: AppProvidersProps) { ) useEffect(() => { + if (initialSnAuthCallback) return const location = new URL(window.location.href) const repoUrl = location.searchParams.get('repoUrl') || (/^\/login\/?$/.test(location.pathname) ? location.origin : '') @@ -142,7 +152,7 @@ export default function AppProviders({ children }: AppProvidersProps) { if (initialResetRepository) selectLocal(initialResetRepository) else selectRepository(repoUrl) } - }, [selectRepository, selectLocal, initialResetRepository]) + }, [selectRepository, selectLocal, initialResetRepository, initialSnAuthCallback]) snInjector .getInstance(CommandProviderManager) diff --git a/apps/sensenet/src/context/local-repository-provider.tsx b/apps/sensenet/src/context/local-repository-provider.tsx index 52b1cec232..5479a88405 100644 --- a/apps/sensenet/src/context/local-repository-provider.tsx +++ b/apps/sensenet/src/context/local-repository-provider.tsx @@ -32,6 +32,7 @@ export function LocalRepositoryProvider({ const [error, setError] = useState('') const [chooseRepository, setChooseRepository] = useState(!repoUrl) const mounted = useRef(true) + const activeSession = useRef() const [resetToken, setResetToken] = useState(() => { if (initialResetToken) return initialResetToken const location = new URL(window.location.href) @@ -54,6 +55,7 @@ export function LocalRepositoryProvider({ } const loadUser = async (current: LocalRepositorySession) => { + const { revision } = current const repo = new Repository( { repositoryUrl: current.repositoryUrl, @@ -76,6 +78,7 @@ export function LocalRepositoryProvider({ current.fetch, ) current.onChange = () => { + if (!mounted.current || activeSession.current !== current) return repo.configuration.token = current.accessToken if (!current.hasSession && mounted.current) { setUser(undefined) @@ -88,16 +91,19 @@ export function LocalRepositoryProvider({ const result = await response.json() if (String(result.d?.Id) !== current.subject) throw new Error('Could not identify your account.') await repo.reloadSchema() - if (mounted.current) { + if (mounted.current && activeSession.current === current && current.revision === revision && current.hasSession) { setRepository(repo) setUser(result.d) + return true } + return false } useEffect(() => { mounted.current = true let active = true const abort = new AbortController() + let current: LocalRepositorySession | undefined setUser(undefined) setRepository(undefined) setSession(undefined) @@ -113,7 +119,8 @@ export function LocalRepositoryProvider({ if (!capabilities?.local) throw new Error('Internal authentication is unavailable for this repository or network.') if (!active) return - const current = new LocalRepositorySession(repoUrl, capabilities.local) + current = new LocalRepositorySession(repoUrl, capabilities.local) + activeSession.current = current setSession(current) if (current.hasSession && !enteredWithReset) await loadUser(current) } catch { @@ -126,6 +133,8 @@ export function LocalRepositoryProvider({ active = false mounted.current = false abort.abort() + current?.dispose() + if (activeSession.current === current) activeSession.current = undefined } }, [repoUrl, enteredWithReset]) @@ -145,15 +154,18 @@ export function LocalRepositoryProvider({ setRepository(undefined) }, logout: async () => { + const pending = session.logout() + const { revision } = session try { - await session.logout() + await pending } catch { - if (mounted.current) setError('Signed out locally. Server revocation could not be confirmed.') - } finally { - if (mounted.current) { - setUser(undefined) - setRepository(undefined) - } + if ( + mounted.current && + activeSession.current === session && + session.revision === revision && + !session.hasSession + ) + setError('Signed out locally. Server revocation could not be confirmed.') } }, }}> @@ -174,8 +186,7 @@ export function LocalRepositoryProvider({ onAuthenticated={async () => { if (session) { setError('') - await loadUser(session) - finishReset() + if (await loadUser(session)) finishReset() } }} onChooseRepository={() => setChooseRepository(true)} diff --git a/apps/sensenet/src/context/repository-provider.tsx b/apps/sensenet/src/context/repository-provider.tsx index 376792dbbe..cb26110cf4 100644 --- a/apps/sensenet/src/context/repository-provider.tsx +++ b/apps/sensenet/src/context/repository-provider.tsx @@ -161,11 +161,7 @@ export function RepositoryProvider({ /> )} customEvents={customEvents}> - + {children} @@ -175,17 +171,13 @@ export function RepositoryProvider({ const RepoProvider = ({ children, repoUrl, - clearAuthState, identityServerUrl, - changeAuthType, }: { children: ReactNode repoUrl: string - clearAuthState: Function identityServerUrl?: string - changeAuthType: (x: string) => void }) => { - const { oidcUser, login, logout } = useOidcAuthentication() + const { oidcUser, login, logout, error, errorOperation } = useOidcAuthentication() const logger = useLogger('repo-provider') const [repo, setRepo] = useState() @@ -230,19 +222,35 @@ const RepoProvider = ({ useEffect(() => { ;(async () => { const configString = window.localStorage.getItem(authConfigKey) - if ((!oidcUser || oidcUser.expired) && configString) { + if (!error && (!oidcUser || oidcUser.expired) && configString) { try { await login() - } catch (error) { - const config = JSON.parse(configString) - changeAuthType(repoUrl) - logger.error({ data: error, message: `Couldn't connect to ${config.authority}` }) - clearActiveRepositorySelection() - clearAuthState() + } catch (authenticationError) { + logger.error({ data: authenticationError, message: `Couldn't authenticate with ${identityServerUrl}` }) } } })() - }, [clearAuthState, logger, login, logout, oidcUser, changeAuthType, repoUrl]) + }, [logger, login, logout, oidcUser, error, identityServerUrl]) + + if (error) { + return ( +
+ { + clearActiveRepositorySelection() + window.location.assign('/') + }} + /> +
+ ) + } if (!oidcUser || oidcUser.expired || !repo) { return null diff --git a/apps/sensenet/src/context/sn-auth-repository-provider.tsx b/apps/sensenet/src/context/sn-auth-repository-provider.tsx index 2ae32dfce5..0ddf2daaca 100644 --- a/apps/sensenet/src/context/sn-auth-repository-provider.tsx +++ b/apps/sensenet/src/context/sn-auth-repository-provider.tsx @@ -2,9 +2,10 @@ import { CssBaseline } from '@material-ui/core' import { UserManagerSettings } from '@sensenet/authentication-oidc-react' import { Repository } from '@sensenet/client-core' import { RepositoryContext, useLogger } from '@sensenet/hooks-react' -import { AuthenticationProvider, useSnAuth } from '@sensenet/sn-auth-react' +import { AuthenticationProvider, getPendingExternalLoginTarget, useSnAuth } from '@sensenet/sn-auth-react' import React, { lazy, ReactNode, Suspense, useCallback, useEffect, useState } from 'react' import { FullScreenLoader } from '../components/full-screen-loader' +import { AuthOverrideSkeleton } from '../components/login/auth-override-skeleton' import { NotificationComponent } from '../components/NotificationComponent' import { useGlobalStyles } from '../globalStyles' import { useQuery } from '../hooks' @@ -30,6 +31,7 @@ import { const LoginPage = lazy(() => import(/* webpackChunkName: "login" */ '../components/login/login-page')) export const authConfigKey = snAuthConfigKey +const callbackUri = '/authentication/callback' export function SnAuthRepositoryProvider({ children, @@ -54,13 +56,49 @@ export function SnAuthRepositoryProvider({ const cancelledLogin = useQuery().get('cancelledLogin') const [configString, setConfigString] = useState() const [authServerUrl, setAuthServerUrl] = useState() + const [callbackEntry] = useState(() => { + if (window.location.pathname !== callbackUri) return undefined + const target = getPendingExternalLoginTarget(callbackUri, new URLSearchParams(window.location.search).get('state')) + let config + try { + config = target && getSnAuthRepositoryConfig(target.repoUrl) + } catch { + return { config: undefined } + } + const settings = config?.userManagerSettings + if ( + target && + config?.authServerSettings?.type === 'SNAuth' && + normalizeRepositoryUrl(settings?.extraQueryParams?.snrepo || '') === normalizeRepositoryUrl(target.repoUrl) && + normalizeRepositoryUrl(settings?.authority || '') === normalizeRepositoryUrl(target.authServerUrl) + ) + return { config } + return { config: undefined } + }) + const [callbackError, setCallbackError] = useState( + callbackEntry && !callbackEntry.config + ? 'The sign-in response does not match an active login. Start sign-in again.' + : undefined, + ) const clearState = useCallback(() => setAuthState({ repoUrl: '', config: null }), []) + const returnToRepositorySelection = useCallback(() => { + setCallbackError(undefined) + window.history.replaceState({}, '', '/') + setConfigString(null) + clearActiveRepositorySelection() + clearState() + }, [clearState]) useEffect(() => { if (cancelledLogin) { clearActiveRepositorySelection() setAuthState((oldState) => ({ ...oldState, repoUrl: '' })) + } else if (callbackEntry) { + if (callbackEntry.config) { + window.localStorage.setItem('authType', 'SNAuth') + setConfigString(JSON.stringify(callbackEntry.config)) + } } else { const selectedRepository = getSelectedSnAuthRepository() const selectedConfig = selectedRepository && getSnAuthRepositoryConfig(selectedRepository) @@ -85,6 +123,7 @@ export function SnAuthRepositoryProvider({ }, [configString]) useEffect(() => { + if (callbackEntry && window.location.pathname === callbackUri) return if (url) { const repoUrl = normalizeRepositoryUrl(url) const storedConfig = getSnAuthRepositoryConfig(repoUrl) @@ -103,9 +142,10 @@ export function SnAuthRepositoryProvider({ } try { setIsLoginInProgress(true) - if (!(await prepareAuthentication(authState.repoUrl))) return + const boundCallback = window.location.pathname === callbackUri && callbackEntry?.config + if (!boundCallback && !(await prepareAuthentication(authState.repoUrl))) return setAuthenticationCheckedFor(authState.repoUrl) - const storedConfig = getSnAuthRepositoryConfig(authState.repoUrl) + const storedConfig = boundCallback || getSnAuthRepositoryConfig(authState.repoUrl) if (storedConfig) { setAuthServerUrl(storedConfig.userManagerSettings.authority) @@ -138,6 +178,19 @@ export function SnAuthRepositoryProvider({ getConfig() }, [getConfig]) + if (callbackError) { + return ( +
+ +
+ ) + } + if (authState.repoUrl && authenticationCheckedFor !== authState.repoUrl) return @@ -181,7 +234,7 @@ export function SnAuthRepositoryProvider({ key={getSnAuthStorageKeyPrefix(authState.repoUrl)} storageKeyPrefix={getSnAuthStorageKeyPrefix(authState.repoUrl)} snAuthConfiguration={{ - callbackUri: '/authentication/callback', + callbackUri, }} eventCallbacks={{ onNoInitialization() { @@ -197,11 +250,14 @@ export function SnAuthRepositoryProvider({ clearActiveRepositorySelection() clearState() }, + onLogoutError(error) { + logger.warning({ data: error, message: 'Signed out locally. Server revocation could not be confirmed.' }) + }, }}> {children} @@ -273,11 +329,7 @@ const RepoProvider = ({ useEffect(() => { ;(async () => { const configString = window.localStorage.getItem(authConfigKey) - if (error && !user && !isLoading) { - clearActiveRepositorySelection() - clearAuthState() - return - } + if (error && !user && !isLoading) return if (!user && !isLoading && !accessToken && authServerUrl && configString) { if (!consumeSnAuthRepositoryLogin(repoUrl)) { @@ -287,10 +339,7 @@ const RepoProvider = ({ try { await externalLogin() } catch (externalLoginError) { - changeAuthType(repoUrl) logger.error({ data: externalLoginError, message: `Couldn't connect to ${authServerUrl}` }) - clearActiveRepositorySelection() - clearAuthState() } } })() @@ -308,6 +357,19 @@ const RepoProvider = ({ repoUrl, ]) + if (error && !user && !isLoading) { + return ( +
+ clearAuthState()} + /> +
+ ) + } + if (!user || !repo) { return null } diff --git a/apps/sensenet/src/services/local-authentication.ts b/apps/sensenet/src/services/local-authentication.ts index 88e45969ee..c9b365778f 100644 --- a/apps/sensenet/src/services/local-authentication.ts +++ b/apps/sensenet/src/services/local-authentication.ts @@ -76,6 +76,15 @@ export async function discoverAuthentication(repoUrl: string, signal?: AbortSign } type Tokens = { accessToken: string; refreshToken: string; expiresIn: number } +type LocalSessionState = { + tokens?: Tokens + saved: string | null + generation: number + refreshing?: Promise + listeners: Set<() => void> +} +// sessionStorage is shared by every session instance in this tab. Its lifecycle must be shared too. +const sessionStates = new Map() type TokenClaims = { exp: number; sub: string; iss: string } const claims = (token: string): TokenClaims => { const part = token.split('.')[1] @@ -96,9 +105,10 @@ export function getLocalRepositories(): string[] { /** An explicit human-login session. Passwords are never retained in configuration or storage. */ export class LocalRepositorySession { - private tokens?: Tokens - private refreshing?: Promise - private generation = 0 + private readonly state: LocalSessionState + private disposed = false + private challenge?: { token: string; generation: number } + private readonly notify = () => this.onChange?.() readonly repositoryUrl: string readonly storageKey: string @@ -110,15 +120,35 @@ export class LocalRepositorySession { this.repositoryUrl = normalizeRepositoryUrl(repoUrl) if (new URL(this.repositoryUrl).protocol !== 'https:') throw new Error('Internal authentication requires HTTPS.') this.storageKey = localSessionKey(this.repositoryUrl, endpoints.issuer) - try { - const saved = sessionStorage.getItem(this.storageKey) - if (saved) this.tokens = this.validateTokens(JSON.parse(saved), false) - } catch { - this.clear() + const existing = sessionStates.get(this.storageKey) + this.state = existing || { saved: null, generation: 0, listeners: new Set() } + sessionStates.set(this.storageKey, this.state) + const saved = sessionStorage.getItem(this.storageKey) + if (saved !== this.state.saved) { + // A new document/storage reset must also invalidate pending work from older instances. + this.state.generation++ + this.state.refreshing = undefined + try { + this.state.tokens = saved ? this.validateTokens(JSON.parse(saved), false) : undefined + this.state.saved = saved + this.changed() + } catch { + this.clearSession() + } } + this.state.listeners.add(this.notify) } onChange?: () => void + private get tokens() { + return this.state.tokens + } + private get generation() { + return this.state.generation + } + get revision() { + return this.generation + } get accessToken() { return this.tokens?.accessToken } @@ -129,6 +159,28 @@ export class LocalRepositorySession { return this.tokens ? claims(this.tokens.accessToken).sub : undefined } + /** Detach this provider without signing out a repository that may be selected again. */ + dispose() { + this.disposed = true + this.challenge = undefined + this.state.listeners.delete(this.notify) + this.onChange = undefined + } + + private ensureActive() { + if (this.disposed) throw new Error('Session has ended.') + } + + private ensureCurrent(generation: number) { + this.ensureActive() + if (generation !== this.generation) throw new Error('Session has ended.') + } + + private changed() { + this.state.listeners.forEach((listener) => listener()) + window.dispatchEvent(new Event(localSessionsChanged)) + } + private validateTokens(tokens: Tokens, requireFresh = true) { const decoded = claims(tokens.accessToken) if ( @@ -144,32 +196,44 @@ export class LocalRepositorySession { return tokens } - private save(tokens: Tokens, generation: number) { + private save(tokens: Tokens, generation: number, selectRepository = false) { if (generation !== this.generation) throw new Error('Session has ended.') const validated = this.validateTokens(tokens) if (this.tokens && claims(validated.accessToken).sub !== this.subject) throw new Error('Internal authentication changed identity.') - this.tokens = validated - sessionStorage.setItem(this.storageKey, JSON.stringify(tokens)) + this.state.tokens = validated + this.state.saved = JSON.stringify(validated) + sessionStorage.setItem(this.storageKey, this.state.saved) sessionStorage.setItem( localRepositoriesKey, JSON.stringify([...new Set([...getLocalRepositories(), this.repositoryUrl])]), ) - sessionStorage.setItem(localSelectedRepository, this.repositoryUrl) - this.onChange?.() - window.dispatchEvent(new Event(localSessionsChanged)) + if (selectRepository) sessionStorage.setItem(localSelectedRepository, this.repositoryUrl) + this.changed() } clear() { - this.generation++ - this.tokens = undefined + this.ensureActive() + this.clearSession() + } + + private clearSession() { + this.state.generation++ + this.state.tokens = undefined + this.state.saved = null + this.state.refreshing = undefined + this.challenge = undefined sessionStorage.removeItem(this.storageKey) - sessionStorage.setItem( - localRepositoriesKey, - JSON.stringify(getLocalRepositories().filter((url) => url !== this.repositoryUrl)), - ) - this.onChange?.() - window.dispatchEvent(new Event(localSessionsChanged)) + const prefix = `sn-local:${encodeURIComponent(this.repositoryUrl)}:` + const otherIssuerHasSession = Array.from({ length: sessionStorage.length }, (_, index) => + sessionStorage.key(index), + ).some((key) => key?.startsWith(prefix) && sessionStorage.getItem(key)) + if (!otherIssuerHasSession) + sessionStorage.setItem( + localRepositoriesKey, + JSON.stringify(getLocalRepositories().filter((url) => url !== this.repositoryUrl)), + ) + this.changed() } private async post(operation: keyof typeof operationPaths, body: object) { @@ -191,7 +255,9 @@ export class LocalRepositorySession { this.clear() const { generation } = this const response = await this.post('login', { username, password, twoFactorCode }) - this.save(await response.json(), generation) + const tokens = await response.json() + this.ensureCurrent(generation) + this.save(tokens, generation, true) } async beginLogin(username: string, password: string, twoFactorCode?: string): Promise { @@ -203,7 +269,7 @@ export class LocalRepositorySession { const { generation } = this const response = await this.post('login', { username, password, requestMfaChallenge: true }) const data = await response.json() - if (generation !== this.generation) throw new Error('Session has ended.') + this.ensureCurrent(generation) if (response.status === 202) { if ( typeof data.challengeToken !== 'string' || @@ -213,28 +279,41 @@ export class LocalRepositorySession { data.expiresIn > 300 ) throw new Error('Invalid verification challenge.') + this.challenge = { token: data.challengeToken, generation } return data } - this.save(data, generation) + this.save(data, generation, true) } async completeMfa(challengeToken: string, twoFactorCode: string) { - const { generation } = this + this.ensureActive() + const pending = this.challenge + if (!pending || pending.token !== challengeToken) throw new Error('Verification challenge has ended.') + const { generation } = pending + this.ensureCurrent(generation) const response = await this.post('mfa', { challengeToken, twoFactorCode }) - this.save(await response.json(), generation) + const tokens = await response.json() + this.ensureCurrent(generation) + this.save(tokens, generation, true) + this.challenge = undefined } async forgotPassword(email: string) { + this.ensureActive() await this.post('forgotPassword', { email }) } async resetPassword(token: string, password: string) { + this.ensureActive() + const { generation } = this await this.post('resetPassword', { token, password }) + this.ensureCurrent(generation) this.clear() } private async refresh() { - if (this.refreshing) return this.refreshing + this.ensureActive() + if (this.state.refreshing) return this.state.refreshing const { generation } = this const refreshToken = this.tokens?.refreshToken if (!refreshToken) throw new Error('Sign in again.') @@ -243,15 +322,15 @@ export class LocalRepositorySession { const response = await this.post('refresh', { refreshToken }) this.save(await response.json(), generation) } catch { - if (generation === this.generation) this.clear() + if (generation === this.generation) this.clearSession() throw new Error('Your internal session has ended. Sign in again.') } })() - this.refreshing = operation + this.state.refreshing = operation try { await operation } finally { - if (this.refreshing === operation) this.refreshing = undefined + if (this.state.refreshing === operation) this.state.refreshing = undefined } } @@ -263,6 +342,7 @@ export class LocalRepositorySession { /** Same-repository bearer transport with one refresh/retry after 401. */ readonly fetch: typeof fetch = async (input, init) => { + this.ensureActive() const original = new Request(input, init) const target = new URL(original.url) const base = new URL(this.repositoryUrl) @@ -276,6 +356,7 @@ export class LocalRepositorySession { const { generation } = this if (claims(this.tokens.accessToken).exp * 1000 <= Date.now() + 10000) await this.refresh() const send = () => { + this.ensureCurrent(generation) if (generation !== this.generation || !this.tokens) throw new Error('Your session has ended. Sign in again.') const headers = new Headers(original.headers) headers.set('Authorization', `Bearer ${this.tokens!.accessToken}`) @@ -283,12 +364,21 @@ export class LocalRepositorySession { } const usedToken = this.tokens?.accessToken let response = await send() + this.ensureCurrent(generation) if (response.status === 401) { - if (generation !== this.generation) throw new Error('Your session has ended. Sign in again.') - if (this.tokens?.accessToken === usedToken) await this.refresh() + // Another request may already be rotating the token that replaced usedToken. + if (this.state.refreshing) await this.state.refreshing + else if (this.tokens?.accessToken === usedToken) await this.refresh() + this.ensureCurrent(generation) if (!this.tokens) throw new Error('Sign in again.') + const retryToken = this.tokens.accessToken response = await send() - if (response.status === 401 && generation === this.generation) this.clear() + this.ensureCurrent(generation) + if (response.status === 401) { + if (this.state.refreshing) await this.state.refreshing + this.ensureCurrent(generation) + if (this.tokens?.accessToken === retryToken) this.clearSession() + } } return response } diff --git a/apps/sensenet/test/local-authentication-provider.test.tsx b/apps/sensenet/test/local-authentication-provider.test.tsx index d3b1b7b550..98cbb2a118 100644 --- a/apps/sensenet/test/local-authentication-provider.test.tsx +++ b/apps/sensenet/test/local-authentication-provider.test.tsx @@ -176,3 +176,82 @@ it.each(['fragment', 'prop'])('returns to login after reset, sign-in and logout expect(request).toHaveBeenCalledTimes(5) expect(window.location.hash).toBe('') }) + +it.each(['success', 'failure'])('keeps a new login when an older pending logout finishes with %s', async (outcome) => { + let finishLogout!: (value: Response) => void + let failLogout!: (error: Error) => void + sessionStorage.setItem(localSessionKey(repo, repo), JSON.stringify(tokens)) + const freshTokens = { ...tokens, refreshToken: 'n'.repeat(64) } + const request = jest + .fn() + .mockResolvedValueOnce(response({ mode: 'InternalOnly', local: endpoints })) + .mockResolvedValueOnce(response({ d: { Id: 42, Name: 'admin', Path: '/Root/IMS/Public/admin' } })) + .mockImplementationOnce( + () => + new Promise((resolve, reject) => { + finishLogout = resolve + failLogout = reject + }), + ) + .mockResolvedValueOnce(response(freshTokens)) + .mockResolvedValueOnce(response({ d: { Id: 42, Name: 'admin', Path: '/Root/IMS/Public/admin' } })) + global.fetch = request + await mount() + await act(async () => { + element.querySelector('button')!.click() + }) + expect(element.querySelector('[name=username]')).not.toBeNull() + ;(element.querySelector('[name=username]') as HTMLInputElement).value = 'admin' + ;(element.querySelector('[name=password]') as HTMLInputElement).value = 'password' + await act(async () => { + Simulate.submit(element.querySelector('form')!) + }) + expect(element.textContent).toContain('Signed in as admin') + await act(async () => { + if (outcome === 'success') finishLogout(new Response(null, { status: 204 })) + else failLogout(new Error('Old logout failed')) + }) + expect(element.textContent).toContain('Signed in as admin') + expect(element.querySelector('[role=alert]')).toBeNull() + expect(JSON.parse(sessionStorage.getItem(localSessionKey(repo, repo))!)).toEqual(freshTokens) +}) + +it('does not replace a new repository identity when an old provider load finishes', async () => { + const secondRepo = 'https://second.example' + const secondTokens = { + ...tokens, + accessToken: `h.${btoa( + JSON.stringify({ sub: '43', iss: secondRepo, exp: Math.floor(Date.now() / 1000) + 300 }), + )}.s`, + } + sessionStorage.setItem(localSessionKey(repo, repo), JSON.stringify(tokens)) + sessionStorage.setItem(localSessionKey(secondRepo, secondRepo), JSON.stringify(secondTokens)) + let finishFirstUser!: (value: Response) => void + const request = jest + .fn() + .mockResolvedValueOnce(response({ mode: 'InternalOnly', local: endpoints })) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirstUser = resolve + }), + ) + .mockResolvedValueOnce(response({ mode: 'InternalOnly', local: { ...endpoints, issuer: secondRepo } })) + .mockResolvedValueOnce(response({ d: { Id: 43, Name: 'second-admin', Path: '/Root/IMS/Public/second-admin' } })) + global.fetch = request + await mount() + await act(async () => { + render( + {}}> + + , + element, + ) + }) + expect(element.textContent).toContain('Signed in as second-admin') + await act(async () => { + finishFirstUser(response({ d: { Id: 42, Name: 'first-admin', Path: '/Root/IMS/Public/first-admin' } })) + }) + expect(element.textContent).toContain('Signed in as second-admin') + expect(element.textContent).not.toContain('Signed in as first-admin') +}) diff --git a/apps/sensenet/test/local-authentication-routing.test.tsx b/apps/sensenet/test/local-authentication-routing.test.tsx index 9fac22c8cc..583e94e307 100644 --- a/apps/sensenet/test/local-authentication-routing.test.tsx +++ b/apps/sensenet/test/local-authentication-routing.test.tsx @@ -1,6 +1,7 @@ import React, { useEffect } from 'react' import { render, unmountComponentAtNode } from 'react-dom' import { act } from 'react-dom/test-utils' +import { defaultAuthConfig } from '../src/auth-config' import AppProviders from '../src/components/app-providers' import { discoverAuthentication } from '../src/services/local-authentication' @@ -8,6 +9,8 @@ const mockPass = ({ children }: any) => <>{children} const mockExternal = jest.fn() const mockLocal = jest.fn() const mockReset = jest.fn() +const mockSnAuthMount = jest.fn() +const mockIdentityServerMount = jest.fn() const MockExternalProvider = ({ children, url, prepareAuthentication }: any) => { useEffect(() => { if (url) @@ -30,16 +33,28 @@ jest.mock('../src/context', () => ({ ResponsiveContextProvider: (props: any) => mockPass(props), ThemeProvider: (props: any) => mockPass(props), RepositorySwitchContext: jest.requireActual('react').createContext(null), - RepositoryProvider: (props: any) => MockExternalProvider(props), + RepositoryProvider: (props: any) => { + mockIdentityServerMount(props.url) + return MockExternalProvider(props) + }, })) jest.mock('../src/context/sn-auth-repository-provider', () => ({ - SnAuthRepositoryProvider: (props: any) => MockExternalProvider(props), + SnAuthRepositoryProvider: (props: any) => { + mockSnAuthMount(props.url) + return MockExternalProvider(props) + }, })) jest.mock('../src/context/local-repository-provider', () => ({ - LocalRepositoryProvider: ({ url, initialResetToken, onResetComplete }: any) => { + LocalRepositoryProvider: ({ url, initialResetToken, onResetComplete, selectRepository }: any) => { mockLocal(url) mockReset(initialResetToken) - return
Internal login{initialResetToken && }
+ return ( +
+ Internal login + {initialResetToken && } + +
+ ) }, })) jest.mock('../src/context/auth-provider', () => ({ @@ -74,6 +89,7 @@ jest.mock('../src/services/repository-session', () => ({ let element: HTMLDivElement beforeEach(() => { + defaultAuthConfig.authType = 'SNAuth' jest.clearAllMocks() localStorage.clear() sessionStorage.clear() @@ -190,3 +206,57 @@ it('clears a consumed parent reset token without restarting provider selection', expect(mockExternal).not.toHaveBeenCalled() expect(discoverAuthentication).not.toHaveBeenCalled() }) + +it.each(['IdentityServer', 'SNAuth'] as const)( + 'retains the selected %s mode on callback remount without discarding the previous local selection', + async (externalMode) => { + defaultAuthConfig.authType = externalMode + window.history.replaceState(null, '', '/') + localStorage.setItem('authType', 'Local') + sessionStorage.setItem('selected-local', 'https://previous.example') + sessionStorage.setItem('previous-local-session', 'preserved-session') + ;(discoverAuthentication as jest.Mock).mockResolvedValue(null) + await mount() + await choose('Choose external repository') + expect(mockExternal).toHaveBeenCalledWith('https://external.example') + expect(localStorage.getItem('authType')).toBe(externalMode) + act(() => { + unmountComponentAtNode(element) + }) + mockLocal.mockClear() + window.history.replaceState( + null, + '', + externalMode === 'IdentityServer' + ? '/authentication/login-callback?code=fixture' + : '/authentication/callback?auth_code=fixture', + ) + await mount() + expect(mockLocal).not.toHaveBeenCalled() + expect(localStorage.getItem('authType')).toBe(externalMode) + expect(sessionStorage.getItem('selected-local')).toBe('https://previous.example') + expect(sessionStorage.getItem('previous-local-session')).toBe('preserved-session') + }, +) + +it.each(['Local', 'IdentityServer'])( + 'routes an SNAuth callback to its validating provider despite the globally remembered %s mode', + async (previousMode) => { + localStorage.setItem('authType', previousMode) + sessionStorage.setItem('selected-local', 'https://previous.example') + window.history.replaceState( + null, + '', + '/authentication/callback?auth_code=fixture&state=unknown&repoUrl=https%3A%2F%2Fwrong.example', + ) + await mount() + expect(mockSnAuthMount).toHaveBeenCalledWith('') + expect(mockIdentityServerMount).not.toHaveBeenCalled() + expect(mockLocal).not.toHaveBeenCalled() + expect(discoverAuthentication).not.toHaveBeenCalled() + expect(mockExternal).not.toHaveBeenCalled() + expect(element.textContent).not.toContain('Choose how to sign in') + expect(localStorage.getItem('authType')).toBe(previousMode) + expect(sessionStorage.getItem('selected-local')).toBe('https://previous.example') + }, +) diff --git a/apps/sensenet/test/local-authentication.test.ts b/apps/sensenet/test/local-authentication.test.ts index 57ece0d21d..6a8fa2fb86 100644 --- a/apps/sensenet/test/local-authentication.test.ts +++ b/apps/sensenet/test/local-authentication.test.ts @@ -1,4 +1,10 @@ -import { discoverAuthentication, LocalRepositorySession, localSessionKey } from '../src/services/local-authentication' +import { + discoverAuthentication, + getLocalRepositories, + LocalRepositorySession, + localSelectedRepository, + localSessionKey, +} from '../src/services/local-authentication' const repo = 'https://repo.example' const endpoints = { @@ -8,16 +14,31 @@ const endpoints = { logout: '/authentication/local/logout', revoke: '/authentication/local/revoke', } -const token = (subject = '1', lifetime = 300) => - `header.${btoa(JSON.stringify({ sub: subject, iss: repo, exp: Math.floor(Date.now() / 1000) + lifetime }))}.signature` +const token = (subject = '1', lifetime = 300, issuer = repo) => + `header.${btoa( + JSON.stringify({ sub: subject, iss: issuer, exp: Math.floor(Date.now() / 1000) + lifetime }), + )}.signature` const tokens = (subject = '1') => ({ accessToken: token(subject), refreshToken: 'r'.repeat(64), expiresIn: 300 }) const response = (body: any, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) +const deferred = () => { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((finish, fail) => { + resolve = finish + reject = fail + }) + return { promise, resolve, reject } +} beforeEach(() => { + // Near-expiry fixtures must not expire during a slow or concurrent build. + jest.spyOn(Date, 'now').mockReturnValue(Date.now()) sessionStorage.clear() localStorage.clear() }) +afterEach(() => jest.restoreAllMocks()) + it('keeps local sessions isolated by repository and issuer and never stores passwords', async () => { const request = jest.fn().mockResolvedValue(response(tokens())) const first = new LocalRepositorySession(repo, endpoints, request) @@ -133,3 +154,234 @@ it('calls the default browser transport without binding it to the session', asyn global.fetch = original } }) + +it.each(['success', 'failure'])('does not restore a logged-out remount after an old refresh %s', async (outcome) => { + const refresh = deferred() + const request = jest + .fn() + .mockResolvedValueOnce(response({ ...tokens(), accessToken: token('1', 1) })) + .mockReturnValueOnce(refresh.promise) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + const previous = new LocalRepositorySession(repo, endpoints, request) + await previous.login('admin', 'password') + const pending = expect(previous.fetch(`${repo}/data`)).rejects.toThrow('session has ended') + await Promise.resolve() + previous.dispose() + const current = new LocalRepositorySession(repo, endpoints, request) + await current.logout() + if (outcome === 'success') refresh.resolve(response(tokens())) + else refresh.reject(new Error('Old refresh failed')) + await pending + expect(current.hasSession).toBe(false) + expect(sessionStorage.getItem(current.storageKey)).toBeNull() +}) + +it('shares one refresh and its rotated tokens across session instances for the same repository and issuer', async () => { + const refresh = deferred() + const firstRequest = jest + .fn() + .mockResolvedValueOnce(response({ ...tokens(), accessToken: token('1', 1) })) + .mockReturnValueOnce(refresh.promise) + .mockResolvedValue(response({ ok: true })) + const secondRequest = jest.fn().mockResolvedValue(response({ ok: true })) + const first = new LocalRepositorySession(repo, endpoints, firstRequest) + await first.login('admin', 'password') + const firstFetch = first.fetch(`${repo}/first`) + await Promise.resolve() + const second = new LocalRepositorySession(repo, endpoints, secondRequest) + const secondFetch = second.fetch(`${repo}/second`) + const rotated = { ...tokens(), refreshToken: 'n'.repeat(64) } + refresh.resolve(response(rotated)) + await Promise.all([firstFetch, secondFetch]) + expect(firstRequest.mock.calls.filter(([url]) => url === repo + endpoints.refresh)).toHaveLength(1) + expect(secondRequest.mock.calls).toHaveLength(1) + expect(second.accessToken).toBe(rotated.accessToken) + expect(JSON.parse(sessionStorage.getItem(second.storageKey)!)).toEqual(rotated) +}) + +it.each(['success', 'failure'])('does not erase a new identity when an old refresh ends with %s', async (outcome) => { + const refresh = deferred() + const request = jest + .fn() + .mockResolvedValueOnce(response({ ...tokens(), accessToken: token('1', 1) })) + .mockReturnValueOnce(refresh.promise) + .mockResolvedValueOnce(response(tokens('2'))) + const previous = new LocalRepositorySession(repo, endpoints, request) + await previous.login('first-user', 'password') + const pending = expect(previous.fetch(`${repo}/data`)).rejects.toThrow('session has ended') + await Promise.resolve() + previous.dispose() + const current = new LocalRepositorySession(repo, endpoints, request) + await current.login('second-user', 'password') + if (outcome === 'success') refresh.resolve(response(tokens('1'))) + else refresh.reject(new Error('Old refresh failed')) + await pending + expect(current.subject).toBe('2') + expect(JSON.parse(sessionStorage.getItem(current.storageKey)!)).toEqual(tokens('2')) +}) + +it.each(['success', 'failure'])('does not let a disposed login %s overwrite a later login', async (outcome) => { + const login = deferred() + const previous = new LocalRepositorySession(repo, endpoints, jest.fn().mockReturnValue(login.promise)) + const pending = expect(previous.login('first-user', 'password')).rejects.toThrow() + previous.dispose() + const current = new LocalRepositorySession(repo, endpoints, jest.fn().mockResolvedValue(response(tokens('2')))) + await current.login('second-user', 'password') + if (outcome === 'success') login.resolve(response(tokens('1'))) + else login.reject(new Error('Old login failed')) + await pending + expect(current.subject).toBe('2') + expect(JSON.parse(sessionStorage.getItem(current.storageKey)!)).toEqual(tokens('2')) +}) + +it('does not change the selected repository when a disposed provider finishes a refresh', async () => { + const refresh = deferred() + const request = jest + .fn() + .mockResolvedValueOnce(response({ ...tokens(), accessToken: token('1', 1) })) + .mockReturnValueOnce(refresh.promise) + const previous = new LocalRepositorySession(repo, endpoints, request) + await previous.login('admin', 'password') + const pending = expect(previous.fetch(`${repo}/data`)).rejects.toThrow('Session has ended') + await Promise.resolve() + previous.dispose() + const otherRepo = 'https://other.example' + const otherTokens = { ...tokens(), accessToken: token('2', 300, otherRepo) } + const other = new LocalRepositorySession( + otherRepo, + { ...endpoints, issuer: otherRepo }, + jest.fn().mockResolvedValue(response(otherTokens)), + ) + await other.login('other-user', 'password') + refresh.resolve(response(tokens())) + await pending + expect(sessionStorage.getItem(localSelectedRepository)).toBe(otherRepo) + expect(other.subject).toBe('2') + expect(new LocalRepositorySession(repo, endpoints).hasSession).toBe(true) +}) + +it('keeps another issuer session and repository entry when one issuer logs out', async () => { + const first = new LocalRepositorySession(repo, endpoints, jest.fn().mockResolvedValue(response(tokens()))) + await first.login('first-user', 'password') + const issuer = 'https://other-issuer.example' + const otherTokens = { ...tokens(), accessToken: token('2', 300, issuer) } + const other = new LocalRepositorySession( + repo, + { ...endpoints, issuer }, + jest.fn().mockResolvedValue(response(otherTokens)), + ) + await other.login('other-user', 'password') + await first.logout() + expect(first.hasSession).toBe(false) + expect(other.subject).toBe('2') + expect(sessionStorage.getItem(other.storageKey)).not.toBeNull() + expect(getLocalRepositories()).toContain(repo) +}) + +it('does not clear a newer login when an older password reset completes', async () => { + const reset = deferred() + const request = jest + .fn() + .mockResolvedValueOnce(response(tokens())) + .mockReturnValueOnce(reset.promise) + .mockResolvedValueOnce(response(tokens('2'))) + const session = new LocalRepositorySession( + repo, + { ...endpoints, resetPassword: '/authentication/local/reset-password' }, + request, + ) + await session.login('first-user', 'password') + const pending = expect(session.resetPassword('reset-token', 'new-password')).rejects.toThrow('Session has ended') + await session.login('second-user', 'password') + reset.resolve(new Response(null, { status: 204 })) + await pending + expect(session.subject).toBe('2') +}) + +it('rejects a verification challenge from an ended session before sending an MFA request', async () => { + const request = jest.fn().mockResolvedValue(response({ challengeToken: 'c'.repeat(64), expiresIn: 120 }, 202)) + const session = new LocalRepositorySession(repo, { ...endpoints, mfa: '/authentication/local/mfa' }, request) + await session.beginLogin('admin', 'password') + await session.logout() + await expect(session.completeMfa('c'.repeat(64), '123456')).rejects.toThrow('Verification challenge has ended') + expect(request).toHaveBeenCalledTimes(1) +}) + +it('waits for an active shared refresh before retrying a delayed 401 from an older token', async () => { + const oldResponse = deferred() + const latestRefresh = deferred() + const latestRefreshStarted = deferred() + const initial = tokens() + const intermediate = { ...tokens(), accessToken: `${token()}-second`, refreshToken: 's'.repeat(64) } + const latest = { ...tokens(), accessToken: `${token()}-third`, refreshToken: 't'.repeat(64) } + let refreshCount = 0 + let oldCount = 0 + let rotationCount = 0 + const request = jest.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url + if (url === repo + endpoints.login) return response(initial) + if (url === repo + endpoints.refresh) { + if (++refreshCount === 1) return response(intermediate) + latestRefreshStarted.resolve() + return latestRefresh.promise + } + if (url.endsWith('/old') && ++oldCount === 1) return oldResponse.promise + if (url.endsWith('/rotation')) return ++rotationCount === 1 ? response({}, 401) : response({ ok: true }) + const authorized = input instanceof Request && input.headers.get('Authorization') === `Bearer ${latest.accessToken}` + return response({}, authorized ? 200 : 401) + }) + const first = new LocalRepositorySession(repo, endpoints, request) + await first.login('admin', 'password') + const oldRequest = first.fetch(`${repo}/old`).then( + (result) => result.status, + (error) => error.message, + ) + await first.fetch(`${repo}/rotation`) + const second = new LocalRepositorySession(repo, endpoints, request) + const currentRequest = second.fetch(`${repo}/current`).then( + (result) => result.status, + (error) => error.message, + ) + await latestRefreshStarted.promise + oldResponse.resolve(response({}, 401)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(oldCount).toBe(1) + latestRefresh.resolve(response(latest)) + expect(await Promise.all([oldRequest, currentRequest])).toEqual([200, 200]) + expect(first.accessToken).toBe(latest.accessToken) + expect(second.accessToken).toBe(latest.accessToken) + expect(refreshCount).toBe(2) +}) + +it('does not clear a newer token when an older retry eventually returns 401', async () => { + const oldRetry = deferred() + const oldRetryStarted = deferred() + const intermediate = { ...tokens(), accessToken: `${token()}-second`, refreshToken: 's'.repeat(64) } + const latest = { ...tokens(), accessToken: `${token()}-third`, refreshToken: 't'.repeat(64) } + let refreshCount = 0 + let oldCount = 0 + let currentCount = 0 + const request = jest.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url + if (url === repo + endpoints.login) return response(tokens()) + if (url === repo + endpoints.refresh) return response(++refreshCount === 1 ? intermediate : latest) + if (url.endsWith('/old')) { + if (++oldCount === 1) return response({}, 401) + oldRetryStarted.resolve() + return oldRetry.promise + } + return ++currentCount === 1 ? response({}, 401) : response({ ok: true }) + }) + const session = new LocalRepositorySession(repo, endpoints, request) + await session.login('admin', 'password') + const oldRequest = session.fetch(`${repo}/old`) + await oldRetryStarted.promise + await session.fetch(`${repo}/current`) + oldRetry.resolve(response({}, 401)) + expect((await oldRequest).status).toBe(401) + expect(session.hasSession).toBe(true) + expect(session.accessToken).toBe(latest.accessToken) + expect(JSON.parse(sessionStorage.getItem(session.storageKey)!)).toEqual(latest) + expect(oldCount).toBe(2) + expect(refreshCount).toBe(2) +}) diff --git a/apps/sensenet/test/oidc-event-lifecycle.test.ts b/apps/sensenet/test/oidc-event-lifecycle.test.ts new file mode 100644 index 0000000000..85c1baedb5 --- /dev/null +++ b/apps/sensenet/test/oidc-event-lifecycle.test.ts @@ -0,0 +1,104 @@ +import { User, UserManager } from 'oidc-client' +import { addOidcEvents, removeOidcEvents } from '../../../packages/sn-authentication-oidc-react/src/oidc-events' + +const createManager = () => + new UserManager({ + authority: 'https://identity.example.invalid', + client_id: 'event-lifecycle-test', + redirect_uri: 'http://localhost/callback', + automaticSilentRenew: false, + monitorSession: false, + }) + +describe('OIDC event subscription lifecycle', () => { + let manager: UserManager + + beforeEach(() => { + manager = createManager() + jest.spyOn(manager, 'signinSilent').mockResolvedValue({} as User) + }) + + afterEach(() => { + manager.events.unload() + manager.stopSilentRenew() + }) + + it('does not renew twice or dispatch to the old provider after resubscribing', async () => { + const previousDispatch = jest.fn() + const currentDispatch = jest.fn() + const previousOptions = { userManager: manager, dispatch: previousDispatch } + addOidcEvents(previousOptions) + removeOidcEvents(previousOptions) + const dispose = addOidcEvents({ userManager: manager, dispatch: currentDispatch }) + + ;(manager.events as any)._accessTokenExpired.raise() + await Promise.resolve() + + expect(manager.signinSilent).toHaveBeenCalledTimes(1) + expect(previousDispatch).not.toHaveBeenCalled() + expect(currentDispatch).toHaveBeenCalledTimes(1) + dispose() + }) + + it('removes every subscribed callback and makes cleanup idempotent', () => { + const dispatch = jest.fn() + const customEvents = { + onUserLoaded: jest.fn(), + onUserUnloaded: jest.fn(), + onSilentRenewError: jest.fn(), + onUserSignedOut: jest.fn(), + onAccessTokenExpired: jest.fn(), + onAccessTokenExpiring: jest.fn(), + onUserSessionChanged: jest.fn(), + } + const dispose = addOidcEvents({ userManager: manager, dispatch, customEvents }) + dispose() + dispose() + + const events = manager.events as any + events._userLoaded.raise({}) + events.unload() + events._raiseSilentRenewError(new Error('Disconnected')) + events._raiseUserSignedOut() + events._accessTokenExpired.raise() + events._accessTokenExpiring.raise() + events._raiseUserSessionChanged() + + expect(dispatch).not.toHaveBeenCalled() + expect(manager.signinSilent).not.toHaveBeenCalled() + Object.values(customEvents).forEach((callback) => expect(callback).not.toHaveBeenCalled()) + }) + + it('keeps another subscriber active when one provider is removed', () => { + const previousDispatch = jest.fn() + const currentDispatch = jest.fn() + const first = { userManager: manager, dispatch: previousDispatch } + addOidcEvents(first) + const disposeCurrent = addOidcEvents({ userManager: manager, dispatch: currentDispatch }) + + removeOidcEvents(first) + ;(manager.events as any)._raiseUserSignedOut() + + expect(previousDispatch).not.toHaveBeenCalled() + expect(currentDispatch).toHaveBeenCalledWith({ type: 'ON_UNLOAD_USER' }) + disposeCurrent() + }) + + it('reports failed renewal instead of leaving an unhandled rejection', async () => { + const dispatch = jest.fn() + ;(manager.signinSilent as jest.Mock).mockRejectedValueOnce(new Error('Renewal unavailable')) + const dispose = addOidcEvents({ userManager: manager, dispatch }) + + ;(manager.events as any)._accessTokenExpired.raise() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(dispatch).toHaveBeenLastCalledWith({ + type: 'ON_ERROR', + message: 'Renewal unavailable', + operation: 'refresh', + }) + dispose() + }) +}) diff --git a/apps/sensenet/test/oidc-operation-lifecycle.test.tsx b/apps/sensenet/test/oidc-operation-lifecycle.test.tsx new file mode 100644 index 0000000000..d2bed4aefb --- /dev/null +++ b/apps/sensenet/test/oidc-operation-lifecycle.test.tsx @@ -0,0 +1,334 @@ +import { User, UserManager, UserManagerSettings } from 'oidc-client' +import React from 'react' +import { render, unmountComponentAtNode } from 'react-dom' +import { act } from 'react-dom/test-utils' +import { AuthenticationProvider, useOidcAuthentication } from '../../../packages/sn-authentication-oidc-react/src' +import { + authenticationService, + removeUserManager, +} from '../../../packages/sn-authentication-oidc-react/src/authentication-service' +import { CallbackContainer } from '../../../packages/sn-authentication-oidc-react/src/components/callback' +import { addOidcEvents } from '../../../packages/sn-authentication-oidc-react/src/oidc-events' + +const deferred = () => { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((finish, fail) => { + resolve = finish + reject = fail + }) + return { promise, resolve, reject } +} +const user = (accessToken: string) => + new User({ + profile: { sub: '42' }, + access_token: accessToken, + id_token: 'synthetic-id-token', + token_type: 'Bearer', + scope: 'openid', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }) + +describe('OIDC in-flight operation lifecycle', () => { + let manager: UserManager + let settings: UserManagerSettings + let navigation: ReturnType> + let iframeStarted: ReturnType> + let element: HTMLDivElement + let auth: ReturnType + const staleUser = user('old-silent-access') + const freshUser = user('fresh-explicit-access') + const Probe = () => { + auth = useOidcAuthentication() + return
{auth.error || auth.oidcUser?.access_token || 'Signed out'}
+ } + + beforeEach(async () => { + sessionStorage.clear() + window.history.replaceState(null, '', '/') + removeUserManager() + navigation = deferred<{ url: string }>() + iframeStarted = deferred() + settings = { + authority: 'https://identity.example.invalid', + client_id: 'operation-lifecycle-test', + redirect_uri: 'http://localhost/callback', + response_type: 'code', + automaticSilentRenew: false, + monitorSession: false, + metadata: { + authorization_endpoint: 'https://identity.example.invalid/connect/authorize', + }, + iframeNavigator: { + prepare: () => + Promise.resolve({ + navigate: () => { + iframeStarted.resolve() + return navigation.promise + }, + close: jest.fn(), + }), + }, + redirectNavigator: { + prepare: () => Promise.resolve({ navigate: jest.fn().mockResolvedValue({ url: '' }), close: jest.fn() }), + }, + } as UserManagerSettings + manager = authenticationService(settings) + await manager.storeUser(user('initial-access')) + // Keep the real iframe completion, user store and UserLoaded events. Only the + // identity provider's signed response validation is replaced with synthetic users. + jest + .spyOn(UserManager.prototype, 'processSigninResponse') + .mockImplementation(async (url) => (url.includes('fresh-explicit') ? freshUser : staleUser)) + element = document.createElement('div') + document.body.appendChild(element) + }) + + afterEach(() => { + act(() => { + unmountComponentAtNode(element) + }) + manager.events.unload() + manager.stopSilentRenew() + removeUserManager() + element.remove() + sessionStorage.clear() + jest.restoreAllMocks() + }) + + const mount = () => + act(async () => { + render( + + + , + element, + ) + }) + const beginRenewal = () => + act(async () => { + ;(manager.events as any)._accessTokenExpired.raise() + await iframeStarted.promise + }) + + it('cannot restore the stored user when an old iframe completes after a failed logout', async () => { + jest + .spyOn(manager.metadataService, 'getEndSessionEndpoint') + .mockRejectedValueOnce(new Error('End-session is unavailable')) + await mount() + await beginRenewal() + await act(async () => { + await auth.logout() + }) + expect(auth.errorOperation).toBe('logout') + expect(await manager.getUser()).toBeNull() + + await act(async () => { + navigation.resolve({ url: 'http://localhost/callback?code=old-silent' }) + }) + expect(await manager.getUser()).toBeNull() + expect(auth.oidcUser).toBeUndefined() + expect(auth.errorOperation).toBe('logout') + expect(element.textContent).toBe('End-session is unavailable') + }) + + it('ignores an older iframe failure after a fresh user callback was completed', async () => { + await mount() + await beginRenewal() + await act(async () => { + await manager.signinRedirectCallback('http://localhost/callback?code=fresh-explicit') + }) + expect(auth.oidcUser?.access_token).toBe(freshUser.access_token) + + await act(async () => { + navigation.reject(new Error('The older silent request failed')) + }) + expect(auth.error).toBeUndefined() + expect(auth.oidcUser?.access_token).toBe(freshUser.access_token) + expect((await manager.getUser())?.access_token).toBe(freshUser.access_token) + }) + + it('does not dispatch a late iframe failure to a disposed event subscription', async () => { + const dispatch = jest.fn() + const dispose = addOidcEvents({ userManager: manager, dispatch }) + ;(manager.events as any)._accessTokenExpired.raise() + await iframeStarted.promise + dispose() + dispatch.mockClear() + + navigation.reject(new Error('The disposed silent request failed')) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('cannot overwrite a fresh explicit login with an older successful iframe result', async () => { + await mount() + await beginRenewal() + await act(async () => { + await manager.signinRedirect() + await manager.signinRedirectCallback('http://localhost/callback?code=fresh-explicit') + }) + expect(auth.oidcUser?.access_token).toBe(freshUser.access_token) + + await act(async () => { + navigation.resolve({ url: 'http://localhost/callback?code=old-silent' }) + }) + expect((await manager.getUser())?.access_token).toBe(freshUser.access_token) + expect(auth.oidcUser?.access_token).toBe(freshUser.access_token) + expect(auth.error).toBeUndefined() + }) + + it('shares one successful silent operation and publishes its user only once', async () => { + await mount() + const loaded = jest.fn() + manager.events.addUserLoaded(loaded) + const first = manager.signinSilent() + const second = manager.signinSilent() + expect(first).toBe(second) + await iframeStarted.promise + await act(async () => { + navigation.resolve({ url: 'http://localhost/callback?code=old-silent' }) + await Promise.all([first, second]) + }) + expect(loaded).toHaveBeenCalledTimes(1) + expect((await manager.getUser())?.access_token).toBe(staleUser.access_token) + expect(auth.oidcUser?.access_token).toBe(staleUser.access_token) + expect(auth.error).toBeUndefined() + }) + + it('cannot restore a user when an explicit callback completes validation after logout', async () => { + const validation = deferred() + const validationStarted = deferred() + jest.spyOn(UserManager.prototype, 'processSigninResponse').mockImplementationOnce(() => { + validationStarted.resolve() + return validation.promise as any + }) + jest + .spyOn(manager.metadataService, 'getEndSessionEndpoint') + .mockRejectedValueOnce(new Error('End-session is unavailable')) + await mount() + let callback!: Promise + await act(async () => { + callback = manager.signinRedirectCallback('http://localhost/callback?code=old-explicit').catch((error) => error) + await validationStarted.promise + }) + await act(async () => { + await auth.logout() + }) + expect(await manager.getUser()).toBeNull() + await act(async () => { + validation.resolve(staleUser) + await callback + }) + expect(await manager.getUser()).toBeNull() + expect(auth.oidcUser).toBeUndefined() + expect(auth.errorOperation).toBe('logout') + }) + + it('does not navigate from an unmounted callback after a newer login superseded it', async () => { + const validation = deferred() + const validationStarted = deferred() + jest.spyOn(UserManager.prototype, 'processSigninResponse').mockImplementationOnce(() => { + validationStarted.resolve() + return validation.promise as any + }) + const history = { push: jest.fn() } + await act(async () => { + render(Callback} />, element) + await validationStarted.promise + }) + act(() => { + unmountComponentAtNode(element) + }) + await manager.signinRedirect() + await act(async () => { + validation.resolve(staleUser) + }) + expect(history.push).not.toHaveBeenCalled() + }) + + it.each(['success', 'failure'])( + 'does not restart login after an expired-user renewal %s was superseded by logout', + async (outcome) => { + const expired = user('expired-access') + expired.expires_at = Math.floor(Date.now() / 1000) - 1 + await manager.storeUser(expired) + const redirect = jest.spyOn(manager, 'signinRedirect') + jest + .spyOn(manager.metadataService, 'getEndSessionEndpoint') + .mockRejectedValueOnce(new Error('End-session is unavailable')) + await mount() + let login!: Promise + await act(async () => { + login = auth.login().catch((error) => error) + await iframeStarted.promise + }) + await act(async () => { + await auth.logout() + }) + await act(async () => { + if (outcome === 'success') navigation.resolve({ url: 'http://localhost/callback?code=old-silent' }) + else navigation.reject(new Error('The old silent flow failed')) + await login + }) + expect(redirect).not.toHaveBeenCalled() + expect(await manager.getUser()).toBeNull() + expect(auth.oidcUser).toBeUndefined() + expect(auth.errorOperation).toBe('logout') + }, + ) + + it.each(['logout', 'login'])( + 'keeps a newer %s when an old silent user-store write is already pending', + async (action) => { + const oldWrite = deferred() + const oldWriteStarted = deferred() + const store = manager.settings.userStore + const set = store.set.bind(store) + jest.spyOn(store, 'set').mockImplementation((key, value) => { + if (value.includes(staleUser.access_token)) { + oldWriteStarted.resolve() + return oldWrite.promise.then(() => set(key, value)) + } + return set(key, value) + }) + const loaded = jest.fn() + manager.events.addUserLoaded(loaded) + if (action === 'logout') + jest + .spyOn(manager.metadataService, 'getEndSessionEndpoint') + .mockRejectedValueOnce(new Error('End-session is unavailable')) + await mount() + await beginRenewal() + await act(async () => { + navigation.resolve({ url: 'http://localhost/callback?code=old-silent' }) + await oldWriteStarted.promise + }) + let newer!: Promise + await act(async () => { + newer = + action === 'logout' + ? auth.logout() + : manager + .signinRedirect() + .then(() => manager.signinRedirectCallback('http://localhost/callback?code=fresh-explicit')) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + await act(async () => { + oldWrite.resolve() + await newer + }) + + expect(loaded.mock.calls.map(([loadedUser]) => loadedUser.access_token)).not.toContain(staleUser.access_token) + if (action === 'logout') { + expect(await manager.getUser()).toBeNull() + expect(auth.oidcUser).toBeUndefined() + expect(auth.errorOperation).toBe('logout') + } else { + expect((await manager.getUser())?.access_token).toBe(freshUser.access_token) + expect(auth.oidcUser?.access_token).toBe(freshUser.access_token) + expect(auth.error).toBeUndefined() + } + }, + ) +}) diff --git a/apps/sensenet/test/oidc-provider-lifecycle.test.tsx b/apps/sensenet/test/oidc-provider-lifecycle.test.tsx new file mode 100644 index 0000000000..140404ec1a --- /dev/null +++ b/apps/sensenet/test/oidc-provider-lifecycle.test.tsx @@ -0,0 +1,85 @@ +import { User, UserManager } from 'oidc-client' +import React from 'react' +import { render, unmountComponentAtNode } from 'react-dom' +import { act } from 'react-dom/test-utils' +import { AuthenticationProvider, useOidcAuthentication } from '../../../packages/sn-authentication-oidc-react/src' + +let mockManager: UserManager +jest.mock('../../../packages/sn-authentication-oidc-react/src/authentication-service', () => ({ + authenticationService: () => mockManager, + getUserManager: () => mockManager, +})) + +const settings = { + authority: 'https://identity.example.invalid', + client_id: 'provider-lifecycle-test', + redirect_uri: 'http://localhost/callback', + automaticSilentRenew: false, + monitorSession: false, +} +const Probe = () => { + const auth = useOidcAuthentication() + return
{auth.isLoading ? 'Loading' : auth.oidcUser ? 'Signed in' : auth.error || 'Signed out'}
+} + +describe('OIDC provider initialization lifecycle', () => { + let element: HTMLDivElement + + beforeEach(() => { + window.history.replaceState(null, '', '/') + mockManager = new UserManager(settings) + element = document.createElement('div') + document.body.appendChild(element) + }) + + afterEach(() => { + act(() => { + unmountComponentAtNode(element) + }) + mockManager.events.unload() + mockManager.stopSilentRenew() + element.remove() + sessionStorage.clear() + }) + + const mount = () => + act(async () => { + render( + + + , + element, + ) + }) + + it('finishes initialization when no stored user exists', async () => { + await mount() + expect(element.textContent).toBe('Signed out') + }) + + it('shows a failed user-store read instead of loading forever', async () => { + jest.spyOn(mockManager, 'getUser').mockRejectedValueOnce(new Error('User store unavailable')) + await mount() + expect(element.textContent).toBe('User store unavailable') + }) + + it('cannot restore an initial user result after the session has been unloaded', async () => { + let resolveUser!: (user: User) => void + jest.spyOn(mockManager, 'getUser').mockReturnValueOnce( + new Promise((resolve) => { + resolveUser = resolve + }), + ) + await mount() + expect(element.textContent).toBe('Loading') + + await act(async () => { + mockManager.events.unload() + }) + expect(element.textContent).toBe('Signed out') + await act(async () => { + resolveUser({ profile: { sub: 'old-user' } } as User) + }) + expect(element.textContent).toBe('Signed out') + }) +}) diff --git a/apps/sensenet/test/oidc-repository-recovery.test.tsx b/apps/sensenet/test/oidc-repository-recovery.test.tsx new file mode 100644 index 0000000000..b2db1fa70c --- /dev/null +++ b/apps/sensenet/test/oidc-repository-recovery.test.tsx @@ -0,0 +1,197 @@ +import { User, UserManager } from 'oidc-client' +import React from 'react' +import { render, unmountComponentAtNode } from 'react-dom' +import { act, Simulate } from 'react-dom/test-utils' +import { BrowserRouter } from 'react-router-dom' +import { useOidcAuthentication } from '../../../packages/sn-authentication-oidc-react/src' +import { RepositoryProvider } from '../src/context/repository-provider' +import { clearActiveRepositorySelection, oidcAuthConfigKey } from '../src/services/repository-session' + +let mockManager: UserManager +const mockLogger = { error: jest.fn(), warning: jest.fn() } +jest.mock('../../../packages/sn-authentication-oidc-react/src/authentication-service', () => ({ + authenticationService: () => mockManager, + getUserManager: () => mockManager, + removeUserManager: jest.fn(), +})) +jest.mock('@sensenet/hooks-react', () => ({ + RepositoryContext: jest.requireActual('react').createContext(null), + useLogger: () => mockLogger, +})) +jest.mock('@sensenet/client-core', () => ({ + Repository: class { + configuration: any + constructor(configuration: any) { + this.configuration = configuration + } + reloadSchema() { + return Promise.resolve() + } + }, +})) +jest.mock('../src/globalStyles', () => ({ useGlobalStyles: () => ({ full: 'full' }) })) +jest.mock('../src/hooks', () => ({})) +jest.mock('../src/components/full-screen-loader', () => ({ + FullScreenLoader: function FullScreenLoader() { + return
Loading
+ }, +})) +jest.mock('../src/components/NotificationComponent', () => ({ NotificationComponent: () => null })) +jest.mock('../src/components/login/not-authenticated-override', () => ({ NotAuthenticatedOverride: () => null })) +jest.mock('../src/components/login/session-lost-override', () => ({ SessionLostOverride: () => null })) +jest.mock( + '../src/components/login/login-page', + () => + function LoginPage() { + return
Repository picker
+ }, +) +jest.mock('../src/components/login/auth-override-skeleton', () => ({ + AuthOverrideSkeleton: ({ primaryText, secondaryText, buttonText, buttonOnClick }: any) => ( + <> +

{primaryText}

+

{secondaryText}

+ {buttonOnClick && } + + ), +})) +const repository = 'https://repo.example.invalid' +const settings = { + authority: 'https://identity.example.invalid', + client_id: 'repository-recovery-test', + redirect_uri: 'http://localhost/callback', + extraQueryParams: { snrepo: repository }, + automaticSilentRenew: false, + monitorSession: false, +} +jest.mock('../src/services/auth-config', () => ({ + getAuthConfig: () => + Promise.resolve({ authServerSettings: { type: 'IdentityServer' }, userManagerSettings: settings }), +})) +const Probe = () => { + const auth = useOidcAuthentication() + return ( + <> +
Authenticated content
+ + + ) +} + +describe('IdentityServer repository logout recovery', () => { + const originalLocation = window.location + const assign = jest.fn() + let element: HTMLDivElement + const changeAuthType = jest.fn() + const prepareAuthentication = jest.fn().mockResolvedValue(true) + + beforeEach(async () => { + jest.clearAllMocks() + localStorage.clear() + sessionStorage.clear() + window.history.replaceState(null, '', '/') + delete (window as any).location + window.location = { ...originalLocation, assign } as Location + mockManager = new UserManager(settings) + await mockManager.storeUser( + new User({ + profile: { sub: '42' }, + access_token: 'synthetic-access', + id_token: 'synthetic-id-token', + token_type: 'Bearer', + scope: 'openid', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }), + ) + element = document.createElement('div') + document.body.appendChild(element) + }) + + afterEach(() => { + act(() => { + unmountComponentAtNode(element) + }) + mockManager.events.unload() + mockManager.stopSilentRenew() + element.remove() + localStorage.clear() + sessionStorage.clear() + window.location = originalLocation + }) + + it('shows a recovery action when end-session discovery fails after the local user is removed', async () => { + jest + .spyOn(mockManager.metadataService, 'getEndSessionEndpoint') + .mockRejectedValueOnce(new Error('Identity provider is unavailable')) + localStorage.setItem('unrelated-repository-token', 'keep-this-session') + await act(async () => { + render( + + + + + , + element, + ) + }) + expect(element.textContent).toContain('Authenticated content') + + await act(async () => { + Simulate.click(Array.from(element.querySelectorAll('button')).find((button) => button.textContent === 'Logout')!) + }) + + expect(await mockManager.getUser()).toBeNull() + expect(localStorage.getItem(oidcAuthConfigKey)).toBeNull() + expect(element.querySelector('[role="alert"]')?.textContent).toContain('Sign-out could not be confirmed') + expect(element.textContent).not.toContain('Authenticated content') + expect(changeAuthType).not.toHaveBeenCalled() + expect(localStorage.getItem('unrelated-repository-token')).toBe('keep-this-session') + + await act(async () => { + Simulate.click( + Array.from(element.querySelectorAll('button')).find( + (button) => button.textContent === 'Choose another repository', + )!, + ) + }) + expect(assign).toHaveBeenCalledWith('/') + expect(localStorage.getItem('unrelated-repository-token')).toBe('keep-this-session') + }) + + it('shows an IdentityServer login error without switching the repository to SNAuth', async () => { + await mockManager.removeUser() + const signinRedirect = jest + .spyOn(mockManager, 'signinRedirect') + .mockRejectedValue(new Error('Identity provider is unavailable')) + await act(async () => { + render( + + + + + , + element, + ) + }) + + expect(signinRedirect).toHaveBeenCalledTimes(1) + expect(element.querySelector('[role="alert"]')?.textContent).toContain('Authentication failed') + expect(element.textContent).not.toContain('Authenticated content') + expect(changeAuthType).not.toHaveBeenCalled() + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ message: 'Identity provider is unavailable' }) }), + ) + }) +}) diff --git a/apps/sensenet/test/snauth-repository-recovery.test.tsx b/apps/sensenet/test/snauth-repository-recovery.test.tsx new file mode 100644 index 0000000000..c2685fd7da --- /dev/null +++ b/apps/sensenet/test/snauth-repository-recovery.test.tsx @@ -0,0 +1,212 @@ +import { webcrypto } from 'crypto' +import { TextEncoder } from 'util' +import React from 'react' +import { render, unmountComponentAtNode } from 'react-dom' +import { act, Simulate } from 'react-dom/test-utils' +import { getPendingExternalLoginTarget, prepareExternalLogin } from '../../../packages/sn-auth-react/src/external-login' +import { + convertAuthTokenApiCall, + getUserDetailsApiCall, + refreshTokenApiCall, +} from '../../../packages/sn-auth-react/src/server-actions' +import { getAccessToken, setAccessToken, setRefreshToken } from '../../../packages/sn-auth-react/src/storage-helpers' +import { SnAuthRepositoryProvider } from '../src/context/sn-auth-repository-provider' +import { + getSelectedSnAuthRepository, + getSnAuthStorageKeyPrefix, + setSelectedSnAuthRepository, + setSnAuthRepositoryConfig, + startSnAuthRepositoryLogin, +} from '../src/services/repository-session' + +const mockLogger = { error: jest.fn(), warning: jest.fn() } +jest.mock('../../../packages/sn-auth-react/src/server-actions') +jest.mock('@sensenet/hooks-react', () => ({ + RepositoryContext: jest.requireActual('react').createContext(null), + useLogger: () => mockLogger, +})) +jest.mock('@sensenet/client-core', () => ({ + Repository: class { + configuration: any + constructor(configuration: any) { + this.configuration = configuration + } + reloadSchema() { + return Promise.resolve() + } + }, +})) +jest.mock('../src/globalStyles', () => ({ useGlobalStyles: () => ({ full: 'full' }) })) +jest.mock('../src/hooks', () => ({ useQuery: () => new URLSearchParams(window.location.search) })) +jest.mock('../src/components/full-screen-loader', () => ({ FullScreenLoader: () =>
Loading
})) +jest.mock('../src/components/NotificationComponent', () => ({ NotificationComponent: () => null })) +jest.mock( + '../src/components/login/login-page', + () => + function LoginPage() { + return
Repository picker
+ }, +) +jest.mock('../src/components/login/auth-override-skeleton', () => ({ + AuthOverrideSkeleton: ({ primaryText, secondaryText, buttonText, buttonOnClick }: any) => ( + <> +

{primaryText}

+

{secondaryText}

+ + + ), +})) +const repository = 'https://repo.example.invalid' +const server = 'https://auth.example.invalid' +const configuration = { + authServerSettings: { type: 'SNAuth' }, + userManagerSettings: { authority: server, extraQueryParams: { snrepo: repository } }, +} +jest.mock('../src/services/auth-config', () => ({ getAuthConfig: () => Promise.resolve(configuration) })) + +describe('SNAuth repository failure recovery', () => { + let element: HTMLDivElement + const changeAuthType = jest.fn() + const prepareAuthentication = jest.fn().mockResolvedValue(true) + const originalCrypto = window.crypto + const originalEncoder = global.TextEncoder + + beforeEach(() => { + jest.clearAllMocks() + localStorage.clear() + sessionStorage.clear() + window.history.replaceState(null, '', '/') + setSnAuthRepositoryConfig(repository, configuration) + localStorage.setItem('unrelated-repository-token', 'keep-this-session') + element = document.createElement('div') + document.body.appendChild(element) + Object.defineProperty(window, 'crypto', { configurable: true, value: webcrypto }) + global.TextEncoder = TextEncoder as any + }) + + afterEach(() => { + act(() => { + unmountComponentAtNode(element) + }) + element.remove() + Object.defineProperty(window, 'crypto', { configurable: true, value: originalCrypto }) + global.TextEncoder = originalEncoder + localStorage.clear() + sessionStorage.clear() + }) + + const mount = (url = repository) => + act(async () => { + render( + +
Authenticated content
+
, + element, + ) + }) + + const expectRecovery = async (message: string) => { + expect(element.querySelector('[role="alert"]')?.textContent).toContain(message) + expect(element.textContent).not.toContain('Loading') + expect(element.textContent).not.toContain('Authenticated content') + expect(changeAuthType).not.toHaveBeenCalled() + await act(async () => { + Simulate.click(element.querySelector('button')!) + }) + expect(element.textContent).toContain('Repository picker') + expect(element.textContent).not.toContain('Loading') + expect(localStorage.getItem('unrelated-repository-token')).toBe('keep-this-session') + } + + it('shows failed startup refresh and returns to the repository picker without a stale loader', async () => { + const prefix = getSnAuthStorageKeyPrefix(repository) + const expired = `header.${btoa(JSON.stringify({ exp: 1 }))}.signature` + setAccessToken(expired, prefix) + setRefreshToken('expired-refresh', prefix) + ;(refreshTokenApiCall as jest.Mock).mockRejectedValue(new Error('Refresh expired')) + await mount() + expect(getAccessToken(prefix)).toBeNull() + await expectRecovery('Failed to validate or refresh token') + }) + + it.each(['Local', 'IdentityServer'])( + 'preserves the global %s mode and prior repository for an unknown-state callback', + async (previousMode) => { + localStorage.setItem('authType', previousMode) + window.history.replaceState(null, '', '/authentication/callback?auth_code=unsolicited-code&state=unknown-state') + const globalConfig = localStorage.getItem('sn-auth-config') + setSelectedSnAuthRepository('https://second.example.invalid') + await mount() + expect(getSelectedSnAuthRepository()).toBe('https://second.example.invalid') + expect(localStorage.getItem('sn-auth-config')).toBe(globalConfig) + expect(localStorage.getItem('authType')).toBe(previousMode) + expect(convertAuthTokenApiCall).not.toHaveBeenCalled() + await expectRecovery('does not match an active login') + }, + ) + + it('shows secure-login initialization failures without silently changing authentication type', async () => { + Object.defineProperty(window, 'crypto', { configurable: true, value: {} }) + startSnAuthRepositoryLogin(repository) + await mount() + await expectRecovery('Could not start secure sign-in') + }) + + it.each(['Local', 'IdentityServer'])( + 'restores the tab-bound callback repository after another tab changes the global mode to %s', + async (previousMode) => { + localStorage.setItem('authType', previousMode) + const callbackUri = '/authentication/callback' + const pending = new URL(await prepareExternalLogin({ repoUrl: repository, authServerUrl: server, callbackUri })) + const otherRepository = 'https://second.example.invalid' + const otherConfiguration = { + ...configuration, + userManagerSettings: { + authority: 'https://second-auth.example.invalid', + extraQueryParams: { snrepo: otherRepository }, + }, + } + setSnAuthRepositoryConfig(otherRepository, otherConfiguration) + setSelectedSnAuthRepository(otherRepository) + const otherPrefix = getSnAuthStorageKeyPrefix(otherRepository) + setAccessToken('other-access', otherPrefix) + setRefreshToken('other-refresh', otherPrefix) + const state = pending.searchParams.get('state')! + window.history.replaceState(null, '', `${callbackUri}?${new URLSearchParams({ auth_code: 'bound-code', state })}`) + ;(convertAuthTokenApiCall as jest.Mock).mockResolvedValue({ accessToken: 'a-access', refreshToken: 'a-refresh' }) + ;(getUserDetailsApiCall as jest.Mock).mockResolvedValue({ Id: 42 }) + await mount('') + expect(prepareAuthentication).not.toHaveBeenCalled() + expect(convertAuthTokenApiCall).toHaveBeenCalledWith(server, 'bound-code', expect.any(String)) + expect(getUserDetailsApiCall).toHaveBeenCalledWith(repository, 'a-access') + expect(getAccessToken(getSnAuthStorageKeyPrefix(repository))).toBe('a-access') + expect(getAccessToken(otherPrefix)).toBe('other-access') + expect(getSelectedSnAuthRepository()).toBe(repository) + expect(localStorage.getItem('authType')).toBe('SNAuth') + expect(element.textContent).toContain('Authenticated content') + expect(getPendingExternalLoginTarget(callbackUri, state)).toBeUndefined() + }, + ) + + it('refuses a cached config whose authority differs from the tab-bound callback', async () => { + const callbackUri = '/authentication/callback' + const pending = new URL(await prepareExternalLogin({ repoUrl: repository, authServerUrl: server, callbackUri })) + setSnAuthRepositoryConfig(repository, { + ...configuration, + userManagerSettings: { ...configuration.userManagerSettings, authority: 'https://wrong-auth.example.invalid' }, + }) + setSelectedSnAuthRepository('https://second.example.invalid') + const state = pending.searchParams.get('state')! + window.history.replaceState(null, '', `${callbackUri}?${new URLSearchParams({ auth_code: 'bound-code', state })}`) + const globalConfig = localStorage.getItem('sn-auth-config') + await mount('') + expect(convertAuthTokenApiCall).not.toHaveBeenCalled() + expect(getSelectedSnAuthRepository()).toBe('https://second.example.invalid') + expect(localStorage.getItem('sn-auth-config')).toBe(globalConfig) + expect(getPendingExternalLoginTarget(callbackUri, state)?.repoUrl).toBe(repository) + await expectRecovery('does not match an active login') + }) +}) diff --git a/packages/sn-auth-react/src/components/authentication-provider.tsx b/packages/sn-auth-react/src/components/authentication-provider.tsx index 9111c645e3..7dc9b07721 100644 --- a/packages/sn-auth-react/src/components/authentication-provider.tsx +++ b/packages/sn-auth-react/src/components/authentication-provider.tsx @@ -1,4 +1,5 @@ -import React, { createContext, ReactNode, useEffect, useState } from 'react' +import React, { createContext, ReactNode, useEffect, useRef, useState } from 'react' +import { clearPendingExternalLogin, consumeExternalLogin, prepareExternalLogin } from '../external-login' import { LoginRequest } from '../models/login-request' import { LoginResponse } from '../models/login-response' import { MultiFactorLoginRequest } from '../models/multi-factor-login-request' @@ -31,8 +32,8 @@ export interface AuthenticationContextState { isLoading: boolean user?: User login: (loginRequest: LoginRequest) => Promise - externalLogin: () => void - multiFactorLogin: (multiFactorRequest: MultiFactorLoginRequest) => void + externalLogin: () => Promise + multiFactorLogin: (multiFactorRequest: MultiFactorLoginRequest) => Promise forgotPassword: (email: string) => Promise passwordRecovery: (token: string, password: string) => Promise changePassword: (password: string) => Promise @@ -53,6 +54,7 @@ export interface AuthenticationProviderProps { onInitialized?: () => void onNoInitialization?: () => void onLogout?: () => void + onLogoutError?: (error: unknown) => void } } @@ -68,276 +70,210 @@ const TOKEN_EXPIRY_THRESHOLD = 10 * 1000 export const AuthenticationProvider = (props: AuthenticationProviderProps) => { const [authState, setState] = useState({ isLoading: true }) - const [path, setPath] = useState(window.location.pathname) - const { storageKeyPrefix } = props - - const setNewPath = () => setPath(window.location.pathname) - - useEffect(() => { - window.addEventListener('popstate', setNewPath, false) - return () => window.removeEventListener('popstate', setNewPath, false) - }, []) + const [path, setPath] = useState(window.location.pathname) + const state = useRef(authState) + const mounted = useRef(true) + const generation = useRef(0) + const refreshing = useRef() + const { storageKeyPrefix, repoUrl, authServerUrl } = props + const { callbackUri } = props.snAuthConfiguration + const target = { repoUrl, authServerUrl, callbackUri } + const callbacks = useRef(props.eventCallbacks) + callbacks.current = props.eventCallbacks + + const active = (version: number) => mounted.current && generation.current === version + const publish = (next: AuthState) => { + state.current = next + if (mounted.current) setState(next) + } + const clearSession = (error?: string) => { + generation.current++ + removeAccessToken(storageKeyPrefix) + removeRefreshToken(storageKeyPrefix) + clearPendingExternalLogin(target) + publish({ isLoading: false, ...(error ? { error } : {}) }) + } + const saveSession = (version: number, accessToken: string, refreshToken: string, user: User) => { + if (!active(version)) return + setAccessTokenStorage(accessToken, storageKeyPrefix) + setRefreshTokenStorage(refreshToken, storageKeyPrefix) + publish({ accessToken, refreshToken, user, isLoading: false }) + } + const beginSignIn = () => { + const version = ++generation.current + publish({ isLoading: true }) + return version + } useEffect(() => { - if (path === props.snAuthConfiguration.callbackUri) { - convertAuthToken().finally(() => { - window.history.pushState({}, '', '/') - setNewPath() - }) + mounted.current = true + return () => { + mounted.current = false + generation.current++ } - }, [path]) + }, [repoUrl, authServerUrl, storageKeyPrefix]) useEffect(() => { - const validateAndRefreshToken = async () => { - if (path !== props.snAuthConfiguration.callbackUri) { - setState({ isLoading: true }) - try { - let accessToken = getAccessToken(storageKeyPrefix) - let refreshToken = getRefreshToken(storageKeyPrefix) - if (accessToken && refreshToken) { - // Refresh expired (or nearly expired) tokens before sending an authenticated request. - const isValid = - !isTokenAboutToExpire(accessToken, TOKEN_EXPIRY_THRESHOLD) && - (await validateTokenApiCall(props.authServerUrl, accessToken)) - - if (!isValid) { - const response = await refreshAccessToken(refreshToken) - if (!response?.accessToken) throw new Error() - const { accessToken: newAccessToken, refreshToken: newRefreshToken } = response - accessToken = newAccessToken - refreshToken = newRefreshToken - setAccessAndRefreshTokenStorage(accessToken, refreshToken) - } + const updatePath = () => setPath(window.location.pathname) + window.addEventListener('popstate', updatePath) + return () => window.removeEventListener('popstate', updatePath) + }, []) - const userDetails = await getUserDetailsApiCall(props.repoUrl, accessToken) - setState({ - accessToken, - refreshToken, - user: userDetails, - isLoading: false, - }) - props.eventCallbacks?.onInitialized?.() - } else { - props.eventCallbacks?.onNoInitialization?.() - setState({ - isLoading: false, - }) - } - } catch (err) { - setState({ - error: 'Failed to validate or refresh token', + useEffect(() => { + if (path !== callbackUri) return + const version = beginSignIn() + ;(async () => { + try { + const query = new URLSearchParams(window.location.search) + const authToken = query.get('auth_code') + if (!authToken) throw new Error('The sign-in response contains no authorization code. Start sign-in again.') + const verifier = consumeExternalLogin(target, query.get('state')) + const response = await convertAuthTokenApiCall(authServerUrl, authToken, verifier) + if (!active(version)) return + const user = await getUserDetailsApiCall(repoUrl, response.accessToken) + saveSession(version, response.accessToken, response.refreshToken, user) + } catch (error) { + if (active(version)) { + publish({ + error: error instanceof Error ? error.message : 'Failed to complete sign-in. Start sign-in again.', isLoading: false, }) - logoutLocal() + } + } finally { + if (active(version)) { + window.history.replaceState({}, '', '/') + setPath('/') } } - } + })() + }, [path, callbackUri, repoUrl, authServerUrl, storageKeyPrefix]) - validateAndRefreshToken() - }, []) + useEffect(() => { + if (path === callbackUri) return + const version = generation.current + publish({ isLoading: true }) + ;(async () => { + try { + let accessToken = getAccessToken(storageKeyPrefix) + let refreshToken = getRefreshToken(storageKeyPrefix) + if (!accessToken || !refreshToken) { + publish({ isLoading: false }) + callbacks.current?.onNoInitialization?.() + return + } + const valid = + !isTokenAboutToExpire(accessToken, TOKEN_EXPIRY_THRESHOLD) && + (await validateTokenApiCall(authServerUrl, accessToken)) + if (!active(version)) return + if (!valid) { + const refreshed = await refreshTokenApiCall(authServerUrl, refreshToken) + if (!active(version)) return + ;({ accessToken, refreshToken } = refreshed) + } + const user = await getUserDetailsApiCall(repoUrl, accessToken) + if (!active(version)) return + saveSession(version, accessToken, refreshToken, user) + callbacks.current?.onInitialized?.() + } catch { + if (active(version)) clearSession('Failed to validate or refresh token. Sign in again.') + } + })() + }, [repoUrl, authServerUrl, storageKeyPrefix]) useEffect(() => { - const intervalId = setInterval(async () => { + const interval = setInterval(async () => { + const { current } = state + const version = generation.current if ( - authState.accessToken && - authState.refreshToken && - isTokenAboutToExpire(authState.accessToken, TOKEN_EXPIRY_THRESHOLD) && - !authState.isLoading && - !authState.error - ) { - setState({ - ...authState, - isLoading: true, - }) - try { - const response = await refreshAccessToken(authState.refreshToken) - if (!response?.accessToken) throw new Error() - - const userDetails = await getUserDetailsApiCall(props.repoUrl, response.accessToken) - setState({ - accessToken: response.accessToken, - refreshToken: response.refreshToken, - user: userDetails, - isLoading: false, - }) - setAccessAndRefreshTokenStorage(response.accessToken, response.refreshToken) - } catch (err) { - setState({ - error: 'Failed to refresh token', - isLoading: false, - }) - logoutLocal() - } + !mounted.current || + !current.accessToken || + !current.refreshToken || + current.isLoading || + current.error || + refreshing.current === version || + !isTokenAboutToExpire(current.accessToken, TOKEN_EXPIRY_THRESHOLD) + ) + return + refreshing.current = version + publish({ ...current, isLoading: true }) + try { + const response = await refreshTokenApiCall(authServerUrl, current.refreshToken) + if (!active(version)) return + const user = await getUserDetailsApiCall(repoUrl, response.accessToken) + saveSession(version, response.accessToken, response.refreshToken, user) + } catch { + if (active(version)) clearSession('Failed to refresh token. Sign in again.') + } finally { + if (refreshing.current === version) refreshing.current = undefined } }, TOKEN_EXPIRY_THRESHOLD) + return () => clearInterval(interval) + }, [repoUrl, authServerUrl, storageKeyPrefix]) - return () => clearInterval(intervalId) - }, [authState]) - - const externalLogin = () => { - window.location.replace( - `${props.authServerUrl}/Login?RedirectUrl=${window.location.origin}&CallbackUri=${props.snAuthConfiguration.callbackUri}`, - ) - } - - const convertAuthToken = async () => { - const urlParams = new URLSearchParams(window.location.search) - const authToken = urlParams.get('auth_code') - - setState({ - ...authState, - isLoading: true, - }) + const externalLogin = async () => { + const version = beginSignIn() try { - if (authToken) { - const { accessToken, refreshToken } = await convertAuthTokenApiCall(props.authServerUrl, authToken) - - const user = await getUserDetailsApiCall(props.repoUrl, accessToken) - setState({ - user, - accessToken, - refreshToken, - isLoading: false, - }) - setAccessAndRefreshTokenStorage(accessToken, refreshToken) - } - } catch (e) { - setState({ - error: 'Failed to convert auth token', - isLoading: false, - }) - } - } - - const refreshAccessToken = async (refToken: string) => { - if (refToken) { - try { - setState({ - ...authState, - isLoading: true, - }) - const { accessToken, refreshToken } = await refreshTokenApiCall(props.authServerUrl, refToken) - - return { accessToken, refreshToken } - } catch (e) { - console.error(e) - setState({ - ...authState, - error: 'Failed to refresh access token', - isLoading: false, - }) - logoutLocal() - } + const destination = await prepareExternalLogin(target, () => active(version)) + if (active(version)) window.location.replace(destination) + } catch (error) { + if (active(version)) clearSession('Could not start secure sign-in. Start sign-in again.') + throw error } } const logout = () => { - if (authState.accessToken && props.authServerUrl) - logoutApiCall(props.authServerUrl, authState.accessToken) - .catch((e) => { - console.error(e) - }) - .finally(() => { - props.eventCallbacks?.onLogout?.() - logoutLocal() - }) - else logoutLocal() + const accessToken = state.current.accessToken || getAccessToken(storageKeyPrefix) + const onLogoutError = callbacks.current?.onLogoutError + clearSession() + window.history.replaceState({}, '', '/') + setPath('/') + callbacks.current?.onLogout?.() + if (accessToken && authServerUrl) { + logoutApiCall(authServerUrl, accessToken).catch((error) => { + console.error('Server logout could not be confirmed.') + onLogoutError?.(error) + }) + } } - const login = async (loginRequest: LoginRequest): Promise => { - setState({ - ...authState, - isLoading: true, - }) + const login = async (request: LoginRequest): Promise => { + const version = beginSignIn() try { - const response = await loginApiCall(props.authServerUrl, loginRequest) - + const response = await loginApiCall(authServerUrl, { ...request, siteUrl: repoUrl }) + if (!active(version)) throw new Error('Sign-in was cancelled.') if (!response.multiFactorRequired && response.accessToken && response.refreshToken) { - const user = await getUserDetailsApiCall(props.repoUrl, response.accessToken) - setState({ - user, - accessToken: response.accessToken, - refreshToken: response.refreshToken, - isLoading: false, - }) - setAccessAndRefreshTokenStorage(response.accessToken, response.refreshToken) - } - + const user = await getUserDetailsApiCall(repoUrl, response.accessToken) + if (!active(version)) throw new Error('Sign-in was cancelled.') + saveSession(version, response.accessToken, response.refreshToken, user) + } else publish({ isLoading: false }) return response - } catch (e) { - setState({ - ...authState, - error: 'Error during login.', - isLoading: false, - }) - logoutLocal() - - throw e + } catch (error) { + if (active(version)) clearSession('Error during login. Sign in again.') + throw error } } - const multiFactorLogin = async (multiFactorRequest: MultiFactorLoginRequest): Promise => { + const multiFactorLogin = async (request: MultiFactorLoginRequest): Promise => { + const version = beginSignIn() try { - setState({ - ...authState, - isLoading: true, - }) - const response = await multiFactorApiCall(props.authServerUrl, multiFactorRequest) - - if (response.accessToken && response.refreshToken) { - const user = await getUserDetailsApiCall(props.repoUrl, response.accessToken) - setState({ - user, - accessToken: response.accessToken, - refreshToken: response.refreshToken, - isLoading: false, - }) - setAccessAndRefreshTokenStorage(response.accessToken, response.refreshToken) - - return response - } else { - throw new Error() - } - } catch (e) { - setState({ - ...authState, - error: 'Error during multi-factor validation.', - }) - logoutLocal() - - throw e + const response = await multiFactorApiCall(authServerUrl, { ...request, siteUrl: repoUrl }) + if (!active(version)) throw new Error('Sign-in was cancelled.') + if (!response.accessToken || !response.refreshToken) throw new Error('Incomplete sign-in response.') + const user = await getUserDetailsApiCall(repoUrl, response.accessToken) + if (!active(version)) throw new Error('Sign-in was cancelled.') + saveSession(version, response.accessToken, response.refreshToken, user) + return response + } catch (error) { + if (active(version)) clearSession('Error during multi-factor validation. Sign in again.') + throw error } } - const forgotPassword = async (email: string) => { - await forgotPasswordApiCall(props.authServerUrl, { email }) - } - - const passwordRecovery = async (token: string, password: string) => { - await passwordRecoveryApiCall(props.authServerUrl, { token, password }) - } - + const forgotPassword = async (email: string) => forgotPasswordApiCall(authServerUrl, { email }) + const passwordRecovery = async (token: string, password: string) => + passwordRecoveryApiCall(authServerUrl, { token, password }) const changePassword = async (password: string) => { - if (authState.accessToken) changePasswordApiCall(props.authServerUrl, authState.accessToken, { password }) - } - - const logoutLocal = () => { - setState({ - ...authState, - accessToken: undefined, - user: undefined, - refreshToken: undefined, - }) - - removeAccessToken(storageKeyPrefix) - removeRefreshToken(storageKeyPrefix) - - window.history.pushState({}, '', '/') - } - - const setAccessAndRefreshTokenStorage = (accessToken: string, refreshToken: string) => { - setAccessTokenStorage(accessToken, storageKeyPrefix) - setRefreshTokenStorage(refreshToken, storageKeyPrefix) + if (state.current.accessToken) await changePasswordApiCall(authServerUrl, state.current.accessToken, { password }) } return ( @@ -355,7 +291,7 @@ export const AuthenticationProvider = (props: AuthenticationProviderProps) => { changePassword, multiFactorLogin, }}> - + {props.children} diff --git a/packages/sn-auth-react/src/external-login.ts b/packages/sn-auth-react/src/external-login.ts new file mode 100644 index 0000000000..34a50d4201 --- /dev/null +++ b/packages/sn-auth-react/src/external-login.ts @@ -0,0 +1,98 @@ +const pendingLifetime = 10 * 60 * 1000 +const pendingKeyPrefix = 'sn-auth-pending-login:' + +type LoginTarget = { repoUrl: string; authServerUrl: string; callbackUri: string } +type PendingLogin = LoginTarget & { state: string; codeVerifier: string; createdAt: number } + +const normalize = (url: string) => url.replace(/\/+$/, '') +const keyFor = ({ repoUrl, authServerUrl, callbackUri }: LoginTarget) => + `${pendingKeyPrefix}${encodeURIComponent(normalize(repoUrl))}:${encodeURIComponent( + normalize(authServerUrl), + )}:${encodeURIComponent(callbackUri)}` +const base64Url = (bytes: Uint8Array) => + window + .btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + +export const clearPendingExternalLogin = (target: LoginTarget) => window.sessionStorage.removeItem(keyFor(target)) + +const readPending = (key: string): PendingLogin | undefined => { + try { + return JSON.parse(window.sessionStorage.getItem(key) || 'null') || undefined + } catch { + return undefined + } +} + +const usablePending = (pending: PendingLogin | undefined, state: string | null, callbackUri: string) => + !!state && + !!pending && + pending.state === state && + typeof pending.repoUrl === 'string' && + typeof pending.authServerUrl === 'string' && + pending.callbackUri === callbackUri && + /^[A-Za-z0-9_-]{43}$/.test(pending.codeVerifier) && + Number.isFinite(pending.createdAt) && + pending.createdAt <= Date.now() && + Date.now() - pending.createdAt <= pendingLifetime + +/** Find a callback's tab-local target without consuming its one-use state. */ +export function getPendingExternalLoginTarget(callbackUri: string, state: string | null): LoginTarget | undefined { + for (let index = 0; index < window.sessionStorage.length; index++) { + const key = window.sessionStorage.key(index) + if (!key?.startsWith(pendingKeyPrefix)) continue + const pending = readPending(key) + if (usablePending(pending, state, callbackUri) && key === keyFor(pending!)) { + return { repoUrl: pending!.repoUrl, authServerUrl: pending!.authServerUrl, callbackUri } + } + } + return undefined +} + +/** Prepare an authorization request bound to this tab and repository. */ +export async function prepareExternalLogin(target: LoginTarget, isActive: () => boolean = () => true): Promise { + if (!window.crypto?.getRandomValues || !window.crypto.subtle) { + throw new Error('Secure browser cryptography is unavailable. Sign in from HTTPS or localhost.') + } + const state = base64Url(window.crypto.getRandomValues(new Uint8Array(32))) + const codeVerifier = base64Url(window.crypto.getRandomValues(new Uint8Array(32))) + const challenge = base64Url( + new Uint8Array(await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))), + ) + const pending: PendingLogin = { + repoUrl: normalize(target.repoUrl), + authServerUrl: normalize(target.authServerUrl), + callbackUri: target.callbackUri, + state, + codeVerifier, + createdAt: Date.now(), + } + if (!isActive()) throw new Error('Sign-in was cancelled.') + window.sessionStorage.setItem(keyFor(target), JSON.stringify(pending)) + const url = new URL(`${normalize(target.authServerUrl)}/Login`) + url.search = new URLSearchParams({ + RedirectUrl: window.location.origin, + CallbackUri: target.callbackUri, + state, + codeChallenge: challenge, + codeChallengeMethod: 'S256', + }).toString() + return url.href +} + +/** Consume a matching pending request before exchanging its authorization code. */ +export function consumeExternalLogin(target: LoginTarget, state: string | null): string { + const pending = readPending(keyFor(target)) + if ( + !usablePending(pending, state, target.callbackUri) || + !pending || + pending.repoUrl !== normalize(target.repoUrl) || + pending.authServerUrl !== normalize(target.authServerUrl) + ) { + throw new Error('The sign-in response does not match an active login. Start sign-in again.') + } + clearPendingExternalLogin(target) + return pending.codeVerifier +} diff --git a/packages/sn-auth-react/src/index.ts b/packages/sn-auth-react/src/index.ts index 4db1ee6b17..dc9c6ddf08 100644 --- a/packages/sn-auth-react/src/index.ts +++ b/packages/sn-auth-react/src/index.ts @@ -2,4 +2,5 @@ export { useSnAuth } from './useSnAuth' export { AuthenticationProvider } from './components/authentication-provider' export { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from './constants' export { getStorageKey } from './storage-helpers' +export { getPendingExternalLoginTarget } from './external-login' export * from './models' diff --git a/packages/sn-auth-react/src/models/login-request.ts b/packages/sn-auth-react/src/models/login-request.ts index 026d876996..babf05d65a 100644 --- a/packages/sn-auth-react/src/models/login-request.ts +++ b/packages/sn-auth-react/src/models/login-request.ts @@ -1,4 +1,5 @@ export interface LoginRequest { loginName: string password: string + siteUrl?: string } diff --git a/packages/sn-auth-react/src/models/multi-factor-login-request.ts b/packages/sn-auth-react/src/models/multi-factor-login-request.ts index 8fc0ca6d13..17c2af3ce8 100644 --- a/packages/sn-auth-react/src/models/multi-factor-login-request.ts +++ b/packages/sn-auth-react/src/models/multi-factor-login-request.ts @@ -1,4 +1,5 @@ export interface MultiFactorLoginRequest { multiFactorAuthToken: string multiFactorCode: string + siteUrl?: string } diff --git a/packages/sn-auth-react/src/server-actions.ts b/packages/sn-auth-react/src/server-actions.ts index f1bcf6de3e..0c882ebff6 100644 --- a/packages/sn-auth-react/src/server-actions.ts +++ b/packages/sn-auth-react/src/server-actions.ts @@ -9,6 +9,7 @@ import { User } from './models/user' export async function convertAuthTokenApiCall( server: string, authToken: string, + codeVerifier: string, ): Promise<{ accessToken: string; refreshToken: string }> { try { const response = await fetch(`${server}/api/auth/convert-auth-token`, { @@ -16,7 +17,7 @@ export async function convertAuthTokenApiCall( headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ token: authToken }), + body: JSON.stringify({ token: authToken, codeVerifier }), }) if (response.ok) { @@ -61,6 +62,8 @@ export async function refreshTokenApiCall( } export async function logoutApiCall(server: string, accessToken: string): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) try { const response = await fetch(`${server}/api/auth/logout`, { method: 'POST', @@ -68,6 +71,7 @@ export async function logoutApiCall(server: string, accessToken: string): Promis 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, + signal: controller.signal, }) if (response.ok) { @@ -78,6 +82,8 @@ export async function logoutApiCall(server: string, accessToken: string): Promis } catch (error) { console.error('Error:', error) throw error + } finally { + clearTimeout(timeout) } } diff --git a/packages/sn-auth-react/test/external-login.test.ts b/packages/sn-auth-react/test/external-login.test.ts new file mode 100644 index 0000000000..dff11749b0 --- /dev/null +++ b/packages/sn-auth-react/test/external-login.test.ts @@ -0,0 +1,98 @@ +import { createHash, webcrypto } from 'crypto' +import { TextEncoder } from 'util' +import { consumeExternalLogin, getPendingExternalLoginTarget, prepareExternalLogin } from '../src/external-login' + +const target = { + repoUrl: 'https://repo.example.invalid', + authServerUrl: 'https://auth.example.invalid', + callbackUri: '/callback', +} +const originalCrypto = window.crypto +const originalEncoder = global.TextEncoder +beforeEach(() => { + localStorage.clear() + sessionStorage.clear() + Object.defineProperty(window, 'crypto', { configurable: true, value: webcrypto }) + global.TextEncoder = TextEncoder as any +}) +afterEach(() => { + Object.defineProperty(window, 'crypto', { configurable: true, value: originalCrypto }) + global.TextEncoder = originalEncoder + jest.restoreAllMocks() +}) + +it('binds each outbound login to tab-local state and an S256 verifier', async () => { + const url = new URL(await prepareExternalLogin(target)) + const state = url.searchParams.get('state')! + const verifier = consumeExternalLogin(target, state) + const challenge = createHash('sha256').update(verifier).digest('base64url') + expect(state).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(verifier).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(url.origin + url.pathname).toBe(`${target.authServerUrl}/Login`) + expect(url.searchParams.get('RedirectUrl')).toBe(window.location.origin) + expect(url.searchParams.get('CallbackUri')).toBe('/callback') + expect(url.searchParams.get('codeChallengeMethod')).toBe('S256') + expect(url.searchParams.get('codeChallenge')).toBe(challenge) + expect(url.searchParams.has('codeVerifier')).toBe(false) + expect(localStorage.length).toBe(0) + expect(sessionStorage.length).toBe(0) +}) + +it.each([null, '', 'another-login'])( + 'rejects a missing or mismatched state (%s) before consuming a valid login', + async (wrong) => { + const url = new URL(await prepareExternalLogin(target)) + expect(() => consumeExternalLogin(target, wrong)).toThrow('does not match an active login') + expect(consumeExternalLogin(target, url.searchParams.get('state'))).toMatch(/^[A-Za-z0-9_-]{43}$/) + }, +) + +it('rejects replayed authorization callbacks', async () => { + const url = new URL(await prepareExternalLogin(target)) + consumeExternalLogin(target, url.searchParams.get('state')) + expect(() => consumeExternalLogin(target, url.searchParams.get('state'))).toThrow('does not match an active login') +}) + +it('looks up a callback target without consuming or disclosing its verifier', async () => { + const url = new URL(await prepareExternalLogin(target)) + const state = url.searchParams.get('state') + expect(getPendingExternalLoginTarget(target.callbackUri, state)).toEqual(target) + expect(getPendingExternalLoginTarget('/another-callback', state)).toBeUndefined() + expect(getPendingExternalLoginTarget(target.callbackUri, 'unknown')).toBeUndefined() + expect(consumeExternalLogin(target, state)).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(getPendingExternalLoginTarget(target.callbackUri, state)).toBeUndefined() +}) + +it.each(['repoUrl', 'authServerUrl', 'callbackUri'] as const)('does not consume another %s login', async (field) => { + const url = new URL(await prepareExternalLogin(target)) + const other = { ...target, [field]: field === 'callbackUri' ? '/another-callback' : 'https://other.example.invalid' } + expect(() => consumeExternalLogin(other, url.searchParams.get('state'))).toThrow('does not match an active login') + expect(consumeExternalLogin(target, url.searchParams.get('state'))).toBeTruthy() +}) + +it('rejects expired pending state', async () => { + const url = new URL(await prepareExternalLogin(target)) + const now = Date.now() + jest.spyOn(Date, 'now').mockReturnValue(now + 10 * 60 * 1000 + 1) + expect(() => consumeExternalLogin(target, url.searchParams.get('state'))).toThrow('does not match an active login') +}) + +it('keeps independent repository login attempts', async () => { + const other = { ...target, repoUrl: 'https://second.example.invalid' } + const first = new URL(await prepareExternalLogin(target)) + const second = new URL(await prepareExternalLogin(other)) + expect(first.searchParams.get('state')).not.toBe(second.searchParams.get('state')) + consumeExternalLogin(target, first.searchParams.get('state')) + expect(consumeExternalLogin(other, second.searchParams.get('state'))).toBeTruthy() +}) + +it('does not create pending state when the initiating operation has already ended', async () => { + await expect(prepareExternalLogin(target, () => false)).rejects.toThrow('Sign-in was cancelled') + expect(sessionStorage.length).toBe(0) +}) + +it('fails closed when browser cryptography is unavailable', async () => { + Object.defineProperty(window, 'crypto', { configurable: true, value: {} }) + await expect(prepareExternalLogin(target)).rejects.toThrow('Secure browser cryptography is unavailable') + expect(sessionStorage.length).toBe(0) +}) diff --git a/packages/sn-auth-react/test/lifecycle.test.tsx b/packages/sn-auth-react/test/lifecycle.test.tsx new file mode 100644 index 0000000000..c1a0a515b1 --- /dev/null +++ b/packages/sn-auth-react/test/lifecycle.test.tsx @@ -0,0 +1,297 @@ +import { webcrypto } from 'crypto' +import { TextEncoder } from 'util' +import React, { useContext } from 'react' +import { render, unmountComponentAtNode } from 'react-dom' +import { act } from 'react-dom/test-utils' +import { + AuthenticationContext, + AuthenticationContextState, + AuthenticationProvider, +} from '../src/components/authentication-provider' +import { prepareExternalLogin } from '../src/external-login' +import { + convertAuthTokenApiCall, + getUserDetailsApiCall, + loginApiCall, + logoutApiCall, + multiFactorApiCall, + refreshTokenApiCall, + validateTokenApiCall, +} from '../src/server-actions' +import { getAccessToken, getRefreshToken, setAccessToken, setRefreshToken } from '../src/storage-helpers' + +jest.mock('../src/server-actions') +jest.mock('../src/components/auth-routes', () => ({ AuthRoutes: ({ children }: any) => children })) +const target = { + repoUrl: 'https://repo.example.invalid', + authServerUrl: 'https://auth.example.invalid', + callbackUri: '/callback', +} +const prefix = 'repository-a' +const token = (seconds: number) => + `header.${btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + seconds }))}.signature` +const deferred = () => { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} +let state: AuthenticationContextState +let container: HTMLDivElement +const Probe = () => { + state = useContext(AuthenticationContext)! + return null +} +const initialize = async (callbacks: any = {}) => { + await act(async () => { + render( + + + , + container, + ) + }) +} +const originalCrypto = window.crypto +const originalEncoder = global.TextEncoder +beforeEach(() => { + jest.resetAllMocks() + localStorage.clear() + sessionStorage.clear() + window.history.replaceState(null, '', '/') + container = document.createElement('div') + document.body.appendChild(container) + ;(validateTokenApiCall as jest.Mock).mockResolvedValue(true) + ;(getUserDetailsApiCall as jest.Mock).mockResolvedValue({ Id: 1 }) + ;(refreshTokenApiCall as jest.Mock).mockResolvedValue({ accessToken: token(3600), refreshToken: 'rotated-refresh' }) + ;(logoutApiCall as jest.Mock).mockResolvedValue(true) + Object.defineProperty(window, 'crypto', { configurable: true, value: webcrypto }) + global.TextEncoder = TextEncoder as any +}) +afterEach(() => { + act(() => { + unmountComponentAtNode(container) + }) + container.remove() + jest.useRealTimers() + jest.restoreAllMocks() + Object.defineProperty(window, 'crypto', { configurable: true, value: originalCrypto }) + global.TextEncoder = originalEncoder +}) +const seed = (seconds = 3600) => { + setAccessToken(token(seconds), prefix) + setRefreshToken('stored-refresh', prefix) +} + +it('finishes failed startup refresh in a recoverable signed-out state', async () => { + seed(-60) + ;(refreshTokenApiCall as jest.Mock).mockRejectedValue(new Error('expired refresh token')) + await initialize() + expect(state.isLoading).toBe(false) + expect(state.user).toBeUndefined() + expect(state.error).toContain('Failed to validate or refresh token') + expect(getAccessToken(prefix)).toBeNull() + expect(getRefreshToken(prefix)).toBeNull() +}) + +it('clears only the current local session immediately even while server logout is pending', async () => { + seed() + setAccessToken('other-access', 'repository-b') + setRefreshToken('other-refresh', 'repository-b') + const pending = deferred() + ;(logoutApiCall as jest.Mock).mockReturnValue(pending.promise) + const onLogout = jest.fn() + await initialize({ onLogout }) + act(() => state.logout()) + expect(state.user).toBeUndefined() + expect(state.isLoading).toBe(false) + expect(getAccessToken(prefix)).toBeNull() + expect(getRefreshToken(prefix)).toBeNull() + expect(getAccessToken('repository-b')).toBe('other-access') + expect(getRefreshToken('repository-b')).toBe('other-refresh') + expect(onLogout).toHaveBeenCalledTimes(1) + await act(async () => pending.resolve(true)) +}) + +it('reports a late revocation failure without clearing a subsequent login', async () => { + seed() + const pending = deferred() + ;(logoutApiCall as jest.Mock).mockReturnValue(pending.promise) + const onLogoutError = jest.fn() + await initialize({ onLogoutError }) + act(() => state.logout()) + const fresh = { accessToken: token(7200), refreshToken: 'fresh-refresh' } + ;(loginApiCall as jest.Mock).mockResolvedValue(fresh) + ;(getUserDetailsApiCall as jest.Mock).mockResolvedValue({ Id: 2 }) + await act(async () => { + await state.login({ loginName: 'user', password: 'not-stored' }) + }) + jest.spyOn(console, 'error').mockImplementation(() => {}) + await act(async () => pending.reject(new Error('server unavailable'))) + expect(onLogoutError).toHaveBeenCalledTimes(1) + expect(state.user).toEqual({ Id: 2 }) + expect(getRefreshToken(prefix)).toBe('fresh-refresh') + expect(loginApiCall).toHaveBeenCalledWith(target.authServerUrl, { + loginName: 'user', + password: 'not-stored', + siteUrl: target.repoUrl, + }) +}) + +it('does not initialize an obsolete session after logout while validation is pending', async () => { + seed() + const pending = deferred() + ;(validateTokenApiCall as jest.Mock).mockReturnValue(pending.promise) + const onInitialized = jest.fn() + const storedToken = getAccessToken(prefix) + await initialize({ onInitialized }) + act(() => state.logout()) + expect(logoutApiCall).toHaveBeenCalledWith(target.authServerUrl, storedToken) + await act(async () => pending.resolve(true)) + expect(getUserDetailsApiCall).not.toHaveBeenCalled() + expect(onInitialized).not.toHaveBeenCalled() + expect(state.user).toBeUndefined() + expect(getAccessToken(prefix)).toBeNull() +}) + +it.each(['logout', 'unmount'])('does not persist late refresh results after %s', async (finish) => { + jest.useFakeTimers() + seed(15) + await initialize() + const pendingUser = deferred() + ;(getUserDetailsApiCall as jest.Mock).mockReturnValue(pendingUser.promise) + await act(async () => jest.advanceTimersByTime(10000)) + expect(refreshTokenApiCall).toHaveBeenCalledTimes(1) + const before = getAccessToken(prefix) + act(() => { + if (finish === 'logout') state.logout() + else unmountComponentAtNode(container) + }) + await act(async () => pendingUser.resolve({ Id: 1 })) + expect(getAccessToken(prefix)).toBe(finish === 'logout' ? null : before) + expect(getRefreshToken(prefix)).toBe(finish === 'logout' ? null : 'stored-refresh') +}) + +it('does not commit a login response that arrives after logout', async () => { + await initialize() + const pending = deferred() + ;(loginApiCall as jest.Mock).mockReturnValue(pending.promise) + let loginResult!: Promise + act(() => { + loginResult = state.login({ loginName: 'user', password: 'not-stored' }).catch((error) => error) + }) + act(() => state.logout()) + await act(async () => pending.resolve({ accessToken: token(3600), refreshToken: 'too-late' })) + expect((await loginResult).message).toBe('Sign-in was cancelled.') + expect(getUserDetailsApiCall).not.toHaveBeenCalled() + expect(getAccessToken(prefix)).toBeNull() +}) + +it('ignores the first repository initialization after an A-to-B-to-A scope switch', async () => { + seed() + const pendingUser = deferred() + ;(getUserDetailsApiCall as jest.Mock).mockReturnValueOnce(pendingUser.promise).mockResolvedValue({ Id: 2 }) + await initialize() + const renderScope = async (repoUrl: string, storageKeyPrefix: string) => { + await act(async () => { + render( + + + , + container, + ) + }) + } + await renderScope('https://second.example.invalid', 'repository-b') + await renderScope(target.repoUrl, prefix) + expect(state.user).toEqual({ Id: 2 }) + await act(async () => pendingUser.resolve({ Id: 1 })) + expect(state.user).toEqual({ Id: 2 }) + expect(getRefreshToken(prefix)).toBe('stored-refresh') +}) + +it('binds direct MFA to the repository and ignores its response after logout', async () => { + await initialize() + const pending = deferred() + ;(multiFactorApiCall as jest.Mock).mockReturnValue(pending.promise) + let loginResult!: Promise + act(() => { + loginResult = state + .multiFactorLogin({ multiFactorAuthToken: 'mfa-challenge', multiFactorCode: '123456' }) + .catch((error) => error) + }) + expect(multiFactorApiCall).toHaveBeenCalledWith(target.authServerUrl, { + multiFactorAuthToken: 'mfa-challenge', + multiFactorCode: '123456', + siteUrl: target.repoUrl, + }) + act(() => state.logout()) + await act(async () => pending.resolve({ accessToken: token(3600), refreshToken: 'too-late' })) + expect((await loginResult).message).toBe('Sign-in was cancelled.') + expect(getUserDetailsApiCall).not.toHaveBeenCalled() + expect(getAccessToken(prefix)).toBeNull() +}) + +it.each(['exchange', 'current-user'])('ignores a late callback %s response after logout', async (step) => { + const url = new URL(await prepareExternalLogin(target)) + const query = new URLSearchParams({ auth_code: 'bound-code', state: url.searchParams.get('state')! }) + window.history.replaceState(null, '', `/callback?${query}`) + const pending = deferred() + const tokens = { accessToken: token(3600), refreshToken: 'too-late' } + ;(convertAuthTokenApiCall as jest.Mock).mockResolvedValue(tokens) + if (step === 'exchange') (convertAuthTokenApiCall as jest.Mock).mockReturnValue(pending.promise) + else (getUserDetailsApiCall as jest.Mock).mockReturnValue(pending.promise) + await initialize() + act(() => state.logout()) + await act(async () => pending.resolve(step === 'exchange' ? tokens : { Id: 1 })) + expect(state.user).toBeUndefined() + expect(getAccessToken(prefix)).toBeNull() + expect(getRefreshToken(prefix)).toBeNull() + if (step === 'exchange') expect(getUserDetailsApiCall).not.toHaveBeenCalled() +}) + +it.each(['missing', 'wrong'])('rejects an unsolicited %s-state callback before exchanging the code', async (kind) => { + const query = new URLSearchParams({ auth_code: 'unsolicited-code' }) + if (kind === 'wrong') query.set('state', 'unknown-state') + window.history.replaceState(null, '', `/callback?${query}`) + await initialize() + expect(convertAuthTokenApiCall).not.toHaveBeenCalled() + expect(state.isLoading).toBe(false) + expect(state.error).toContain('does not match an active login') + expect(getAccessToken(prefix)).toBeNull() +}) + +it('exchanges a matching callback once using the browser-held verifier', async () => { + const url = new URL(await prepareExternalLogin(target)) + const query = new URLSearchParams({ auth_code: 'bound-code', state: url.searchParams.get('state')! }) + window.history.replaceState(null, '', `/callback?${query}`) + ;(convertAuthTokenApiCall as jest.Mock).mockResolvedValue({ accessToken: token(3600), refreshToken: 'bound-refresh' }) + await initialize() + expect(convertAuthTokenApiCall).toHaveBeenCalledWith( + target.authServerUrl, + 'bound-code', + expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + ) + expect(state.user).toEqual({ Id: 1 }) + expect(getRefreshToken(prefix)).toBe('bound-refresh') + expect(sessionStorage.length).toBe(0) + expect(window.location.pathname + window.location.search).toBe('/') + await act(async () => { + window.history.replaceState(null, '', `/callback?${query}`) + window.dispatchEvent(new PopStateEvent('popstate')) + }) + expect(convertAuthTokenApiCall).toHaveBeenCalledTimes(1) + expect(state.error).toContain('does not match an active login') +}) diff --git a/packages/sn-auth-react/test/server-actions.test.ts b/packages/sn-auth-react/test/server-actions.test.ts new file mode 100644 index 0000000000..904ffcbf1b --- /dev/null +++ b/packages/sn-auth-react/test/server-actions.test.ts @@ -0,0 +1,42 @@ +import { convertAuthTokenApiCall, logoutApiCall } from '../src/server-actions' + +const previousFetch = global.fetch +beforeEach(() => jest.useFakeTimers()) +afterEach(() => { + global.fetch = previousFetch + jest.useRealTimers() + jest.restoreAllMocks() +}) + +it('sends the code verifier only in the conversion POST body', async () => { + const request = jest + .fn() + .mockResolvedValue({ ok: true, json: async () => ({ accessToken: 'access', refreshToken: 'refresh' }) }) + global.fetch = request + await convertAuthTokenApiCall('https://auth.example.invalid', 'auth-code', 'tab-only-verifier') + expect(request.mock.calls[0][0]).toBe('https://auth.example.invalid/api/auth/convert-auth-token') + expect(JSON.parse(request.mock.calls[0][1].body)).toEqual({ token: 'auth-code', codeVerifier: 'tab-only-verifier' }) +}) + +it('aborts an unresponsive server logout after five seconds', async () => { + let signal!: AbortSignal + global.fetch = jest.fn((_url, options: any) => { + ;({ signal } = options) + return new Promise((_resolve, reject) => + signal.addEventListener('abort', () => reject(new Error('Request aborted'))), + ) + }) as any + jest.spyOn(console, 'error').mockImplementation(() => {}) + const result = logoutApiCall('https://auth.example.invalid', 'access') + const rejection = expect(result).rejects.toThrow('Request aborted') + jest.advanceTimersByTime(5000) + await rejection + expect(signal.aborted).toBe(true) + expect(jest.getTimerCount()).toBe(0) +}) + +it('cancels the timeout when revocation succeeds', async () => { + global.fetch = jest.fn().mockResolvedValue({ ok: true }) + await expect(logoutApiCall('https://auth.example.invalid', 'access')).resolves.toBe(true) + expect(jest.getTimerCount()).toBe(0) +}) diff --git a/packages/sn-authentication-oidc-react/src/authentication-service.ts b/packages/sn-authentication-oidc-react/src/authentication-service.ts index f148d4f94d..f3721af9a3 100644 --- a/packages/sn-authentication-oidc-react/src/authentication-service.ts +++ b/packages/sn-authentication-oidc-react/src/authentication-service.ts @@ -1,4 +1,5 @@ import { UserManager, UserManagerSettings } from 'oidc-client' +import { coordinateUserManager } from './user-manager-lifecycle' let userManager: UserManager | undefined @@ -14,6 +15,6 @@ export const authenticationService = (configuration: UserManagerSettings) => { if (userManager) { return userManager } - userManager = new UserManager(configuration) + userManager = coordinateUserManager(new UserManager(configuration), configuration) return userManager } diff --git a/packages/sn-authentication-oidc-react/src/components/authentication-provider.tsx b/packages/sn-authentication-oidc-react/src/components/authentication-provider.tsx index 7fac1fd089..1d5749198d 100644 --- a/packages/sn-authentication-oidc-react/src/components/authentication-provider.tsx +++ b/packages/sn-authentication-oidc-react/src/components/authentication-provider.tsx @@ -1,8 +1,9 @@ import { History } from 'history' -import { User, UserManagerSettings } from 'oidc-client' +import { User, UserManager, UserManagerSettings } from 'oidc-client' import React, { createContext, ElementType, ReactNode, useCallback, useEffect, useReducer } from 'react' import { authenticationService } from '../authentication-service' -import { addOidcEvents, logout, login as oidcLogin, oidcReducer, removeOidcEvents } from '../oidc-events' +import { addOidcEvents, AuthenticationErrorOperation, logout, login as oidcLogin, oidcReducer } from '../oidc-events' +import { coordinateUserManager } from '../user-manager-lifecycle' import { CallbackContainer } from './callback' import { OidcRoutes } from './oidc-routes' import { SessionLostContainer, SessionLostProps } from './session-lost' @@ -12,6 +13,7 @@ export interface AuthenticationContextState { authenticating?: ReactNode oidcUser?: User error?: string + errorOperation?: AuthenticationErrorOperation login: () => Promise logout: () => Promise } @@ -47,27 +49,46 @@ export interface AuthenticationProviderProps { customEvents?: CustomEvents } -const setDefaultState = (configuration: UserManagerSettings) => ({ - userManager: authenticationService(configuration), - isLoading: false, -}) +const setDefaultState = (configuration: UserManagerSettings) => { + const userManager = authenticationService(configuration) + return { + userManager: userManager instanceof UserManager ? coordinateUserManager(userManager, configuration) : userManager, + isLoading: false, + } +} export const AuthenticationProvider = (props: AuthenticationProviderProps) => { const [oidcState, dispatch] = useReducer(oidcReducer, setDefaultState(props.configuration)) useEffect(() => { + let active = true + let eventRevision = 0 dispatch({ type: 'ON_LOADING' }) - addOidcEvents({ customEvents: props.customEvents, dispatch, userManager: oidcState.userManager }) - oidcState.userManager.getUser().then((user) => { - if (!user) { - return - } - dispatch({ type: 'ON_LOAD_USER', user }) + const dispose = addOidcEvents({ + customEvents: props.customEvents, + dispatch: (action) => { + if (!active) return + eventRevision++ + dispatch(action) + }, + userManager: oidcState.userManager, }) - return () => removeOidcEvents({ customEvents: props.customEvents, dispatch, userManager: oidcState.userManager }) + oidcState.userManager + .getUser() + .then((user) => { + if (!active || eventRevision) return + dispatch(user ? { type: 'ON_LOAD_USER', user } : { type: 'ON_UNLOAD_USER' }) + }) + .catch((error) => { + if (active && !eventRevision) dispatch({ type: 'ON_ERROR', message: error.message, operation: 'login' }) + }) + return () => { + active = false + dispose() + } }, [oidcState.userManager, props.customEvents]) - const { oidcUser, isLoading, error } = oidcState + const { oidcUser, isLoading, error, errorOperation } = oidcState const { authenticating, notAuthenticated, @@ -90,6 +111,7 @@ export const AuthenticationProvider = (props: AuthenticationProviderProps) => { isLoading, oidcUser, error, + errorOperation, authenticating: authenticating?.(), login, logout: useCallback(() => logout(oidcState.userManager, dispatch)(), [oidcState.userManager]), diff --git a/packages/sn-authentication-oidc-react/src/components/callback.tsx b/packages/sn-authentication-oidc-react/src/components/callback.tsx index 8b960def0f..8fc5d6889c 100644 --- a/packages/sn-authentication-oidc-react/src/components/callback.tsx +++ b/packages/sn-authentication-oidc-react/src/components/callback.tsx @@ -2,6 +2,7 @@ import { Container, Typography } from '@material-ui/core' import { History } from 'history' import React, { ReactNode, useEffect } from 'react' import { getUserManager } from '../authentication-service' +import { isSupersededOidcOperation } from '../user-manager-lifecycle' export const Callback = () => ( @@ -19,19 +20,25 @@ type CallbackContainerProps = { export const CallbackContainer = ({ callbackComponentOverride, history }: CallbackContainerProps) => { useEffect(() => { + let active = true ;(async () => { try { // In here we are most probably going to have userManager if not the error will be catched const user = await getUserManager()!.signinRedirectCallback() + if (!active) return if (user.state.url) { history.push(user.state.url) } else { console.warn('no location in state') } } catch (error) { - history.push(`/authentication/not-authenticated?message=${encodeURIComponent(error.message)}`) + if (active && !isSupersededOidcOperation(error)) + history.push(`/authentication/not-authenticated?message=${encodeURIComponent(error.message)}`) } })() + return () => { + active = false + } }, [history]) return callbackComponentOverride ? <>{callbackComponentOverride} : diff --git a/packages/sn-authentication-oidc-react/src/oidc-events.ts b/packages/sn-authentication-oidc-react/src/oidc-events.ts index ea7bccd522..b3b4ae6545 100644 --- a/packages/sn-authentication-oidc-react/src/oidc-events.ts +++ b/packages/sn-authentication-oidc-react/src/oidc-events.ts @@ -3,9 +3,12 @@ import { User, UserManager } from 'oidc-client' import { Dispatch } from 'react' import { CustomEvents } from './components/authentication-provider' import { authenticateUser, logoutUser } from './oidc-service' +import { isSupersededOidcOperation } from './user-manager-lifecycle' + +export type AuthenticationErrorOperation = 'login' | 'logout' | 'refresh' type Action = - | { type: 'ON_ERROR'; message: string } + | { type: 'ON_ERROR'; message: string; operation?: AuthenticationErrorOperation } | { type: 'ON_LOADING' } | { type: 'ON_LOAD_USER'; user: User } | { type: 'ON_UNLOAD_USER' } @@ -15,18 +18,19 @@ type State = { userManager: UserManager isLoading: boolean error?: string + errorOperation?: AuthenticationErrorOperation } export const oidcReducer = (state: State, action: Action) => { switch (action.type) { case 'ON_ERROR': - return { ...state, error: action.message, isLoading: false } + return { ...state, error: action.message, errorOperation: action.operation, isLoading: false } case 'ON_LOADING': - return { ...state, isLoading: true } + return { ...state, error: undefined, errorOperation: undefined, isLoading: true } case 'ON_LOAD_USER': - return { ...state, oidcUser: action.user, isLoading: false } + return { ...state, oidcUser: action.user, error: undefined, errorOperation: undefined, isLoading: false } case 'ON_UNLOAD_USER': - return { ...state, oidcUser: undefined, isLoading: false } + return { ...state, oidcUser: undefined, error: undefined, errorOperation: undefined, isLoading: false } default: return state } @@ -36,18 +40,23 @@ export const logout = (userManager: UserManager, dispatch: Dispatch) => try { await logoutUser(userManager) } catch (error) { - onError(dispatch, error) + if (!isSupersededOidcOperation(error)) onError(dispatch, error, 'logout') } } export const login = (userManager: UserManager, dispatch: Dispatch, location: Location, history: History) => async () => { dispatch({ type: 'ON_LOADING' }) - await authenticateUser(userManager, location, history)() + try { + await authenticateUser(userManager, location, history)() + } catch (error) { + if (!isSupersededOidcOperation(error)) onError(dispatch, error, 'login') + throw error + } } -export const onError = (dispatch: Dispatch, error: Error) => { - dispatch({ type: 'ON_ERROR', message: error.message }) +export const onError = (dispatch: Dispatch, error: Error, operation?: AuthenticationErrorOperation) => { + dispatch({ type: 'ON_ERROR', message: error.message, ...(operation ? { operation } : {}) }) } export const onUserLoaded = (dispatch: Dispatch, user: User) => { @@ -69,27 +78,45 @@ type oidcEventsOptions = { userManager: UserManager } +type OidcSubscription = oidcEventsOptions & { dispose: () => void } +const subscriptions = new WeakMap>() + export const addOidcEvents = ({ dispatch, userManager, customEvents }: oidcEventsOptions) => { - userManager.events.addUserLoaded((user) => { + let disposed = false + let revision = 0 + const userLoaded = (user: User) => { + revision++ customEvents?.onUserLoaded?.(user) onUserLoaded(dispatch, user) - }) - userManager.events.addSilentRenewError((error) => { + } + const silentRenewError = (error: Error) => { + if (isSupersededOidcOperation(error)) return customEvents?.onSilentRenewError?.(error) - onError(dispatch, error) - }) - userManager.events.addUserUnloaded(() => { + onError(dispatch, error, 'refresh') + } + const userUnloaded = () => { + revision++ customEvents?.onUserUnloaded?.() onUserUnloaded(dispatch) - }) - userManager.events.addUserSignedOut(() => { + } + const userSignedOut = () => { + revision++ customEvents?.onUserSignedOut?.() onUserUnloaded(dispatch) - }) - userManager.events.addAccessTokenExpired(() => { + } + const accessTokenExpired = () => { + const startedAt = ++revision customEvents?.onAccessTokenExpired?.() - onAccessTokenExpired(dispatch, userManager) - }) + onAccessTokenExpired(dispatch, userManager).catch((error) => { + if (!disposed && revision === startedAt && !isSupersededOidcOperation(error)) onError(dispatch, error, 'refresh') + }) + } + + userManager.events.addUserLoaded(userLoaded) + userManager.events.addSilentRenewError(silentRenewError) + userManager.events.addUserUnloaded(userUnloaded) + userManager.events.addUserSignedOut(userSignedOut) + userManager.events.addAccessTokenExpired(accessTokenExpired) if (customEvents?.onAccessTokenExpiring) { userManager.events.addAccessTokenExpiring(customEvents.onAccessTokenExpiring) @@ -97,35 +124,40 @@ export const addOidcEvents = ({ dispatch, userManager, customEvents }: oidcEvent if (customEvents?.onUserSessionChanged) { userManager.events.addUserSessionChanged(customEvents.onUserSessionChanged) } -} - -export const removeOidcEvents = ({ dispatch, userManager, customEvents }: oidcEventsOptions) => { - userManager.events.removeUserLoaded((user) => { - customEvents?.onUserLoaded?.(user) - onUserLoaded(dispatch, user) - }) - userManager.events.removeSilentRenewError((error) => { - customEvents?.onSilentRenewError?.(error) - onError(dispatch, error) - }) - userManager.events.removeUserUnloaded(() => { - customEvents?.onUserUnloaded?.() - onUserUnloaded(dispatch) - }) - userManager.events.removeUserSignedOut(() => { - customEvents?.onUserSignedOut?.() - onUserUnloaded(dispatch) - }) - userManager.events.removeAccessTokenExpired(() => { - customEvents?.onAccessTokenExpired?.() - onAccessTokenExpired(dispatch, userManager) - }) - if (customEvents?.onAccessTokenExpiring) { - userManager.events.removeAccessTokenExpiring(customEvents.onAccessTokenExpiring) + let managerSubscriptions = subscriptions.get(userManager) + if (!managerSubscriptions) { + managerSubscriptions = new Set() + subscriptions.set(userManager, managerSubscriptions) + } + const subscription: OidcSubscription = { + dispatch, + userManager, + customEvents, + dispose: () => { + if (disposed) return + disposed = true + userManager.events.removeUserLoaded(userLoaded) + userManager.events.removeSilentRenewError(silentRenewError) + userManager.events.removeUserUnloaded(userUnloaded) + userManager.events.removeUserSignedOut(userSignedOut) + userManager.events.removeAccessTokenExpired(accessTokenExpired) + if (customEvents?.onAccessTokenExpiring) { + userManager.events.removeAccessTokenExpiring(customEvents.onAccessTokenExpiring) + } + if (customEvents?.onUserSessionChanged) { + userManager.events.removeUserSessionChanged(customEvents.onUserSessionChanged) + } + managerSubscriptions!.delete(subscription) + if (!managerSubscriptions!.size) subscriptions.delete(userManager) + }, } + managerSubscriptions.add(subscription) + return subscription.dispose +} - if (customEvents?.onUserSessionChanged) { - userManager.events.removeUserSessionChanged(customEvents.onUserSessionChanged) +export const removeOidcEvents = ({ dispatch, userManager, customEvents }: oidcEventsOptions) => { + for (const subscription of subscriptions.get(userManager) ?? []) { + if (subscription.dispatch === dispatch && subscription.customEvents === customEvents) subscription.dispose() } } diff --git a/packages/sn-authentication-oidc-react/src/oidc-service.ts b/packages/sn-authentication-oidc-react/src/oidc-service.ts index e7db1d7659..968a6b198d 100644 --- a/packages/sn-authentication-oidc-react/src/oidc-service.ts +++ b/packages/sn-authentication-oidc-react/src/oidc-service.ts @@ -1,6 +1,7 @@ import { History, Location } from 'history' import { User, UserManager } from 'oidc-client' import { removeUserManager } from './authentication-service' +import { isSupersededOidcOperation, runOidcLogout } from './user-manager-lifecycle' let userRequested = false let numberAuthentication = 0 @@ -23,7 +24,7 @@ export const authenticateUser = try { await userManager.signinRedirect({ data: { url } }) } catch (error) { - removeUserManager() + if (!isSupersededOidcOperation(error)) removeUserManager() throw error } finally { userRequested = false @@ -33,20 +34,22 @@ export const authenticateUser = try { await userManager.signinSilent() } catch (error) { + if (isSupersededOidcOperation(error)) throw error if (numberAuthentication <= 1) { await userManager.signinRedirect({ data: { url } }) } else { userRequested = false history.push(`/authentication/session-lost?path=${encodeURI(url)}`) } + } finally { + userRequested = false } - userRequested = false } } -export const logoutUser = async (userManager: UserManager) => { - const oidcUser = await userManager.getUser() - if (oidcUser) { - await userManager.signoutRedirect() - } -} +export const logoutUser = async (userManager: UserManager) => + runOidcLogout(userManager, async (ensureCurrent) => { + const oidcUser = await userManager.getUser() + ensureCurrent() + if (oidcUser) await userManager.signoutRedirect() + }) diff --git a/packages/sn-authentication-oidc-react/src/user-manager-lifecycle.ts b/packages/sn-authentication-oidc-react/src/user-manager-lifecycle.ts new file mode 100644 index 0000000000..2c220087b8 --- /dev/null +++ b/packages/sn-authentication-oidc-react/src/user-manager-lifecycle.ts @@ -0,0 +1,180 @@ +import { User, UserManager, UserManagerSettings, WebStorageStateStore } from 'oidc-client' + +class SupersededOidcOperation extends Error { + constructor() { + super('The authentication operation has ended.') + } +} + +type Logout = { depth: number } +type Lifecycle = { + generation: number + signedOut: boolean + logout?: Logout + writes: Promise + silent?: { generation: number; promise: Promise } +} + +const lifecycles = new WeakMap() +export const isSupersededOidcOperation = (error: unknown) => error instanceof SupersededOidcOperation + +const assertCurrent = (state: Lifecycle, generation: number) => { + if (state.generation !== generation) throw new SupersededOidcOperation() +} + +const write = (state: Lifecycle, generation: number, operation: () => Promise) => { + const pending = state.writes.then(async () => { + assertCurrent(state, generation) + await operation() + assertCurrent(state, generation) + }) + // Serialize user-store mutations, including custom asynchronous stores. A newer + // clear/save follows an already-started old write; no rollback can erase it. + state.writes = pending.catch(() => {}) + return pending +} + +export const runOidcLogout = async (manager: UserManager, operation: (ensureCurrent: () => void) => Promise) => { + const state = lifecycles.get(manager) + if (!state) return operation(() => {}) + if (!state.logout) { + state.logout = { depth: 0 } + state.generation++ + state.signedOut = true + state.silent = undefined + manager.stopSilentRenew() + } + const { logout } = state + logout.depth++ + const ensureCurrent = () => { + if (state.logout !== logout) throw new SupersededOidcOperation() + } + try { + await operation(ensureCurrent) + ensureCurrent() + } catch (error) { + ensureCurrent() + throw error + } finally { + logout.depth-- + if (!logout.depth && state.logout === logout) await manager.removeUser() + } +} + +/** Coordinate public OIDC operations without modifying oidc-client's response processing. */ +export const coordinateUserManager = (manager: UserManager, configuration: UserManagerSettings) => { + if (lifecycles.has(manager)) return manager + const state: Lifecycle = { generation: 0, signedOut: false, writes: Promise.resolve() } + lifecycles.set(manager, state) + const storeUser = manager.storeUser.bind(manager) + const signinRedirect = manager.signinRedirect.bind(manager) + const signoutRedirect = manager.signoutRedirect.bind(manager) + const signoutPopup = manager.signoutPopup.bind(manager) + + const beginLogin = () => { + state.generation++ + state.signedOut = false + state.logout = undefined + state.silent = undefined + return state.generation + } + const explicitLogin = (operation: () => Promise) => { + const generation = beginLogin() + return operation().catch((error) => { + assertCurrent(state, generation) + throw error + }) + } + + manager.storeUser = (user) => { + state.generation++ + state.silent = undefined + state.signedOut = !user + if (user) state.logout = undefined + else { + state.logout = state.logout || { depth: 0 } + manager.stopSilentRenew() + } + const { generation } = state + return write(state, generation, () => storeUser(user)).then(() => { + if (user && configuration.automaticSilentRenew) manager.startSilentRenew() + }) + } + manager.signinRedirect = (args) => explicitLogin(() => signinRedirect(args)) + manager.signoutRedirect = (args) => runOidcLogout(manager, async () => await signoutRedirect(args)) + manager.signoutPopup = (args) => runOidcLogout(manager, async () => await signoutPopup(args)) + + const userFlow = (generation: number, operation: (flow: UserManager) => Promise) => { + const source = manager.settings.userStore! + const staged = new Map() + const ensureCurrent = () => { + assertCurrent(state, generation) + if (state.signedOut) throw new SupersededOidcOperation() + } + // A response may finish parsing after logout or another login. Its UserManager + // writes only here until the response has passed the lifecycle check. + const userStore: WebStorageStateStore = { + get: async (key) => { + ensureCurrent() + return staged.has(key) ? staged.get(key) : source.get(key) + }, + set: async (key, value) => { + ensureCurrent() + staged.set(key, value) + }, + remove: async (key) => { + ensureCurrent() + const value = staged.has(key) ? staged.get(key) : await source.get(key) + ensureCurrent() + staged.set(key, null) + return value + }, + getAllKeys: async () => [...new Set([...(await source.getAllKeys()), ...staged.keys()])], + } + let flow: UserManager | undefined + return (async () => { + try { + await state.writes + ensureCurrent() + const metadata = await manager.metadataService.getMetadata() + ensureCurrent() + flow = new UserManager({ + ...configuration, + metadata, + userStore, + stateStore: manager.settings.stateStore, + automaticSilentRenew: false, + monitorSession: false, + }) + const user = await operation(flow) + ensureCurrent() + if (user) { + await write(state, generation, () => storeUser(user)) + ensureCurrent() + if (configuration.automaticSilentRenew) manager.startSilentRenew() + manager.events.load(user) + } + return user + } catch (error) { + ensureCurrent() + throw error + } finally { + flow?.stopSilentRenew() + flow?.events.unload() + } + })() + } + manager.signinRedirectCallback = (url) => userFlow(beginLogin(), (flow) => flow.signinRedirectCallback(url)) + manager.signinPopup = (args) => userFlow(beginLogin(), (flow) => flow.signinPopup(args)) + manager.signinSilent = (args) => { + if (state.signedOut) return Promise.reject(new SupersededOidcOperation()) + if (state.silent?.generation === state.generation) return state.silent.promise + const { generation } = state + const promise = userFlow(generation, (flow) => flow.signinSilent(args)).finally(() => { + if (state.silent?.generation === generation) state.silent = undefined + }) + state.silent = { generation, promise } + return promise + } + return manager +} diff --git a/packages/sn-authentication-oidc-react/test/authentication-provider.test.tsx b/packages/sn-authentication-oidc-react/test/authentication-provider.test.tsx index 26c15f83d1..8255675e5c 100644 --- a/packages/sn-authentication-oidc-react/test/authentication-provider.test.tsx +++ b/packages/sn-authentication-oidc-react/test/authentication-provider.test.tsx @@ -1,5 +1,5 @@ import { Button } from '@material-ui/core' -import { mount } from 'enzyme' +import { mount as enzymeMount, ReactWrapper } from 'enzyme' import { UserManager } from 'oidc-client' import React from 'react' import { act } from 'react-dom/test-utils' @@ -18,6 +18,18 @@ jest.mock('../src/authentication-service', () => { } }) +const wrappers: ReactWrapper[] = [] +const mount = (element: React.ReactElement) => { + const wrapper = enzymeMount(element) + wrappers.push(wrapper) + return wrapper +} +afterEach(async () => { + await act(async () => { + wrappers.splice(0).forEach((wrapper) => wrapper.unmount()) + }) +}) + describe('AuthenticationProvider component', () => { it('should show its children if the path is not one of the reserved', () => { const wrapper = mount( diff --git a/packages/sn-authentication-oidc-react/test/events.test.tsx b/packages/sn-authentication-oidc-react/test/events.test.tsx index fd45da05f9..c8ba2f4f93 100644 --- a/packages/sn-authentication-oidc-react/test/events.test.tsx +++ b/packages/sn-authentication-oidc-react/test/events.test.tsx @@ -181,19 +181,22 @@ describe('Authentication provider', () => { expect(userManagerMock.signinSilent).toHaveBeenCalled() }) - it('should subscribe to remove all events when call removeOidcEvents', () => { + it('should unsubscribe the registered handlers without invoking custom callbacks', () => { const eventsMock = { - removeUserLoaded: (cb: (user: any) => void) => { - cb({ name: 'Jon' }) - }, - removeSilentRenewError: (cb: (error: any) => void) => { - cb({ message: 'error' }) - }, - removeUserUnloaded: (cb: () => void) => cb(), - removeUserSignedOut: (cb: () => void) => cb(), - removeAccessTokenExpired: (cb: () => void) => cb(), - removeAccessTokenExpiring: (cb: () => void) => cb(), - removeUserSessionChanged: (cb: () => void) => cb(), + addUserLoaded: jest.fn(), + addSilentRenewError: jest.fn(), + addUserUnloaded: jest.fn(), + addUserSignedOut: jest.fn(), + addAccessTokenExpired: jest.fn(), + addAccessTokenExpiring: jest.fn(), + addUserSessionChanged: jest.fn(), + removeUserLoaded: jest.fn(), + removeSilentRenewError: jest.fn(), + removeUserUnloaded: jest.fn(), + removeUserSignedOut: jest.fn(), + removeAccessTokenExpired: jest.fn(), + removeAccessTokenExpiring: jest.fn(), + removeUserSessionChanged: jest.fn(), } const customEvents: CustomEvents = { onUserLoaded: jest.fn(), @@ -204,18 +207,27 @@ describe('Authentication provider', () => { onUserSignedOut: jest.fn(), onUserUnloaded: jest.fn(), } - removeOidcEvents({ + const options = { userManager: { events: eventsMock, signinSilent: jest.fn() } as any, dispatch: jest.fn(), customEvents, - }) - expect(customEvents.onUserLoaded).toHaveBeenCalledWith({ name: 'Jon' }) - expect(customEvents.onAccessTokenExpired).toHaveBeenCalledTimes(1) - expect(customEvents.onAccessTokenExpiring).toHaveBeenCalledTimes(1) - expect(customEvents.onSilentRenewError).toHaveBeenCalledWith({ message: 'error' }) - expect(customEvents.onUserSessionChanged).toHaveBeenCalledTimes(1) - expect(customEvents.onUserSignedOut).toHaveBeenCalledTimes(1) - expect(customEvents.onUserUnloaded).toHaveBeenCalledTimes(1) + } + addOidcEvents(options) + removeOidcEvents(options) + for (const event of [ + 'UserLoaded', + 'SilentRenewError', + 'UserUnloaded', + 'UserSignedOut', + 'AccessTokenExpired', + 'AccessTokenExpiring', + 'UserSessionChanged', + ]) { + expect((eventsMock as any)[`remove${event}`]).toHaveBeenCalledWith( + (eventsMock as any)[`add${event}`].mock.calls[0][0], + ) + } + for (const callback of Object.values(customEvents)) expect(callback).not.toHaveBeenCalled() }) it('should subscribe to add all events when call addOidcEvents', () => { From ae03d4c8f5c5c2cd7efa9111c8424cc93e2f3336 Mon Sep 17 00:00:00 2001 From: VargaJoe Date: Thu, 5 Nov 2026 18:38:27 +0100 Subject: [PATCH 2/2] fix(adminui): preserve provider handoff and silent callbacks --- apps/sensenet/AUTHENTICATION-TESTING.md | 41 ++-- .../src/context/repository-provider.tsx | 65 +++--- .../context/sn-auth-repository-provider.tsx | 75 ++++--- apps/sensenet/src/index.tsx | 3 +- .../src/services/oidc-silent-callback.ts | 18 ++ .../test/external-provider-switching.test.tsx | 211 ++++++++++++++++++ .../test/oidc-silent-bootstrap.test.tsx | 110 +++++++++ 7 files changed, 446 insertions(+), 77 deletions(-) create mode 100644 apps/sensenet/src/services/oidc-silent-callback.ts create mode 100644 apps/sensenet/test/external-provider-switching.test.tsx create mode 100644 apps/sensenet/test/oidc-silent-bootstrap.test.tsx diff --git a/apps/sensenet/AUTHENTICATION-TESTING.md b/apps/sensenet/AUTHENTICATION-TESTING.md index 402a1c7c81..7cb81bc745 100644 --- a/apps/sensenet/AUTHENTICATION-TESTING.md +++ b/apps/sensenet/AUTHENTICATION-TESTING.md @@ -10,22 +10,24 @@ Record the AdminUI commit/build, browser, repository, authentication mode and result for each case. Do not clear all browser storage as a test prerequisite. Use test users and avoid recording tokens or passwords in evidence. -| Case | IdentityServer | SNAuth | Local/internal | Expected result | -| ------------------------------------------------------- | ------------------------ | ------ | ---------------- | ------------------------------------------------------------------------------------------ | -| Login, reload, logout, login again | Yes | Yes | Yes | Correct identity and usable signed-out view; logout does not hang. | -| Switch A → B → A | Yes | Yes | Yes | Correct repository and identity; other sessions remain available. | -| Local → external login and callback | Yes | Yes | Source is Local | Correct auth mode survives callback/reload; old Local session remains available. | -| Let an access token expire | Yes | Yes | Yes | One refresh operation; UI and requests use the refreshed token. | -| Logout while refresh/current-user request is delayed | Yes | Yes | Yes | Old response cannot restore a logged-out session or overwrite a later login. | -| A → B → A while A's request is delayed, then logout A | Yes | Yes | Yes | A stays logged out when the old request completes; B is unaffected. | -| Network/issuer failure at startup or refresh | Yes | Yes | Yes | Loading finishes with an understandable error and a usable retry/selection action. | -| Logout endpoint fails or stalls | Yes | Yes | Yes | Recoverable UI; SNAuth clears locally immediately and warns if revocation is unconfirmed. | -| Login while an earlier Local logout is pending | — | — | Yes | Completing the old logout does not clear the new identity. | -| Repeated mount/switch/expiry | Yes | — | — | Event subscriptions are removed; one expiry does not trigger duplicate silent renewals. | -| Unsolicited, wrong-state or replayed callback | OIDC's existing protocol | Yes | — | SNAuth does not convert/persist tokens; it shows an error and allows repository selection. | -| Password login, remembered login, MFA retry/success | Existing provider | Yes | Where configured | Same intended browser transaction completes, with the correct user. | -| Registration and forgotten-password navigation/recovery | Existing provider | Yes | Where configured | Return to login preserves the pending transaction; completed login succeeds. | -| Two browser sessions using the same user | Existing provider | Yes | Yes | Logging out one preserves the other when server policy allows parallel sessions. | +| Case | IdentityServer | SNAuth | Local/internal | Expected result | +| ------------------------------------------------------- | ------------------------ | --------- | ---------------- | ------------------------------------------------------------------------------------------ | +| Login, reload, logout, login again | Yes | Yes | Yes | Correct identity and usable signed-out view; logout does not hang. | +| Switch A → B → A | Yes | Yes | Yes | Correct repository and identity; other sessions remain available. | +| IdentityServer → SNAuth after logout, then reverse | Yes | Yes | — | First selection starts the correct login without a compatibility error or a retry. | +| Silent callback while another tab selected SNAuth/Local | Yes | Other tab | Other tab | The iframe notifies its initiating window without repository discovery or changing mode. | +| Local → external login and callback | Yes | Yes | Source is Local | Correct auth mode survives callback/reload; old Local session remains available. | +| Let an access token expire | Yes | Yes | Yes | One refresh operation; UI and requests use the refreshed token. | +| Logout while refresh/current-user request is delayed | Yes | Yes | Yes | Old response cannot restore a logged-out session or overwrite a later login. | +| A → B → A while A's request is delayed, then logout A | Yes | Yes | Yes | A stays logged out when the old request completes; B is unaffected. | +| Network/issuer failure at startup or refresh | Yes | Yes | Yes | Loading finishes with an understandable error and a usable retry/selection action. | +| Logout endpoint fails or stalls | Yes | Yes | Yes | Recoverable UI; SNAuth clears locally immediately and warns if revocation is unconfirmed. | +| Login while an earlier Local logout is pending | — | — | Yes | Completing the old logout does not clear the new identity. | +| Repeated mount/switch/expiry | Yes | — | — | Event subscriptions are removed; one expiry does not trigger duplicate silent renewals. | +| Unsolicited, wrong-state or replayed callback | OIDC's existing protocol | Yes | — | SNAuth does not convert/persist tokens; it shows an error and allows repository selection. | +| Password login, remembered login, MFA retry/success | Existing provider | Yes | Where configured | Same intended browser transaction completes, with the correct user. | +| Registration and forgotten-password navigation/recovery | Existing provider | Yes | Where configured | Return to login preserves the pending transaction; completed login succeeds. | +| Two browser sessions using the same user | Existing provider | Yes | Yes | Logging out one preserves the other when server policy allows parallel sessions. | The existing bare-root automatic restoration of a previously selected repository is intentionally unchanged. The explicit Choose another repository/Switch action @@ -36,3 +38,10 @@ Automated checks use the app Jest configuration for the Local/OIDC/SNAuth wrappe and package regression suites, plus both IdentityServer and SNAuth production webpack builds. The server has independent HTTP, proof, MFA, expiry and session tests. These checks support review; live provider acceptance is a separate step. + +Provider-switch regressions exercise the actual repository wrappers and SNAuth +initialization, including old discovery success/failure after a newer selection. +Silent-callback bootstrap regressions exercise the real public OIDC callback API: +only the exact iframe callback bypasses application bootstrap, and it neither +loads a session nor changes the globally selected authentication mode. The +initiating OIDC manager retains origin, frame-source, state and proof validation. diff --git a/apps/sensenet/src/context/repository-provider.tsx b/apps/sensenet/src/context/repository-provider.tsx index cb26110cf4..c8aba84be6 100644 --- a/apps/sensenet/src/context/repository-provider.tsx +++ b/apps/sensenet/src/context/repository-provider.tsx @@ -73,38 +73,49 @@ export function RepositoryProvider({ } }, [url]) - const getConfig = useCallback(async () => { - if (!authState.repoUrl) { - setIsLoginInProgress(false) - return - } - try { - setIsLoginInProgress(true) - if (!(await prepareAuthentication(authState.repoUrl))) return - setAuthenticationCheckedFor(authState.repoUrl) - const config = await getAuthConfig(authState.repoUrl) - if (config.authServerSettings.type !== 'IdentityServer') { - changeAuthType(authState.repoUrl) - logger.error({ message: 'Incompatible authentication server type' }) - clearActiveRepositorySelection() - setAuthState((oldState) => ({ ...oldState, repoUrl: '' })) + const getConfig = useCallback( + async (isCurrent: () => boolean) => { + if (!authState.repoUrl) { + setIsLoginInProgress(false) return } + try { + setIsLoginInProgress(true) + if (!(await prepareAuthentication(authState.repoUrl))) return + if (!isCurrent()) return + setAuthenticationCheckedFor(authState.repoUrl) + const config = await getAuthConfig(authState.repoUrl) + if (!isCurrent()) return + if (config.authServerSettings.type === 'SNAuth') { + // Clear the old selection before the next provider creates its login intent. + clearActiveRepositorySelection() + changeAuthType(authState.repoUrl) + return + } + if (config.authServerSettings.type !== 'IdentityServer') + throw new Error('Unsupported authentication server type') - window.localStorage.setItem(authConfigKey, JSON.stringify(config)) - // Set only userManagerSettings in authState to match the expected type - setAuthState((oldState) => ({ ...oldState, config: config.userManagerSettings })) - } catch (error) { - logger.warning({ data: error, message: `Couldn't connect to ${authState.repoUrl}` }) - clearActiveRepositorySelection() - setAuthState((oldState) => ({ ...oldState, repoUrl: '' })) - } finally { - setIsLoginInProgress(false) - } - }, [logger, authState.repoUrl, changeAuthType, prepareAuthentication]) + window.localStorage.setItem(authConfigKey, JSON.stringify(config)) + // Set only userManagerSettings in authState to match the expected type + setAuthState((oldState) => ({ ...oldState, config: config.userManagerSettings })) + } catch (error) { + if (!isCurrent()) return + logger.warning({ data: error, message: `Couldn't connect to ${authState.repoUrl}` }) + clearActiveRepositorySelection() + setAuthState((oldState) => ({ ...oldState, repoUrl: '' })) + } finally { + if (isCurrent()) setIsLoginInProgress(false) + } + }, + [logger, authState.repoUrl, changeAuthType, prepareAuthentication], + ) useEffect(() => { - getConfig() + let active = true + getConfig(() => active) + return () => { + active = false + } }, [getConfig]) if (authState.repoUrl && authenticationCheckedFor !== authState.repoUrl) diff --git a/apps/sensenet/src/context/sn-auth-repository-provider.tsx b/apps/sensenet/src/context/sn-auth-repository-provider.tsx index 0ddf2daaca..38ea9c94dd 100644 --- a/apps/sensenet/src/context/sn-auth-repository-provider.tsx +++ b/apps/sensenet/src/context/sn-auth-repository-provider.tsx @@ -135,47 +135,56 @@ export function SnAuthRepositoryProvider({ } }, [url]) - const getConfig = useCallback(async () => { - if (!authState.repoUrl) { - setIsLoginInProgress(false) - return - } - try { - setIsLoginInProgress(true) - const boundCallback = window.location.pathname === callbackUri && callbackEntry?.config - if (!boundCallback && !(await prepareAuthentication(authState.repoUrl))) return - setAuthenticationCheckedFor(authState.repoUrl) - const storedConfig = boundCallback || getSnAuthRepositoryConfig(authState.repoUrl) - - if (storedConfig) { - setAuthServerUrl(storedConfig.userManagerSettings.authority) - window.localStorage.setItem(authConfigKey, JSON.stringify(storedConfig)) - setAuthState((oldState) => ({ ...oldState, config: storedConfig.userManagerSettings })) + const getConfig = useCallback( + async (isCurrent: () => boolean) => { + if (!authState.repoUrl) { + setIsLoginInProgress(false) return } + try { + setIsLoginInProgress(true) + const boundCallback = window.location.pathname === callbackUri && callbackEntry?.config + if (!boundCallback && !(await prepareAuthentication(authState.repoUrl))) return + if (!isCurrent()) return + setAuthenticationCheckedFor(authState.repoUrl) + const storedConfig = boundCallback || getSnAuthRepositoryConfig(authState.repoUrl) + + if (storedConfig) { + setAuthServerUrl(storedConfig.userManagerSettings.authority) + window.localStorage.setItem(authConfigKey, JSON.stringify(storedConfig)) + setAuthState((oldState) => ({ ...oldState, config: storedConfig.userManagerSettings })) + return + } - const config = await getAuthConfig(authState.repoUrl) - if (config.authServerSettings.type === 'SNAuth') { - setSnAuthRepositoryConfig(authState.repoUrl, config) - setConfigString(window.localStorage.getItem(authConfigKey)) - setAuthState((oldState) => ({ ...oldState, config: config.userManagerSettings })) - } else { - changeAuthType(authState.repoUrl) - logger.error({ message: 'Incompatible authentication server type' }) + const config = await getAuthConfig(authState.repoUrl) + if (!isCurrent()) return + if (config.authServerSettings.type === 'SNAuth') { + setSnAuthRepositoryConfig(authState.repoUrl, config) + setConfigString(window.localStorage.getItem(authConfigKey)) + setAuthState((oldState) => ({ ...oldState, config: config.userManagerSettings })) + } else if (config.authServerSettings.type === 'IdentityServer') { + // Provider discovery is a normal handoff, not a failed sign-in. + clearActiveRepositorySelection() + changeAuthType(authState.repoUrl) + } else throw new Error('Unsupported authentication server type') + } catch (error) { + if (!isCurrent()) return + logger.warning({ data: error, message: `Couldn't connect to ${authState.repoUrl}` }) clearActiveRepositorySelection() setAuthState((oldState) => ({ ...oldState, repoUrl: '' })) + } finally { + if (isCurrent()) setIsLoginInProgress(false) } - } catch (error) { - logger.warning({ data: error, message: `Couldn't connect to ${authState.repoUrl}` }) - clearActiveRepositorySelection() - setAuthState((oldState) => ({ ...oldState, repoUrl: '' })) - } finally { - setIsLoginInProgress(false) - } - }, [logger, authState.repoUrl, changeAuthType, prepareAuthentication]) + }, + [logger, authState.repoUrl, changeAuthType, prepareAuthentication], + ) useEffect(() => { - getConfig() + let active = true + getConfig(() => active) + return () => { + active = false + } }, [getConfig]) if (callbackError) { diff --git a/apps/sensenet/src/index.tsx b/apps/sensenet/src/index.tsx index 1fd24fa8e0..431ff06a70 100644 --- a/apps/sensenet/src/index.tsx +++ b/apps/sensenet/src/index.tsx @@ -1,9 +1,10 @@ import React from 'react' import { render } from 'react-dom' import { App } from './components/app' +import { handleOidcSilentCallback } from './services/oidc-silent-callback' import 'monaco-editor/esm/vs/base/browser/ui/codicons/codicon/codicon.css' import './style.css' import '@ag-grid-community/styles/ag-grid.css' import '@ag-grid-community/styles/ag-theme-balham.css' -render(, document.getElementById('root')) +if (!handleOidcSilentCallback()) render(, document.getElementById('root')) diff --git a/apps/sensenet/src/services/oidc-silent-callback.ts b/apps/sensenet/src/services/oidc-silent-callback.ts new file mode 100644 index 0000000000..aedb7425d5 --- /dev/null +++ b/apps/sensenet/src/services/oidc-silent-callback.ts @@ -0,0 +1,18 @@ +import { UserManager } from '@sensenet/authentication-oidc-react' + +/** Relay the iframe response before repository discovery or session initialization. */ +export const handleOidcSilentCallback = () => { + if (window.location.pathname !== '/authentication/silent-callback' || window.parent === window) return false + + // The public callback only notifies its parent. The initiating UserManager + // validates the frame, origin, state and authorization response there. + const manager = new UserManager({ + response_type: 'code', + automaticSilentRenew: false, + monitorSession: false, + }) + manager.signinSilentCallback(window.location.href).catch(() => { + console.error('The silent sign-in response could not be delivered.') + }) + return true +} diff --git a/apps/sensenet/test/external-provider-switching.test.tsx b/apps/sensenet/test/external-provider-switching.test.tsx new file mode 100644 index 0000000000..68398e4f56 --- /dev/null +++ b/apps/sensenet/test/external-provider-switching.test.tsx @@ -0,0 +1,211 @@ +import React, { useCallback, useState } from 'react' +import { render, unmountComponentAtNode } from 'react-dom' +import { act, Simulate } from 'react-dom/test-utils' +import { BrowserRouter } from 'react-router-dom' +import { prepareExternalLogin } from '../../../packages/sn-auth-react/src/external-login' +import { RepositoryProvider } from '../src/context/repository-provider' +import { SnAuthRepositoryProvider } from '../src/context/sn-auth-repository-provider' +import { getAuthConfig } from '../src/services/auth-config' +import { + clearActiveRepositorySelection, + getSelectedSnAuthRepository, + oidcAuthConfigKey, + snAuthConfigKey, + startSnAuthRepositoryLogin, +} from '../src/services/repository-session' + +const mockLogger = { error: jest.fn(), warning: jest.fn() } +const mockOidcLogin = jest.fn().mockResolvedValue(undefined) +const mockOidcAuthentication = { login: mockOidcLogin, logout: jest.fn(), oidcUser: undefined } +const mockHandoff = jest.fn() +const mockPrepareAuthentication = jest.fn().mockResolvedValue(true) +jest.mock('@sensenet/authentication-oidc-react', () => ({ + AuthenticationProvider: ({ children }: any) => children, + useOidcAuthentication: () => mockOidcAuthentication, +})) +jest.mock('../../../packages/sn-auth-react/src/external-login', () => ({ + ...jest.requireActual('../../../packages/sn-auth-react/src/external-login'), + prepareExternalLogin: jest.fn(), +})) +jest.mock('../src/services/auth-config', () => ({ getAuthConfig: jest.fn() })) +jest.mock('@sensenet/hooks-react', () => ({ + RepositoryContext: jest.requireActual('react').createContext(null), + useLogger: () => mockLogger, +})) +jest.mock('@sensenet/client-core', () => ({ + Repository: class { + configuration: any + constructor(configuration: any) { + this.configuration = configuration + } + reloadSchema() { + return Promise.resolve() + } + }, +})) +jest.mock('../src/globalStyles', () => ({ useGlobalStyles: () => ({ full: 'full' }) })) +jest.mock('../src/hooks', () => ({ useQuery: () => new URLSearchParams(window.location.search) })) +jest.mock('../src/components/full-screen-loader', () => ({ FullScreenLoader: () =>
Loading
})) +jest.mock('../src/components/NotificationComponent', () => ({ NotificationComponent: () => null })) +jest.mock('../src/components/login/not-authenticated-override', () => ({ NotAuthenticatedOverride: () => null })) +jest.mock('../src/components/login/session-lost-override', () => ({ SessionLostOverride: () => null })) +jest.mock( + '../src/components/login/login-page', + () => + function LoginPage({ handleSubmit }: any) { + return + }, +) + +type Mode = 'IdentityServer' | 'SNAuth' +const chosenRepository = 'https://chosen.example.invalid' +const oldRepository = 'https://old.example.invalid' +const currentRepository = 'https://current.example.invalid' +const settingsFor = (repoUrl: string, mode: Mode) => ({ + authServerSettings: { type: mode }, + userManagerSettings: { + authority: `https://${mode === 'SNAuth' ? 'snauth' : 'identity'}.example.invalid`, + client_id: 'provider-handoff-test', + redirect_uri: 'http://localhost/authentication/login-callback', + extraQueryParams: { snrepo: repoUrl }, + }, +}) +const opposite = (mode: Mode): Mode => (mode === 'IdentityServer' ? 'SNAuth' : 'IdentityServer') + +// This mirrors the parent provider handoff while retaining the real repository +// wrappers, pending-login storage and SNAuth initialization/no-session behavior. +const Harness = ({ initialMode, initialUrl = '' }: { initialMode: Mode; initialUrl?: string }) => { + const [mode, setMode] = useState(initialMode) + const [url, setUrl] = useState(initialUrl) + const changeAuthType = useCallback((repositoryUrl: string) => { + mockHandoff(repositoryUrl) + setUrl(repositoryUrl) + setMode((previous) => { + const next = opposite(previous) + if (next === 'SNAuth') startSnAuthRepositoryLogin(repositoryUrl) + localStorage.setItem('authType', next) + return next + }) + }, []) + const Provider = mode === 'IdentityServer' ? RepositoryProvider : SnAuthRepositoryProvider + return ( + +
{mode}
+ + +
Authenticated content
+
+
+ ) +} + +describe('external authentication provider switching', () => { + let element: HTMLDivElement + const originalLocation = window.location + const replace = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + localStorage.clear() + sessionStorage.clear() + window.history.replaceState(null, '', '/') + delete (window as any).location + window.location = { ...originalLocation, replace } as Location + ;(prepareExternalLogin as jest.Mock).mockResolvedValue('https://snauth.example.invalid/Login') + element = document.createElement('div') + document.body.appendChild(element) + }) + + afterEach(() => { + act(() => { + unmountComponentAtNode(element) + }) + element.remove() + window.location = originalLocation + localStorage.clear() + sessionStorage.clear() + }) + + const mount = (initialMode: Mode, initialUrl = '') => + act(async () => { + localStorage.setItem('authType', initialMode) + render(, element) + }) + const click = (label: string) => + act(async () => { + Simulate.click(Array.from(element.querySelectorAll('button')).find((button) => button.textContent === label)!) + }) + + it('starts SNAuth sign-in on the first selection after an IdentityServer logout', async () => { + clearActiveRepositorySelection() + ;(getAuthConfig as jest.Mock).mockImplementation((repositoryUrl: string) => + Promise.resolve(settingsFor(repositoryUrl, 'SNAuth')), + ) + await mount('IdentityServer') + await click('Select repository') + + expect(element.querySelector('[data-testid="active-mode"]')?.textContent).toBe('SNAuth') + expect(prepareExternalLogin).toHaveBeenCalledTimes(1) + expect(prepareExternalLogin).toHaveBeenCalledWith( + expect.objectContaining({ repoUrl: chosenRepository, authServerUrl: 'https://snauth.example.invalid' }), + expect.any(Function), + ) + expect(replace).toHaveBeenCalledWith('https://snauth.example.invalid/Login') + expect(getSelectedSnAuthRepository()).toBe(chosenRepository) + expect(mockLogger.error).not.toHaveBeenCalled() + }) + + it('hands a selected IdentityServer repository over from SNAuth without a compatibility error', async () => { + ;(getAuthConfig as jest.Mock).mockImplementation((repositoryUrl: string) => + Promise.resolve(settingsFor(repositoryUrl, 'IdentityServer')), + ) + await mount('SNAuth') + await click('Select repository') + + expect(element.querySelector('[data-testid="active-mode"]')?.textContent).toBe('IdentityServer') + expect(mockOidcLogin).toHaveBeenCalledTimes(1) + expect(JSON.parse(localStorage.getItem(oidcAuthConfigKey)!).userManagerSettings.extraQueryParams.snrepo).toBe( + chosenRepository, + ) + expect(mockLogger.error).not.toHaveBeenCalled() + expect(prepareExternalLogin).not.toHaveBeenCalled() + }) + + it.each([ + ['IdentityServer', false], + ['SNAuth', false], + ['IdentityServer', true], + ['SNAuth', true], + ] as const)( + 'ignores late %s settings after a newer repository selection (failure: %s)', + async (initialMode, failure) => { + let resolveOld!: (settings: ReturnType) => void + let rejectOld!: (error: Error) => void + const oldSettings = new Promise>((resolve, reject) => { + resolveOld = resolve + rejectOld = reject + }) + ;(getAuthConfig as jest.Mock).mockImplementation((repositoryUrl: string) => + repositoryUrl === oldRepository ? oldSettings : Promise.resolve(settingsFor(repositoryUrl, initialMode)), + ) + if (initialMode === 'SNAuth') startSnAuthRepositoryLogin(currentRepository) + await mount(initialMode, oldRepository) + await click('Select current repository') + const configKey = initialMode === 'IdentityServer' ? oidcAuthConfigKey : snAuthConfigKey + const activeConfig = localStorage.getItem(configKey) + expect(JSON.parse(activeConfig!).userManagerSettings.extraQueryParams.snrepo).toBe(currentRepository) + + await act(async () => { + if (failure) rejectOld(new Error('The superseded repository is unavailable')) + else resolveOld(settingsFor(oldRepository, opposite(initialMode))) + await oldSettings.catch(() => {}) + }) + + expect(mockHandoff).not.toHaveBeenCalled() + expect(element.querySelector('[data-testid="active-mode"]')?.textContent).toBe(initialMode) + expect(localStorage.getItem(configKey)).toBe(activeConfig) + expect(mockLogger.error).not.toHaveBeenCalled() + expect(mockLogger.warning).not.toHaveBeenCalled() + }, + ) +}) diff --git a/apps/sensenet/test/oidc-silent-bootstrap.test.tsx b/apps/sensenet/test/oidc-silent-bootstrap.test.tsx new file mode 100644 index 0000000000..82c7f88d6b --- /dev/null +++ b/apps/sensenet/test/oidc-silent-bootstrap.test.tsx @@ -0,0 +1,110 @@ +import { UserManager } from 'oidc-client' +import { handleOidcSilentCallback } from '../src/services/oidc-silent-callback' + +const mockRender = jest.fn() +jest.mock('react-dom', () => ({ + ...jest.requireActual('react-dom'), + render: (...args: any[]) => mockRender(...args), +})) +jest.mock('../src/components/app', () => ({ App: () => null })) + +describe('OIDC silent callback bootstrap', () => { + const parentDescriptor = Object.getOwnPropertyDescriptor(window, 'parent')! + const postMessage = jest.fn() + const callbackUrl = '/authentication/silent-callback?code=synthetic-code&state=synthetic-state' + + const insideFrame = () => Object.defineProperty(window, 'parent', { configurable: true, value: { postMessage } }) + const atTopLevel = () => Object.defineProperty(window, 'parent', { configurable: true, value: window }) + const bootstrap = () => { + jest.isolateModules(() => { + // Keep the real relay and its UserManager prototype shared with the spies. + jest.doMock('../src/services/oidc-silent-callback', () => ({ handleOidcSilentCallback })) + require('../src/index') + }) + } + + beforeEach(() => { + jest.clearAllMocks() + localStorage.clear() + sessionStorage.clear() + atTopLevel() + window.history.replaceState(null, '', '/') + }) + + afterEach(() => { + jest.restoreAllMocks() + Object.defineProperty(window, 'parent', parentDescriptor) + localStorage.clear() + sessionStorage.clear() + }) + + it.each(['IdentityServer', 'SNAuth', 'Local'])( + 'relays the real callback without bootstrapping the remembered %s provider', + async (authType) => { + localStorage.setItem('authType', authType) + localStorage.setItem('sn-auth-config', JSON.stringify({ unrelated: 'remembered-repository' })) + localStorage.setItem('unrelated-token', 'preserve-local-session') + sessionStorage.setItem('unrelated-tab-session', 'preserve-tab-session') + const savedLocal = JSON.stringify(localStorage) + const savedSession = JSON.stringify(sessionStorage) + window.history.replaceState(null, '', callbackUrl) + insideFrame() + const getUser = jest.spyOn(UserManager.prototype, 'getUser') + const signinRedirect = jest.spyOn(UserManager.prototype, 'signinRedirect') + const signinSilent = jest.spyOn(UserManager.prototype, 'signinSilent') + const storeUser = jest.spyOn(UserManager.prototype, 'storeUser') + const storageRead = jest.spyOn(Storage.prototype, 'getItem') + const storageWrite = jest.spyOn(Storage.prototype, 'setItem') + const storageRemove = jest.spyOn(Storage.prototype, 'removeItem') + const fetch = jest.spyOn(window, 'fetch') + const xhr = jest.spyOn(XMLHttpRequest.prototype, 'open') + const interval = jest.spyOn(window, 'setInterval') + const timeout = jest.spyOn(window, 'setTimeout') + + bootstrap() + await Promise.resolve() + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith(window.location.href, window.location.origin) + expect(mockRender).not.toHaveBeenCalled() + expect(getUser).not.toHaveBeenCalled() + expect(signinRedirect).not.toHaveBeenCalled() + expect(signinSilent).not.toHaveBeenCalled() + expect(storeUser).not.toHaveBeenCalled() + expect(storageRead).not.toHaveBeenCalled() + expect(storageWrite).not.toHaveBeenCalled() + expect(storageRemove).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() + expect(xhr).not.toHaveBeenCalled() + expect(interval).not.toHaveBeenCalled() + expect(timeout).not.toHaveBeenCalled() + expect(JSON.stringify(localStorage)).toBe(savedLocal) + expect(JSON.stringify(sessionStorage)).toBe(savedSession) + }, + ) + + it.each([ + '/authentication/login-callback?code=synthetic-code&state=synthetic-state', + '/authentication/callback?auth_code=synthetic-code&state=synthetic-state', + '/authentication/silent-callback-extra', + '/', + ])('keeps the application bootstrap for the unrelated route %s', (path) => { + window.history.replaceState(null, '', path) + insideFrame() + + bootstrap() + + expect(mockRender).toHaveBeenCalledTimes(1) + expect(postMessage).not.toHaveBeenCalled() + }) + + it('keeps the application bootstrap for a top-level silent callback navigation', () => { + window.history.replaceState(null, '', callbackUrl) + atTopLevel() + + bootstrap() + + expect(mockRender).toHaveBeenCalledTimes(1) + expect(postMessage).not.toHaveBeenCalled() + }) +})