8. OIDC State IdP Authentication and IdP Claims in Portal JWT
Date: 2026-02-27
Status
Accepted
Context
States may use an external OpenID Connect provider for authentication. The portal must support sign-in via that IdP and use identity data. This requires an OIDC flow and a way for the API and plugins to access IdP-derived claims (for example, phone, givenName, familyName).
Decision
We'll use a frontend-driven Authorization Code/PKCE flow: the Next.js server exchanges the authorization code with the IdP, validates the id_token using JWKS, issues a short-lived callback JWT, and returns it to the client. The client then POSTs the callback token to the .NET API's complete-login endpoint, which validates the token, copies non-principal IdP claims (phone, givenName, familyName, userId, etc.) into the portal JWT, and stores that JWT in an HttpOnly session cookie (see DC-242 addendum below). This also sets the path for having a stateless way of having PII be available in the app without having it persisted in any way (which is a requirement for some states).
Portal (API) —
GET /api/auth/oidc/{code}/config(for fetching config info: authorization endpoint, token endpoint, client id, redirect URI) andPOST /api/auth/oidc/complete-login(accepts callback token, sets the portal JWT as an HttpOnly cookie and returns metadata only).Complete-logintreats standard OIDC/OAuth claims as common and does not add them to the portal JWT (although this is subject to change later; expiry is an example); all other claims from the callback token are added so that plugins and handlers can read them viaUser.Claims.Portal (Next.js) —
POST /api/auth/oidc/callback: accepts code and code_verifier andstateCode, exchanges code with IdP, validates id_token via JWKS, issues short-lived callback JWT signed withOIDC_COMPLETE_LOGIN_SIGNING_KEY, returns callback token to client. Client secret and signing key are located in the Next.js environment.Frontend (Web) — State login page fetches config from the API at
GET /api/auth/oidc/{stateCode}/config, builds PKCE, redirects to IdP; IdP redirects to/callback. Callback page POSTs code and code_verifier (and current state asstateCode) to the Next.js OIDC callback API, receives the callback token, then POSTs it withstateCodeto the .NETcomplete-loginendpoint. The browser stores the resulting session cookie automatically; the SPA then re-reads/api/auth/statusto populate non-sensitive session claims (email, IAL, ID-proofing state) for UI gating.Plugins — Code that needs IdP-derived data (e.g. phone for household lookup) reads from the request's
ClaimsPrincipal; the portal JWT issued at complete-login includes those claims.
Configuration:
Next.js (when the current deployment state uses OIDC): OIDC_DISCOVERY_ENDPOINT, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_COMPLETE_LOGIN_SIGNING_KEY. API: Oidc:CompleteLoginSigningKey (it should match the same value as the Next.js one); for the public config endpoint, Oidc:DiscoveryEndpoint, Oidc:ClientId, Oidc:CallbackRedirectUri, and optionally Oidc:LanguageParam. See appsettings.Development.example.json and .env.example.
Consequences
Users can sign in with OIDC services (like Colorado's MyColorado). IdP claims such as phone, givenName, and familyName are available in the portal JWT and thus on User.Claims for the duration of the session. OIDC config is flat (one IdP per deployment); the client secret lives only in the Next.js server.
Development requires real or test IdP credentials and the correct redirect URI (http://localhost:3000/callback).
Why auth is in the main portal: The portal is the single application for all states. It must expose login endpoints, issue the portal JWT, and discover state plugins.
References
- ADR-0007: Multi-state plugin approach.
- Portal (API):
OidcController,IJwtTokenService.GenerateToken(user, additionalClaims). - Portal (Next.js): OIDC callback API route; state login page (OIDC flow), callback page,
oidc-pkce.
Related ADRs
- ADR-0007: Multi-state plugin approach. OIDC extends the auth flow; IdP claims are carried in the portal JWT.
Addendum (DC-242, 2026-04-08): Portal session JWT moved to HttpOnly cookie
Context
A penetration test flagged that the portal session JWT was being stored in sessionStorage.auth_token, which makes it readable by any XSS payload running on the portal origin.
Decision
The portal session JWT is now transported via an HttpOnly, Secure, SameSite=Lax cookie (sebt_portal_session, see SEBT.Portal.Api/Services/AuthCookies.cs). The token is no longer returned in any response body and is no longer accessible to JavaScript.
POST /api/auth/oidc/complete-loginwrites the JWT to the session cookie. The response body now carries only non-sensitive metadata (returnUrlfor step-up navigation, otherwise an empty body). SeeCompleteLoginResponse.POST /api/auth/otp/validateandPOST /api/auth/refreshwrite the JWT to the session cookie and return204 No Content.POST /api/auth/logoutclears the session cookie (matching attributes + past expiry) and returns204 No Content.GET /api/auth/statusis the SPA's only path to session state — it reads validated JWT claims off theUserprincipal and returns the non-sensitive subset (email, IAL, ID-proofing fields) the UI needs for routing and analytics. The JWT itself never leaves the cookie.JwtBearermiddleware (configured inProgram.cs) falls back to thesebt_portal_sessioncookie viaOnMessageReceivedwhen noAuthorizationheader is present. The header path is preserved for service-to-service callers.
The JWT's claim shape, signing key, and expiry behavior are unchanged from the original ADR — only the storage and transport changed.
Consequences
- An XSS payload on the portal origin can no longer read the session JWT.
- The SPA cannot decode the JWT directly; non-sensitive claims must be exposed via
/auth/status. Adding new UI gates that depend on JWT claims means adding fields toAuthorizationStatusResponse. - All authenticated
fetchcalls from the SPA must include the cookie. TheapiFetchwrapper setscredentials: 'same-origin'; the Next.js/api/*proxy forwardsCookieandSet-Cookieheaders between the browser and the .NET API. - E2E tests seed authentication by writing the cookie directly into the Playwright browser context (
e2e/fixtures/auth.ts) and mocking/api/auth/status(e2e/fixtures/api-routes.ts).
References
SEBT.Portal.Api/Services/AuthCookies.csSEBT.Portal.Api/Controllers/Auth/{AuthController,OidcController,OtpController}.csSEBT.Portal.Api/Program.cs(JwtBearerEvents.OnMessageReceived)SEBT.Portal.Api/Models/{AuthorizationStatusResponse,CompleteLoginResponse}.csSEBT.Portal.Web/src/api/client.ts,src/features/auth/context/AuthContext.tsx,src/lib/jwt.tsSEBT.Portal.Tests/Unit/Services/AuthCookiesTests.cs,SEBT.Portal.Tests/Integration/AuthCookieAuthenticationTests.cs
Addendum (DC-243, 2026-04-10): Server-side OIDC pre-auth session
Context
A penetration test found that the OIDC login flow was fully stateless from the portal's perspective: code_verifier was generated in the browser and sent in cleartext, authorization codes and ID tokens could be replayed from any machine to mint unlimited portal sessions, the state parameter was not validated server-side, and the stateCode route parameter accepted arbitrary values. The assessor demonstrated full account takeover by intercepting an authorization code and replaying it from a separate machine without any session binding.
Decision
The OIDC flow now uses a server-side pre-auth session that binds PKCE, CSRF state, and token exchange to the browser that initiated the login.
Pre-auth session lifecycle:
GET /api/auth/oidc/{stateCode}/configgenerates PKCE (code_verifier+code_challenge) and a randomstateparameter on the server. These are stored in aPreAuthSessionrecord inHybridCache(L1 memory + optional L2 Redis) with a 15-minute TTL. The session ID is set as anoidc_sessionHttpOnly, Secure,SameSite=Strictcookie. The response returnsstate,code_challenge, and the pinnedauthorizationEndpoint— never thecode_verifier.POST /api/auth/oidc/callbackrequires theoidc_sessioncookie, validatesstateandstateCodeagainst the stored session, uses the server-storedcode_verifierfor the token exchange (the browser never sends it), verifies the id_token with strict 10-second clock skew, and advances the session toCallbackCompleted.POST /api/auth/oidc/complete-loginrequires the sameoidc_sessioncookie, verifies the callback token hash matches the session, enforces one-time use (session advances toLoginCompleted), mints the portal JWT, clears the pre-auth cookie, and removes the session from cache.
Token exchange moved from Next.js to .NET:
The OIDC code→token exchange and id_token verification previously happened in a Next.js API route (/api/auth/oidc/callback/route.ts). This route is deleted. The exchange now happens in OidcExchangeService on the .NET API, which owns the client secret, discovery document fetching, JWKS verification, and callback token signing. The Next.js proxy forwards all /api/* requests to .NET without interception.
Endpoint hardening:
stateCodeis validated against a server-side allowlist derived from loaded per-state OIDC configuration.- The PingOne authorization endpoint is pinned in appsettings (
Oidc:AuthorizationEndpoint), not fetched from the IdP discovery document at request time. OidcOriginValidationMiddlewarerequires a matchingOriginheader on all OIDC POST endpoints; mismatchedRefereris logged but not enforced.- ID token
expis enforced with ≤10-second clock skew (previously ~60 seconds). - The callback token JWT now includes
iss/audclaims matching the portal origin to prevent cross-environment confusion. Oidc:CompleteLoginSigningKeyis validated at startup to be ≥32 characters.
Config migration:
OIDC secrets (ClientSecret, CompleteLoginSigningKey, step-up equivalents) moved from Next.js .env.local to the .NET API's per-state appsettings (Oidc:ClientSecret, Oidc:StepUp:ClientSecret). New required keys: Oidc:AuthorizationEndpoint and Oidc:StepUp:AuthorizationEndpoint. The Next.js env schema no longer includes OIDC variables.
Consequences
- Authorization code and ID token replay from another machine is no longer possible — the attacker would need the
oidc_sessioncookie, which is HttpOnly andSameSite=Strict. code_verifiernever appears in browser-visible network traffic, sessionStorage, or request bodies.- Each login flow consumes its session exactly once; a second attempt with the same cookie returns 403.
- The Next.js process no longer holds OIDC client secrets, reducing its attack surface.
- Deployment configs must add
Oidc:ClientSecretandOidc:AuthorizationEndpointto the API's per-state appsettings and can remove the correspondingOIDC_*env vars from the Next.js container. - The
oidc_sessioncookie is short-lived (15 min) and cleared after successful login; it is not present during normal authenticated usage.
References
SEBT.Portal.Api/Services/{PreAuthSession,PreAuthSessionStore,OidcSessionCookie,OidcExchangeService,PkceHelper,StateAllowlist}.csSEBT.Portal.Api/Middleware/OidcOriginValidationMiddleware.csSEBT.Portal.Api/Controllers/Auth/OidcController.csSEBT.Portal.Tests/Integration/OidcPreAuthSecurityTests.csappsettings.co.example.json(Oidc block with new keys)