Skip to content

feat: support federated OIDC login through the OAuth2 Authorization Server#24050

Draft
netroms wants to merge 6 commits into
masterfrom
fix/oauth2-token-dhisoidcuser-allowlist
Draft

feat: support federated OIDC login through the OAuth2 Authorization Server#24050
netroms wants to merge 6 commits into
masterfrom
fix/oauth2-token-dhisoidcuser-allowlist

Conversation

@netroms
Copy link
Copy Markdown
Contributor

@netroms netroms commented May 31, 2026

Goal

Support federated OIDC login through DHIS2's OAuth2 Authorization Server: a client (e.g. the Android app) runs authorization_code (+PKCE) against DHIS2, the user authenticates via an external IdP ("Log in with Google"), and DHIS2's Authorization Server issues its own tokens. The client only ever talks to DHIS2; the IdP is used solely to establish the user's identity — federated identity, not token pass-through.

This path was broken: after an OIDC login the authorization's principal is a DhisOidcUser wrapping a UserDetailsImpl, which Spring Security's SecurityJackson2Modules allowlist refuses to deserialize, so the /oauth2/token read returned HTTP 500 (reported by the Android team).

Approach: store a lean principal

Rather than persist the heavyweight OIDC principal and register custom Jackson mixins to allowlist it, the persistence layer stores a lean, Spring-native principal. At the toEntity serialization boundary, an Authentication whose principal is a DHIS2 UserDetails (DhisOidcUser for OIDC, UserDetailsImpl for form login) is swapped for a UsernamePasswordAuthenticationToken carrying just the DHIS2 username + authorities.

Why this works (verified against the Spring AS 1.5.5 sources):

  • OAuth2AuthorizationCodeAuthenticationProvider and OAuth2RefreshTokenAuthenticationProvider build the token-context principal from authorization.getAttribute(Principal.class.getName()) — the persisted attribute. So the token customizer sees the lean principal; getName() is the DHIS2 username, which becomes the JWT username claim that the resource server (Dhis2JwtAuthenticationManagerResolver, internal provider mapping_claim = username) resolves the user by.
  • The persisted attributes become entirely Spring-native, so they round-trip with no custom mixins.
  • principalName (which the consent store keys on) is left untouched.

This deletes more than it adds: the custom Dhis2OAuth2PrincipalJackson2Module and the speculative Jackson annotations on UserDetailsImpl are removed; the change is one ~12-line helper at the persistence boundary. It also fixes the federated flow end to end — the username/email claims previously carried the IdP sub, which broke both token issuance (NPE on the email scope) and resolution.

Tests

  • LeanPrincipalTest — unit test of the swap (no-op for client_credentials / non-UserDetails principals; form-login and federated principals leaned).
  • Dhis2OAuth2AuthorizationServiceIntegrationTest (Postgres) — a federated authorization persists and reads back as a lean principal with the DHIS2 username + authorities.
  • FederatedOidcTokenControllerTest (web e2e) — a DhisOidcUser-originated authorization_code exchange at /oauth2/token issues a JWT whose username/email claims are the DHIS2 user's (not the IdP sub), and /api/me with that token resolves to the user.

AI Assisted

netroms and others added 3 commits May 31, 2026 12:45
The OAuth2 Authorization Server stores the authenticated principal in the authorization attributes as JSON. After an external-OIDC login that principal is a DhisOidcUser wrapping a UserDetailsImpl, neither of which is on Spring Security's SecurityJackson2Modules deserialization allowlist, so any read of such an authorization (e.g. the /oauth2/token exchange) failed with HTTP 500: "... is not in the allowlist". Serialization succeeded, which is why the failure only showed up on read.

Register Dhis2OAuth2PrincipalJackson2Module on the authorization service's ObjectMapper to allowlist and (de)serialize both types, and make UserDetailsImpl round-trip cleanly through its Lombok builder (@Jacksonized) instead of the previous hand-written, never-exercised @JsonCreator factory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The new OIDC-principal regression test uses OAuth2AuthenticationToken from spring-security-oauth2-client, which dhis-test-integration only pulled in transitively. Declare it explicitly at test scope so the dependency:analyze check (run by the unit-test and sonarqube jobs) passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dule

- Drop @JsonTypeInfo from the DhisOidcUser mix-in (S4544): the principal's static type is the OAuth2User interface, so the allowlist default typing already writes the type id; round-trip re-verified.

- Make the UserDetailsImpl allowlist marker an interface (S1610, S2094).

- Suppress S1610 on the DhisOidcUser mix-in, which must stay a class to carry a @JsonCreator constructor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@netroms netroms changed the title fix: round-trip OIDC principal through OAuth2 authorization persistence feat: support federated OIDC login through the OAuth2 Authorization Server May 31, 2026
netroms and others added 3 commits June 1, 2026 00:46
Supersedes the custom Jackson-mixin approach. The authorization persistence layer now swaps a heavyweight DHIS2 principal (DhisOidcUser from a federated OIDC login, or UserDetailsImpl from a form login) for a lean Spring-native UsernamePasswordAuthenticationToken carrying just the DHIS2 username and authorities, at the toEntity serialization boundary. The persisted attributes then contain no DHIS2-custom types and round-trip without any custom mixins, so the /oauth2/token read no longer trips Spring Security's SecurityJackson2Modules allowlist.

Spring AS builds the token-context principal from the persisted principal attribute, so the token customizer sees the lean principal: getName() is the DHIS2 username, which becomes the JWT username claim the resource server resolves the user by. This makes the federated OIDC flow work end to end (the username/email claims previously carried the IdP sub).

- Delete Dhis2OAuth2PrincipalJackson2Module and the speculative Jackson annotations on UserDetailsImpl (no longer serialized into the authorization). - Add a leanPrincipal unit test, update the service integration test to assert the lean principal, and add a federated-OIDC web e2e (authorization_code -> /oauth2/token -> /api/me).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FederatedOidcTokenControllerTest uses PasswordEncoder (spring-security-crypto) to encode the client secret; dhis-test-web-api only pulled it in transitively. Declare it at test scope so the dependency:analyze check (run by the unit-test and sonarqube jobs) passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…test

SonarCloud S5977 — tests should not depend on randomly generated values. The test runs in isolation, so a fixed client id and authorization code are safe and deterministic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqubecloud
Copy link
Copy Markdown

@netroms netroms marked this pull request as draft June 1, 2026 09:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant