OAuth2/OIDC Authentication (Work in Progress)

This authentication method is currently under development and represents future functionality. The design documented here is subject to change as implementation progresses.

Overview

The OAuth2/OIDC authentication flow provides an alternative authentication method for organizations that use external Identity Providers (IdP) with Single Sign-On (SSO) capabilities. This authentication path coexists with the hash-based JWT authentication used for simpler integrations like WordPress sites.

After successful OAuth2 authentication with an external IdP, the system exchanges the IdP’s JWT token for an internal JWT token that contains the claims necessary for multi-dimensional authorization.

When to Use OAuth2 vs Hash-Based Auth

Authentication Method Use Case Example Scenarios

OAuth2/OIDC

Organizations with existing SSO infrastructure

Corporate identity providers, Keycloak, Azure AD, Okta, Auth0

Hash-Based JWT

Websites without OAuth/OIDC capability

WordPress sites with custom plugin, legacy CMS platforms

Architecture Components

The OAuth2 authentication flow involves four main components:

oauth2-architecture

Authentication Flow

Step 1: OAuth2 Authorization Code Flow

The Registration Portal acts as an OAuth2 client using JHipster’s default OAuth2 implementation for gateways.

# application.yml (Registration Portal Gateway)
spring:
  security:
    oauth2:
      client:
        registration:
          # Configured per tenant/organization
          tenant-idp:
            client-id: ${OAUTH2_CLIENT_ID}
            client-secret: ${OAUTH2_CLIENT_SECRET}
            scope: openid, profile, email
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          tenant-idp:
            issuer-uri: ${OAUTH2_ISSUER_URI}
            user-name-attribute: sub

User Flow:

  1. User clicks "Login with SSO" button

  2. Gateway redirects to external IdP login page

  3. User authenticates with IdP credentials

  4. IdP redirects back to gateway with authorization code

  5. Gateway exchanges code for IdP JWT token (access token + ID token)

Step 2: Token Exchange with Admin Service

Once the gateway receives the IdP JWT, it must exchange this token for an internal JWT that contains:

  • OrgUser ID

  • Organization ID

  • Person ID (if linked)

  • Security permissions for multi-dimensional authorization

token-exchange-sequence

Step 3: OrgUser Lookup and Mapping

The Admin Service resolves the IdP user identity to an internal OrgUser entity:

@Entity
@Table(name = "org_user")
public class OrgUser {
    @Id
    private Long id;

    @ManyToOne
    @JoinColumn(name = "org_id")
    private Organisation organisation;

    // Maps to IdP 'sub' claim
    @Column(name = "external_user_id")
    private String externalUserId;

    @Column(name = "email")
    private String email;

    @Column(name = "display_name")
    private String displayName;

    @ManyToOne
    @JoinColumn(name = "person_id")
    private Person person; // Optional: linked person for authorization

    // ... other fields
}

Mapping Strategy:

-- Lookup query: Find OrgUser by RegistrationSystem + IdP subject
SELECT ou.*
FROM org_user ou
JOIN organisation org ON org.id = ou.org_id
JOIN registration_system rs ON rs.org_id = org.id
WHERE rs.id = :registrationSystemId
  AND ou.external_user_id = :idpSubject;

Key Points:

  • RegistrationSystemId identifies which tenant/organization in Registration Portal

  • Each Registration Portal tenant is linked to exactly one RegistrationSystem in Admin Service

  • RegistrationSystem is linked to exactly one Organisation

  • OrgUser.externalUserId matches the IdP sub claim

  • If OrgUser.person is set, this user can access person-scoped data

Step 4: Internal JWT Generation

The Admin Service generates an internal JWT with claims needed for multi-dimensional authorization:

{
  "sub": "orguser:12345",
  "iss": "admin-service",
  "exp": 1735689600,
  "orgUserId": 12345,
  "orgId": 100,
  "personId": 5678,
  "authorities": ["ROLE_USER"],
  "email": "[email protected]",
  "displayName": "John Doe",
  "externalSub": "idp-sub-claim-value"
}

Step 5: Session-Based JWT Storage

Like the hash-based authentication flow, the internal JWT is stored in the HTTP session and never exposed to the Angular frontend:

