Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions apps/sensenet/AUTHENTICATION-TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 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. |
| 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
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.

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.
5 changes: 5 additions & 0 deletions apps/sensenet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 13 additions & 3 deletions apps/sensenet/src/components/app-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthServerType>(
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<string>()
Expand Down Expand Up @@ -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)
}, [])

Expand Down Expand Up @@ -135,14 +144,15 @@ 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 : '')
if (repoUrl) {
if (initialResetRepository) selectLocal(initialResetRepository)
else selectRepository(repoUrl)
}
}, [selectRepository, selectLocal, initialResetRepository])
}, [selectRepository, selectLocal, initialResetRepository, initialSnAuthCallback])

snInjector
.getInstance(CommandProviderManager)
Expand Down
33 changes: 22 additions & 11 deletions apps/sensenet/src/context/local-repository-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function LocalRepositoryProvider({
const [error, setError] = useState('')
const [chooseRepository, setChooseRepository] = useState(!repoUrl)
const mounted = useRef(true)
const activeSession = useRef<LocalRepositorySession>()
const [resetToken, setResetToken] = useState(() => {
if (initialResetToken) return initialResetToken
const location = new URL(window.location.href)
Expand All @@ -54,6 +55,7 @@ export function LocalRepositoryProvider({
}

const loadUser = async (current: LocalRepositorySession) => {
const { revision } = current
const repo = new Repository(
{
repositoryUrl: current.repositoryUrl,
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -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])

Expand All @@ -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.')
}
},
}}>
Expand All @@ -174,8 +186,7 @@ export function LocalRepositoryProvider({
onAuthenticated={async () => {
if (session) {
setError('')
await loadUser(session)
finishReset()
if (await loadUser(session)) finishReset()
}
}}
onChooseRepository={() => setChooseRepository(true)}
Expand Down
109 changes: 64 additions & 45 deletions apps/sensenet/src/context/repository-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -161,11 +172,7 @@ export function RepositoryProvider({
/>
)}
customEvents={customEvents}>
<RepoProvider
repoUrl={authState.repoUrl}
identityServerUrl={identityServerUrl}
clearAuthState={clearState}
changeAuthType={changeAuthType}>
<RepoProvider repoUrl={authState.repoUrl} identityServerUrl={identityServerUrl}>
{children}
</RepoProvider>
</AuthenticationProvider>
Expand All @@ -175,17 +182,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<Repository>()

Expand Down Expand Up @@ -230,19 +233,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 (
<div role="alert">
<AuthOverrideSkeleton
primaryText={errorOperation === 'logout' ? 'Sign-out could not be confirmed' : 'Authentication failed'}
secondaryText={
errorOperation === 'logout'
? 'The identity provider could not confirm sign-out. Return to the repository selection to continue.'
: 'Authentication could not be completed. Return to the repository selection to try again.'
}
buttonText="Choose another repository"
buttonOnClick={() => {
clearActiveRepositorySelection()
window.location.assign('/')
}}
/>
</div>
)
}

if (!oidcUser || oidcUser.expired || !repo) {
return null
Expand Down
Loading
Loading