Skip to content

Support authentication step-up (level 3 → 4) for unregistered clients (goto flow) #2016

@TheTechArch

Description

@TheTechArch

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/authorizeOidcServerService.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/authenticationAuthenticationController.AuthenticateUserOidcServerService.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

  1. 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).

  2. 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.

  3. 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.

  4. 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

  • An App can trigger step-up via the goto endpoint by requesting idporten-loa-high (level 4).
  • A user with an existing level-3 session who hits a level-4 request is re-driven through ID-porten at LoA-high (not silently reused at level 3).
  • A user who already has a level-4 session is reused without an unnecessary upstream round-trip.
  • The requested level is validated; invalid values are rejected.
  • For a known user being upgraded, acr_values is sent to ID-porten as-is (no selfregistered-email widening).
  • After step-up, the new session records LoA-high and the user is redirected back to the original goto URL.
  • No change to authentication/api/v1/upstream/callback; existing fresh-login behaviour is unchanged (regression-tested).

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.

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

Status
🧪Test

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions