Summary
Authentication-level step-up (LoA-substantial → LoA-high, i.e. level 3 → 4) works for registered OIDC clients but is not available for unregistered clients — the goto-based flow that Altinn Apps and other platform components use via GET authentication/api/v1/authentication.
Today an App cannot ask the user to re-authenticate at a higher level. If a user already has a level-3 session and an App task requires level 4, the existing session is silently reused at level 3 and the user is never stepped up.
This issue scopes the work to bring step-up to the unregistered-client flow.
Background — how the two flows differ today
Registered clients (GET authentication/api/v1/authorize → OidcServerService.Authorize):
- Reads
acr_values from the request.
- Compares the existing session's
Acr against the requested values via AuthenticationHelper.NeedAcrUpgrade (OidcServerService.cs:200-213).
- If the session already meets the requested LoA → reuse + slide expiry. If not → re-drive the user upstream (ID-porten) at the requested LoA, sending
acr_values as-is for a known user (BuildUpstreamAuthorizeUrl, the overload with hasExistingSession, OidcServerService.cs:1432-1482).
Unregistered clients (GET authentication/api/v1/authentication → AuthenticationController.AuthenticateUser → OidcServerService.AuthorizeUnregisteredClient):
AuthenticateUser only accepts goTo and dontChooseReportee. There is no parameter to request a level (AuthenticationController.cs:157-158).
- It hardcodes
AcrValues = [] when building the request (AuthenticationController.cs:336).
- Session reuse happens in the controller (
HandleSessionRefresh, HandleAuthenticateFromSessionResult, A2-ticket) without any ACR comparison — a level-3 session satisfies everything (AuthenticationController.cs:242-326).
AuthorizeUnregisteredClient never inspects an existing session and never calls NeedAcrUpgrade; it always mints a transaction and redirects upstream (OidcServerService.cs:243-271).
- Its
BuildUpstreamAuthorizeUrl overload defaults empty acr_values to selfregistered-email idporten-loa-substantial (level 3) and lacks the hasExistingSession widening control (OidcServerService.cs:1484-1513).
What already works (no change needed)
- The model already has the field:
AuthorizeUnregisteredClientRequest.AcrValues exists (AuthorizeUnregisteredClientRequest.cs:23) — it's just always passed empty.
- The upstream callback is identical for both fresh login and step-up.
BuildUpstreamRedirectUri() returns a fixed, parameterless URL authentication/api/v1/upstream/callback (OidcServerService.cs:1096-1099), used for registered, unregistered, new-session and step-up alike. The flows are differentiated server-side via the state → upstream login transaction, not via the callback URL.
HandleUpstreamCallback already does the right thing on step-up: it deletes the prior session (OidcServerService.cs:284-293) and creates a fresh session storing the new (higher) upstream Acr (CreateOrUpdateOidcSession, OidcServerService.cs:1212), then redirects back to the App via RedirectToGoTo (OidcServerService.cs:404-415, OidcFrontChannelController.cs:136-137).
➡️ Conclusion on the callback question: the return URL the user is sent to from the upstream ID provider is the same whether or not a prior session existed. No new callback endpoint and no callback changes are required for step-up.
Gaps to close
-
No way to request a level on the goto endpoint. AuthenticateUser needs an optional input (e.g. securityLevel / acr_values) that maps into AuthorizeUnregisteredClientRequest.AcrValues. Today it's hardcoded empty (AuthenticationController.cs:336).
-
Session reuse ignores ACR. The controller's reuse branches must compare the requested level against the current session's Acr (reuse AuthenticationHelper.NeedAcrUpgrade). If an upgrade is needed, skip reuse and fall through to AuthorizeUnregisteredClient with the requested AcrValues.
-
AuthorizeUnregisteredClient has no upgrade/session awareness. It should know whether a live session exists so the upstream acr_values is sent as-is for a known user (mirroring the hasExistingSession logic in the registered overload at OidcServerService.cs:1456-1463) rather than being widened with selfregistered-email.
-
Validation. The incoming level must be validated against the allowed set (selfregistered-email, idporten-loa-substantial, idporten-loa-high, level0, level1, level2) — see AuthorizeRequestValidator.cs:12. An equivalent guard is needed for the goto endpoint, plus mapping of a friendly value (securityLevel=4 and/or acr_values=idporten-loa-high) into the internal AcrValues.
Design options (the "new endpoint vs. something else" question)
Option A — extend the existing endpoint (recommended, minimal). No new endpoint.
- Add an optional
acr_values (and/or securityLevel) query param to AuthenticateUser.
- Validate + map it into
AuthorizeUnregisteredClientRequest.AcrValues.
- Add the
NeedAcrUpgrade comparison to the three reuse branches; on required upgrade, bypass reuse.
- Add a
hasExistingSession parameter to the unregistered BuildUpstreamAuthorizeUrl overload and suppress selfregistered-email widening when the user is known.
Option B — funnel the goto flow through the existing Authorize. Reuse the registered-client Authorize path (which already has reuse + NeedAcrUpgrade + upgrade-aware upstream URL building) by representing the goto flow as an internal pseudo-client. More code reuse, but requires synthesizing client_id/redirect_uri/state for the goto flow and is a larger refactor.
Recommendation: Option A for a contained change; consider Option B later to converge the two code paths and avoid the duplicated BuildUpstreamAuthorizeUrl overloads.
Acceptance criteria
Test scenarios
- Level-3 session + level-4 request → upstream step-up → level-4 session → redirect to goto.
- Level-4 session + level-4 request → no upstream round-trip (reuse).
- No session + level-4 request → standard login at LoA-high.
- Invalid
acr_values/securityLevel → rejected.
- Existing level-3 login (no level requested) → unchanged (defaults to substantial).
- Self-identified /
selfregistered-email user requesting step-up to high (interaction with the existing RegisterSelfIdentifiedUserProvisioning / epost-bruker path — verify no regression; see TODO at OidcServerService.cs:381).
Open questions
- Preferred public contract for the goto endpoint: friendly
securityLevel=4 vs. OIDC-native acr_values=idporten-loa-high (or accept both)?
- Should down-grade requests (asking for a lower level than the current session) be a no-op reuse, as in the registered flow?
- Any App-frontend contract changes needed so Apps know to append the level when a task requires it?
Analysis based on the current main / OIDC server code paths referenced above.
Summary
Authentication-level step-up (LoA-substantial → LoA-high, i.e. level 3 → 4) works for registered OIDC clients but is not available for unregistered clients — the
goto-based flow that Altinn Apps and other platform components use viaGET authentication/api/v1/authentication.Today an App cannot ask the user to re-authenticate at a higher level. If a user already has a level-3 session and an App task requires level 4, the existing session is silently reused at level 3 and the user is never stepped up.
This issue scopes the work to bring step-up to the unregistered-client flow.
Background — how the two flows differ today
Registered clients (
GET authentication/api/v1/authorize→OidcServerService.Authorize):acr_valuesfrom the request.Acragainst the requested values viaAuthenticationHelper.NeedAcrUpgrade(OidcServerService.cs:200-213).acr_valuesas-is for a known user (BuildUpstreamAuthorizeUrl, the overload withhasExistingSession,OidcServerService.cs:1432-1482).Unregistered clients (
GET authentication/api/v1/authentication→AuthenticationController.AuthenticateUser→OidcServerService.AuthorizeUnregisteredClient):AuthenticateUseronly acceptsgoToanddontChooseReportee. There is no parameter to request a level (AuthenticationController.cs:157-158).AcrValues = []when building the request (AuthenticationController.cs:336).HandleSessionRefresh,HandleAuthenticateFromSessionResult, A2-ticket) without any ACR comparison — a level-3 session satisfies everything (AuthenticationController.cs:242-326).AuthorizeUnregisteredClientnever inspects an existing session and never callsNeedAcrUpgrade; it always mints a transaction and redirects upstream (OidcServerService.cs:243-271).BuildUpstreamAuthorizeUrloverload defaults emptyacr_valuestoselfregistered-email idporten-loa-substantial(level 3) and lacks thehasExistingSessionwidening control (OidcServerService.cs:1484-1513).What already works (no change needed)
AuthorizeUnregisteredClientRequest.AcrValuesexists (AuthorizeUnregisteredClientRequest.cs:23) — it's just always passed empty.BuildUpstreamRedirectUri()returns a fixed, parameterless URLauthentication/api/v1/upstream/callback(OidcServerService.cs:1096-1099), used for registered, unregistered, new-session and step-up alike. The flows are differentiated server-side via thestate→ upstream login transaction, not via the callback URL.HandleUpstreamCallbackalready does the right thing on step-up: it deletes the prior session (OidcServerService.cs:284-293) and creates a fresh session storing the new (higher) upstreamAcr(CreateOrUpdateOidcSession,OidcServerService.cs:1212), then redirects back to the App viaRedirectToGoTo(OidcServerService.cs:404-415,OidcFrontChannelController.cs:136-137).➡️ Conclusion on the callback question: the return URL the user is sent to from the upstream ID provider is the same whether or not a prior session existed. No new callback endpoint and no callback changes are required for step-up.
Gaps to close
No way to request a level on the goto endpoint.
AuthenticateUserneeds an optional input (e.g.securityLevel/acr_values) that maps intoAuthorizeUnregisteredClientRequest.AcrValues. Today it's hardcoded empty (AuthenticationController.cs:336).Session reuse ignores ACR. The controller's reuse branches must compare the requested level against the current session's
Acr(reuseAuthenticationHelper.NeedAcrUpgrade). If an upgrade is needed, skip reuse and fall through toAuthorizeUnregisteredClientwith the requestedAcrValues.AuthorizeUnregisteredClienthas no upgrade/session awareness. It should know whether a live session exists so the upstreamacr_valuesis sent as-is for a known user (mirroring thehasExistingSessionlogic in the registered overload atOidcServerService.cs:1456-1463) rather than being widened withselfregistered-email.Validation. The incoming level must be validated against the allowed set (
selfregistered-email,idporten-loa-substantial,idporten-loa-high,level0,level1,level2) — seeAuthorizeRequestValidator.cs:12. An equivalent guard is needed for the goto endpoint, plus mapping of a friendly value (securityLevel=4and/oracr_values=idporten-loa-high) into the internalAcrValues.Design options (the "new endpoint vs. something else" question)
Option A — extend the existing endpoint (recommended, minimal). No new endpoint.
acr_values(and/orsecurityLevel) query param toAuthenticateUser.AuthorizeUnregisteredClientRequest.AcrValues.NeedAcrUpgradecomparison to the three reuse branches; on required upgrade, bypass reuse.hasExistingSessionparameter to the unregisteredBuildUpstreamAuthorizeUrloverload and suppressselfregistered-emailwidening when the user is known.Option B — funnel the goto flow through the existing
Authorize. Reuse the registered-clientAuthorizepath (which already has reuse +NeedAcrUpgrade+ upgrade-aware upstream URL building) by representing the goto flow as an internal pseudo-client. More code reuse, but requires synthesizingclient_id/redirect_uri/statefor the goto flow and is a larger refactor.Recommendation: Option A for a contained change; consider Option B later to converge the two code paths and avoid the duplicated
BuildUpstreamAuthorizeUrloverloads.Acceptance criteria
idporten-loa-high(level 4).acr_valuesis sent to ID-porten as-is (noselfregistered-emailwidening).gotoURL.authentication/api/v1/upstream/callback; existing fresh-login behaviour is unchanged (regression-tested).Test scenarios
acr_values/securityLevel→ rejected.selfregistered-emailuser requesting step-up to high (interaction with the existingRegisterSelfIdentifiedUserProvisioning/ epost-bruker path — verify no regression; see TODO atOidcServerService.cs:381).Open questions
securityLevel=4vs. OIDC-nativeacr_values=idporten-loa-high(or accept both)?Analysis based on the current
main/ OIDC server code paths referenced above.