// Gateway: Store JWT in session after token exchange
@PostMapping("/api/auth/oauth2/token-exchange")
public ResponseEntity<Void> exchangeOAuth2Token(
        @RequestHeader("Authorization") String idpToken,
        @RequestParam("registrationSystemId") Long registrationSystemId,
        HttpSession session) {

    // Exchange IdP JWT with Admin Service
    TokenResponse tokenResponse = adminServiceClient.exchangeOAuth2Token(
        idpToken,
        registrationSystemId
    );

    // Store internal JWT in HTTP session (NOT sent to client)
    session.setAttribute("JWT_TOKEN", tokenResponse.getToken());
    session.setAttribute("JWT_EXPIRES_AT", tokenResponse.getExpiresAt());
    session.setAttribute("ORG_USER_ID", tokenResponse.getOrgUserId());

    return ResponseEntity.ok().build();
}

Implementation Details

Gateway Configuration

The Registration Portal gateway uses JwtIssuerAuthenticationManagerResolver to accept JWTs from multiple issuers (external IdPs):

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .successHandler(new OAuth2AuthenticationSuccessHandler())
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .authenticationManagerResolver(
                    new JwtIssuerAuthenticationManagerResolver(
                        "https://idp1.example.com",
                        "https://idp2.example.com"
                    )
                )
            );
        return http.build();
    }
}

OAuth2 Success Handler

After successful OAuth2 authentication, trigger token exchange:

@Component
public class OAuth2AuthenticationSuccessHandler
        extends SimpleUrlAuthenticationSuccessHandler {

    @Autowired
    private AdminServiceClient adminServiceClient;

    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication) throws IOException {

        OAuth2AuthenticationToken oauth2Token =
            (OAuth2AuthenticationToken) authentication;

        // Extract IdP JWT token
        OAuth2AuthorizedClient authorizedClient =
            authorizedClientService.loadAuthorizedClient(
                oauth2Token.getAuthorizedClientRegistrationId(),
                oauth2Token.getName()
            );

        String idpAccessToken = authorizedClient
            .getAccessToken()
            .getTokenValue();

        // Determine RegistrationSystemId from tenant context
        Long registrationSystemId = tenantContext.getRegistrationSystemId();

        // Exchange IdP JWT for internal JWT
        TokenResponse tokenResponse = adminServiceClient.exchangeOAuth2Token(
            "Bearer " + idpAccessToken,
            registrationSystemId
        );

        // Store internal JWT in session
        HttpSession session = request.getSession();
        session.setAttribute("JWT_TOKEN", tokenResponse.getToken());
        session.setAttribute("JWT_EXPIRES_AT", tokenResponse.getExpiresAt());

        // Redirect to application
        super.onAuthenticationSuccess(request, response, authentication);
    }
}

Admin Service Token Exchange Endpoint

@RestController
@RequestMapping("/api/auth")
public class TokenExchangeController {

    @Autowired
    private JwtDecoder jwtDecoder; // Validates IdP JWT

    @Autowired
    private OrgUserService orgUserService;

    @Autowired
    private JwtTokenProvider tokenProvider;

    @PostMapping("/token-exchange/oauth2")
    public ResponseEntity<TokenResponse> exchangeOAuth2Token(
            @RequestHeader("Authorization") String idpToken,
            @RequestParam("registrationSystemId") Long registrationSystemId) {

        // 1. Validate IdP JWT signature using JwtIssuerAuthenticationManagerResolver
        Jwt jwt = jwtDecoder.decode(idpToken.replace("Bearer ", ""));

        // 2. Extract IdP claims
        String idpSubject = jwt.getClaimAsString("sub");
        String email = jwt.getClaimAsString("email");
        String displayName = jwt.getClaimAsString("name");

        // 3. Lookup OrgUser by RegistrationSystemId + IdP subject
        OrgUser orgUser = orgUserService.findByRegistrationSystemAndExternalUserId(
            registrationSystemId,
            idpSubject
        ).orElseThrow(() -> new UnauthorizedException(
            "No OrgUser found for IdP subject: " + idpSubject
        ));

        // 4. Generate internal JWT with OrgUser claims
        String internalJwt = tokenProvider.createToken(
            orgUser.getId(),
            orgUser.getOrganisation().getId(),
            orgUser.getPerson() != null ? orgUser.getPerson().getId() : null,
            email,
            displayName
        );

        // 5. Return internal JWT
        return ResponseEntity.ok(TokenResponse.builder()
            .token(internalJwt)
            .expiresAt(Instant.now().plus(12, ChronoUnit.HOURS))
            .orgUserId(orgUser.getId())
            .build());
    }
}

