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

token-exchange-trust

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 id_token against IdP JWKS

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 — OAuth2AuthService.findOrProvisionUser

JWT signing

No

Yes — NimbusTokenProvider

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

3.3. Response (200 OK)

class OAuth2TokenExchangeResponseDTO {
    String  token;      // admin-service-signed JWT
    Instant expiresAt;  // absolute expiry (now + 24h at issue time)
}

3.4. Error responses

  • 400 Bad Request — malformed DTO

  • 401 Unauthorized — missing or invalid X-API-KEY

  • 500 Internal Server Error — upstream failure (RegistrationSystem not found, Organisation missing, OrgUser provisioning race that can’t be resolved, JWT signing failure)

4. JWT Claims Mapped from the Exchange

NimbusTokenProvider.createToken(…​) populates the following, derived from the resolved OrgUser and Organisation:

Claim Source

sub (standard)

OAuth2TokenExchangeRequestDTO.subjectId — the IdP’s stable subject identifier

userId / accountId (custom)

OrgUser.id — the admin-service-side principal

personId (custom)

OrgUser.person.id — the underlying Person record

orgId (custom)

Organisation.id — the current tenant for this JWT (derived from RegistrationSystem)

registrationSystemId (custom)

From the request

linkedPersonIds (custom)

Empty list in the OIDC path (populated by hash-based flows only)

linkedOrgs (custom)

List of {orgId, accessLevel} entries from LinkedOrg — the user’s cross-tenant access graph

Authorities

ROLE_USER in the OIDC path (no admin bootstrapping)

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:

  1. Look up OrgUser by (orgId, subjectId) — composite lookup via findByExternalUserId.

  2. If present, return. If absent, create:

    1. PersonWrapper with firstName/lastName split from displayName, email from claim

    2. OrgUser linking the Person to the Organisation with externalUserId = subjectId

  3. Concurrent first-login is handled — if two requests race, the second catches DataIntegrityViolationException and 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:

  1. Configure Spring Security oauth2Login() with the portal’s trusted IdPs (per-IdP client_id, client_secret, issuer URI — usually from application-prod.yml or Vault).

  2. Spring Security handles the OIDC redirect, code-exchange with the IdP, and id_token signature validation. The validated OAuth2AuthenticationToken appears in the security context.

  3. An AuthenticationSuccessHandler (or a small @PostConstruct listener) calls AdminServiceAuthClient.exchangeOAuth2(…​) with the extracted claims.

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

token-exchange-tenant-switch

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 registrationSystemId, which works for tenant-per-deployment portals. Options:

  1. Extend the DTO with an optional requestedOrgId (or requestedTenantId); when present and in linkedOrgs, override the registration-system-derived org. Cleanest; registration-portal unaffected.

  2. Add a separate endpoint /auth/token-exchange/oauth2/switch-tenant for the re-mint path; keeps the registration flow signature pristine.

  3. Require one RegistrationSystem per Organisation so admin-portal can pick the correct registrationSystemId per switch. Avoids API changes but couples the concepts.

Recommended: option 1 — small, additive, explicit.

Should JIT provisioning be enabled for admin-portal?

No — admin users should not auto-provision. Options:

  1. Add a jitProvisioning boolean to the DTO (defaults to true for compatibility); admin-portal sends false.

  2. Gate JIT provisioning server-side by providerType or by Organisation flag (Organisation-level "allow OIDC JIT" toggle).

  3. Always reject OIDC on admin-portal for unknown subjects; require staff onboarding to pre-create the OrgUser.

Recommended: option 1 or option 3.

What authorities should admin-portal JWTs carry?

OAuth2 path currently sets ROLE_USER only. Admin-portal needs ROLE_ADMIN for some endpoints. Options:

  1. Derive from IdP group claims (requires portal to pass groups through the DTO — DTO extension).

  2. Derive from OrgUser role stored in admin-service at provisioning / admin UI.

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 JSESSIONID, drops the session JWT). IdP single-logout is optional and IdP-dependent (some IdPs don’t support it cleanly). Covered in Session-Held JWT & JSESSIONID.

9. Observations & Gotchas

  • providerType is 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 AuthorizedClientManager to get a fresh IdP token and re-call). See Session-Held JWT & JSESSIONID for the refresh state machine.

  • GET /auth/jwt-claims is available for introspection. The portal can call this with a held JWT to retrieve parsed claims without decoding client-side — handy for /api/session/current bootstrap, 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.findOrProvisionUser catches DataIntegrityViolationException and re-queries — safe under load-balanced or multi-pod portal deployments.

10. Reference Implementation

File Relevance

admin-service/src/main/java/za/co/idealogic/event/admin/service/external/auth/ExternalAuthResource.java

REST controller, path /auth/**, API-key protected

admin-service/src/main/java/za/co/idealogic/event/admin/service/external/auth/OAuth2AuthService.java

Claim-to-user resolution, JIT provisioning, JWT minting orchestration

admin-service/src/main/java/za/co/idealogic/event/admin/service/external/auth/OAuth2TokenExchangeRequestDTO.java

Request contract

admin-service/src/main/java/za/co/idealogic/event/admin/service/external/auth/OAuth2TokenExchangeResponseDTO.java

Response contract

admin-service/src/main/java/za/co/idealogic/event/admin/security/jwt/NimbusTokenProvider.java

JWT signing, claim population, validity

admin-service/src/main/java/za/co/idealogic/event/admin/security/jwt/LinkedOrgClaim.java

linkedOrgs claim shape

registration-portal/src/main/java/…​/service/AdminServiceAuthClient.java

Portal-side client that calls /auth/token-exchange/oauth2

  • 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

12. Change History

Date Change

2026-04-24

Initial draft. Grounded in current admin-service code (ExternalAuthResource, OAuth2AuthService). Open questions surfaced for admin-portal WS5.