OIDC ⇄ admin-service JWT Token Exchange
1. Overview
Portal gateways authenticate their users against external identity providers (IdPs), but admin-service does not trust those IdPs directly. Instead, admin-service accepts validated OIDC claims from a trusted portal gateway (authenticated by X-API-KEY) and mints its own internal JWT. The result: admin-service has one JWT signing key, one JWT format, one authentication path to harden — regardless of how many IdPs a portal supports.
This page describes the exchange contract, claim mapping, JIT provisioning behaviour, and the known design gap for runtime tenant switching.
2. Trust Model
Responsibilities split cleanly:
| Concern | Portal Gateway | admin-service |
|---|---|---|
IdP client registration (client_id, client_secret, issuer URI) |
Yes — per-portal, per-IdP |
No |
IdP signature validation |
Yes — Spring Security validates |
No — trusts the portal to have done this |
Claim extraction + selection (subject, email, displayName) |
Yes — portal knows which claim maps to what |
No — accepts a normalised DTO |
User resolution / JIT provisioning |
No |
Yes — |
JWT signing |
No |
Yes — |
JWT claims shape (orgId, orgUser.id, linkedOrgs, authorities) |
No |
Yes — single source of truth across all admin-service consumers |
The X-API-KEY enforcement on /auth/** endpoints is what makes this trust model safe. Only portals configured with a valid admin-service API key can submit claims; an external actor cannot post arbitrary OIDC claims and obtain an admin-service JWT. See API-Key Injection at the Gateway.
3. Contract
3.1. Endpoint
POST /auth/token-exchange/oauth2 on admin-service. API-key authenticated. Implemented by [ExternalAuthResource](https://github.com/…;).
3.2. Request
class OAuth2TokenExchangeRequestDTO {
@NotNull Long registrationSystemId; // identifies the portal deployment / tenant
@NotNull String subjectId; // validated IdP subject claim
String email; // validated IdP email claim
String displayName; // IdP "name" / "given_name + family_name"
String providerType; // free-form IdP identifier (e.g. "azure-ad")
}
Headers:
-
X-API-KEY: <admin-service API key> -
Content-Type: application/json
4. JWT Claims Mapped from the Exchange
NimbusTokenProvider.createToken(…) populates the following, derived from the resolved OrgUser and Organisation:
| Claim | Source |
|---|---|
|
|
|
|
|
|
|
|
|
From the request |
|
Empty list in the OIDC path (populated by hash-based flows only) |
|
List of |
Authorities |
|
Expiry |
+24h from issue |
The orgId claim is what downstream code uses to scope data; linkedOrgs is what the portal (and a tenant-switcher UI) uses to enumerate which other tenants this user could switch to.
5. JIT Provisioning Behaviour
OAuth2AuthService.findOrProvisionUser creates missing records on first login:
-
Look up
OrgUserby(orgId, subjectId)— composite lookup viafindByExternalUserId. -
If present, return. If absent, create:
-
PersonWrapperwithfirstName/lastNamesplit fromdisplayName,emailfrom claim -
OrgUserlinking the Person to the Organisation withexternalUserId = subjectId
-
-
Concurrent first-login is handled — if two requests race, the second catches
DataIntegrityViolationExceptionand re-queries.
5.1. Implications
JIT provisioning is appropriate for public registration flows (any authenticated user is a legitimate customer). It is not appropriate for authenticated-staff portals like admin-portal, where a random OIDC-authenticated user should not auto-become an admin.
For admin-portal, JIT provisioning must be disabled or gated — see Open Design Questions for admin-portal.
6. Portal-Side Implementation
On the gateway:
-
Configure Spring Security
oauth2Login()with the portal’s trusted IdPs (per-IdP client_id, client_secret, issuer URI — usually fromapplication-prod.ymlor Vault). -
Spring Security handles the OIDC redirect, code-exchange with the IdP, and
id_tokensignature validation. The validatedOAuth2AuthenticationTokenappears in the security context. -
An
AuthenticationSuccessHandler(or a small@PostConstructlistener) callsAdminServiceAuthClient.exchangeOAuth2(…)with the extracted claims. -
The returned JWT is stored in the HTTP session (see Session-Held JWT & JSESSIONID).
The portal’s AdminServiceAuthClient holds a RestTemplate (or WebClient) preconfigured with the admin-service base URL and X-API-KEY interceptor. Reference: registration-portal’s existing AdminServiceAuthClient — registration-portal/src/main/java/…/service/AdminServiceAuthClient.java.
7. Runtime Tenant Switch
admin-portal requires runtime tenant switching — a staff user belonging to multiple organisations selects a tenant via the UI and has their JWT re-minted with the new orgId claim. The server-authoritative switch (POST /api/session/tenant) must call back to /auth/token-exchange/oauth2 to re-mint.
The ⚠ highlights the design gap: the current OAuth2TokenExchangeRequestDTO has registrationSystemId as its tenant selector, derived at request time from the gateway’s tenant resolution (hostname, header). admin-portal’s runtime switch needs to request a specific target organisation directly. Options documented below.
8. Open Design Questions for admin-portal
These are deferred to WS5 kickoff, when admin-portal starts hitting the endpoint for real. The decisions belong in the admin-portal design journal thread (2026-04/admin-portal-greenfield.adoc) and should be captured as ADRs when resolved.
| Question | Context / Options |
|---|---|
How does admin-portal communicate the target tenant on re-exchange? |
The current DTO uses
Recommended: option 1 — small, additive, explicit. |
Should JIT provisioning be enabled for admin-portal? |
No — admin users should not auto-provision. Options:
Recommended: option 1 or option 3. |
What authorities should admin-portal JWTs carry? |
OAuth2 path currently sets
Recommended: option 2 — admin-service already stores OrgUser authority; use it at mint. |
Logout — single-logout with the IdP, local session kill, or both? |
Portal-local logout is mandatory (invalidates |
9. Observations & Gotchas
-
providerTypeis free-form. The DTO documents it as "e.g.azure-ad`" but admin-service does not validate or switch on it today — it is passed through for diagnostic logging only. Do not rely on admin-service behaviour changing based on `providerType. -
JWT validity is 24 hours. No refresh token; the portal must re-exchange before expiry (it has the OAuth2
AuthorizedClientManagerto get a fresh IdP token and re-call). See Session-Held JWT & JSESSIONID for the refresh state machine. -
GET /auth/jwt-claimsis available for introspection. The portal can call this with a held JWT to retrieve parsed claims without decoding client-side — handy for/api/session/currentbootstrap, and for showing debug info in the dev banner. Signature-validated but expired tokens are accepted (explicit design, useful for post-expiry diagnostics). -
Concurrent first-login is handled.
OAuth2AuthService.findOrProvisionUsercatchesDataIntegrityViolationExceptionand re-queries — safe under load-balanced or multi-pod portal deployments.
10. Reference Implementation
| File | Relevance |
|---|---|
|
REST controller, path |
|
Claim-to-user resolution, JIT provisioning, JWT minting orchestration |
|
Request contract |
|
Response contract |
|
JWT signing, claim population, validity |
|
|
|
Portal-side client that calls |
11. Related Documentation
-
Portal Pattern — overall topology
-
Session-Held JWT & JSESSIONID — where the minted JWT lives, refresh, logout
-
API-Key Injection at the Gateway — the X-API-KEY trust enforcement
-
Multi-Tenancy — Organisation / RegistrationSystem / Tenant relationships
-
design-journal/2026-02/registration-portal-auth-flows.adoc— prior authentication-flow design -
design-journal/2026-01/oauth2-multi-tenant-idp.adoc— multi-tenant IdP configuration design -
design-journal/2026-04/admin-portal-greenfield.adoc— admin-portal thread; owns the open questions above