OrgUser Service Lookup

@Service
public class OrgUserService {

    @Autowired
    private OrgUserRepository orgUserRepository;

    /**
     * Find OrgUser by RegistrationSystemId and IdP subject claim.
     *
     * This resolves:
     * RegistrationSystem -> Organisation -> OrgUser (by externalUserId)
     */
    public Optional<OrgUser> findByRegistrationSystemAndExternalUserId(
            Long registrationSystemId,
            String externalUserId) {

        return orgUserRepository.findByRegistrationSystemIdAndExternalUserId(
            registrationSystemId,
            externalUserId
        );
    }
}
@Repository
public interface OrgUserRepository extends JpaRepository<OrgUser, Long> {

    @Query("""
        SELECT ou FROM OrgUser ou
        JOIN ou.organisation org
        JOIN org.registrationSystems rs
        WHERE rs.id = :registrationSystemId
          AND ou.externalUserId = :externalUserId
    """)
    Optional<OrgUser> findByRegistrationSystemIdAndExternalUserId(
        @Param("registrationSystemId") Long registrationSystemId,
        @Param("externalUserId") String externalUserId
    );
}

JWT Validation Configuration

The Admin Service must validate JWTs from multiple issuers (external IdPs):

@Configuration
public class JwtConfiguration {

    @Bean
    public JwtDecoder jwtDecoder() {
        // Support multiple IdP issuers
        return new JwtIssuerAuthenticationManagerResolver(
            "https://idp1.example.com",
            "https://idp2.example.com"
        ).jwtDecoder();
    }
}
# application.yml (Admin Service)
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # Support multiple issuers for token validation
          issuer-uri: ${JWT_ISSUER_URI:https://idp.example.com}
          audiences:
            - admin-service
            - registration-portal

Tenant Configuration

Each tenant in the Registration Portal can be configured with OAuth2/OIDC settings:

{
  "tenantId": "acme-corp",
  "registrationSystemId": 100,
  "authenticationMethod": "OAUTH2",
  "oauth2Config": {
    "clientId": "registration-portal",
    "clientSecret": "${OAUTH2_CLIENT_SECRET}",
    "issuerUri": "https://keycloak.acme.com/auth/realms/acme",
    "scopes": ["openid", "profile", "email"],
    "userNameAttribute": "sub"
  }
}

Error Scenarios

Error Cause Resolution

OrgUser Not Found

No OrgUser with matching externalUserId for the RegistrationSystem

Create OrgUser in Admin Service with IdP sub value in externalUserId field

Invalid IdP JWT

IdP JWT signature validation fails

Check IdP configuration, ensure correct issuer URI and JWKS endpoint

Expired IdP JWT

IdP access token expired

Implement token refresh flow using refresh token

Multiple IdP Issuers

Admin Service cannot determine which IdP issued the JWT

Use JwtIssuerAuthenticationManagerResolver to support multiple issuers

RegistrationSystem Not Found

Invalid registrationSystemId parameter

Verify tenant configuration in Registration Portal

Security Considerations

Critical Security Requirements:

  1. IdP JWT Validation: Always validate IdP JWT signature before token exchange

  2. Session Security: Use secure, HTTP-only session cookies with CSRF protection

  3. JWT Storage: Never expose internal JWT to Angular frontend

  4. Issuer Whitelist: Only accept JWTs from explicitly configured IdP issuers

  5. Audience Validation: Validate JWT aud claim matches expected client ID

  6. HTTPS Only: All OAuth2 flows must use HTTPS in production

Token Security Boundaries

security-boundaries

Integration with Multi-Dimensional Security

After successful OAuth2 authentication and token exchange, the internal JWT contains all claims necessary for multi-dimensional authorization:

  • Organizational Access: Determined by OrgUser.orgId

  • Personal Access: Determined by OrgUser.personId (if linked)

  • Permissions: Resolved through OrganisationUser and PersonLink relationships

See JWT Authentication for details on how the internal JWT is used for authorization.

Future Enhancements

Planned Features:

  • Token Refresh: Implement refresh token flow for long-lived sessions

  • Multi-IdP Support: Support multiple IdPs per organization

  • JIT Provisioning: Auto-create OrgUser on first login

  • Attribute Mapping: Map additional IdP claims to OrgUser fields

  • Logout Propagation: Single logout (SLO) to external IdP


OAuth2/OIDC authentication is under active development. This documentation will be updated as implementation progresses.