Security Implementation

1. Overview

This document describes the implementation details for security in the Registration Portal. The gateway supports two authentication methods—hash-based and OIDC—both resulting in a backend-issued JWT stored server-side in HTTP sessions.

For security requirements, see Security Requirements. For security architecture, see Security Architecture.

2. Key Components

The security implementation consists of several key components:

Component Purpose Package

SessionConstants

Shared session attribute names

security

AuthenticationResource

Hash-based authentication endpoint

web.rest

BackendTokenExchangeSuccessHandler

OIDC success handler with token exchange

security.oauth2

BackendTokenExchangeClient

Calls backend token exchange endpoint

service

AdminServiceJwtRelayFilter

Injects JWT into backend requests

web.filter

SecurityConfiguration

Spring Security configuration

config

TenantIdentityProvider

JPA entity for per-tenant IdP configuration

domain

TenantIdentityProviderRepository

Repository for TenantIdentityProvider queries

repository

IdentityProviderType

Enum: ENTRA_ID, GOOGLE, FACEBOOK, APPLE, CUSTOM_OIDC

domain.enumeration

TenantClientRegistrationRepository

Dynamic OAuth2 ClientRegistration loading per tenant

security.oauth2

3. Session Attribute Management

3.1. Shared Constants

All components use shared session attribute names defined in SessionConstants:

public final class SessionConstants {
    public static final String BACKEND_JWT = "BACKEND_JWT";
    public static final String BACKEND_JWT_EXPIRY = "BACKEND_JWT_EXPIRY";
}
  • BACKEND_JWT - The backend-issued JWT token (set by both hash and OIDC flows)

  • BACKEND_JWT_EXPIRY - Token expiry timestamp (OIDC only; null for hash-based)

4. Hash-Based Authentication

4.1. Flow Overview

hash-auth-flow

4.2. AuthenticationResource Implementation

The AuthenticationResource handles hash-based authentication from external systems:

Key Implementation Points:

  • Endpoint: POST /api/auth/external-login

  • Extracts registrationSystemId from TenantContext (populated by TenantResolutionFilter)

  • Calls backend via AdminServiceAuthClient with X-API-KEY authentication

  • Stores JWT in session using SessionConstants.BACKEND_JWT

  • Does NOT set BACKEND_JWT_EXPIRY (hash-based tokens have no client-side expiry tracking)

  • Returns 200 OK with empty body—JWT never sent to frontend

4.3. Token Lifecycle (Hash-Based)

  • Token validity: Determined by backend (typically 24 hours)

  • Expiry handling: Backend returns 401 when token expires

  • No refresh: User must obtain new hash from external system

  • Frontend behaviour: Redirect to auth failure or token expiry screen on 401

4.4. Token Expiry Detection and Error Handling

4.4.1. X-Token-Expired Header

The backend distinguishes between token states by including a custom header in 401 responses:

Token State Response Header

Expired Token

401 Unauthorized

X-Token-Expired: true

Invalid Token

401 Unauthorized

No X-Token-Expired header

Missing Token

401 Unauthorized

No X-Token-Expired header

4.4.2. JWT Session Preservation

Critical: The JWT must be preserved in the HTTP session even when expired.

The AdminServiceJwtRelayFilter must:

  1. NOT null/clear the JWT when it expires

  2. Still attempt to relay the token (backend will reject with 401 + X-Token-Expired)

  3. This allows the frontend to distinguish expiry from invalid/missing tokens

This enables the frontend to show appropriate error screens:

  • Expired token in session: Show "Session Expired" screen with return-to-origin option

  • No token in session: Show "Access Denied" screen (user never authenticated)

4.4.3. Tenant Configuration for Error Screens

Tenant-specific configuration controls error screen behavior:

New Fields in RegistrationSystem:

@Size(max = 500)
@Column(name = "reset_redirect_url", length = 500)
private String resetRedirectUrl;  // e.g., "https://hnr.co.za/membership"

@Size(max = 100)
@Column(name = "reset_redirect_name", length = 100)
private String resetRedirectName; // e.g., "HNR Membership Site"

TenantConfigResource Endpoint:

GET /api/tenant-config returns:

{
  "resetRedirectUrl": "https://hnr.co.za/membership",
  "resetRedirectName": "HNR Membership Site"
}

When both values are configured, error screens show "Return to {resetRedirectName}" linking to the URL.

5. OIDC Authentication

5.1. Flow Overview

oidc-auth-flow

5.2. BackendTokenExchangeSuccessHandler

Extends SavedRequestAwareAuthenticationSuccessHandler to exchange IdP tokens for backend JWT immediately after successful OIDC authentication.

Key Implementation Points:

  • Invoked by Spring Security after successful OAuth2 login

  • Loads OAuth2AuthorizedClient from OAuth2AuthorizedClientService

  • Extracts IdP access token and ID token (if OIDC)

  • Calls BackendTokenExchangeClient to exchange for backend JWT

  • Stores both token and expiry in session

  • Delegates to parent for redirect handling (preserves original request)

5.3. BackendTokenExchangeClient

Service component that calls the backend token exchange endpoint.

Key Implementation Points:

  • Endpoint called: POST /auth/token-exchange

  • Uses RestTemplate with X-API-KEY header for service authentication

  • Request payload: {accessToken, idToken, clientRegistrationId}

  • Response payload: {token, expiresAt}

  • Throws BackendTokenExchangeException on failure

5.4. OAuth2AuthorizedClientManager Configuration

Configure refresh token support in SecurityConfiguration:

@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
    ClientRegistrationRepository clientRegistrationRepository,
    OAuth2AuthorizedClientRepository authorizedClientRepository
) {
    OAuth2AuthorizedClientProvider authorizedClientProvider =
        OAuth2AuthorizedClientProviderBuilder.builder()
            .authorizationCode()
            .refreshToken()  // Enable automatic token refresh
            .build();

    DefaultOAuth2AuthorizedClientManager manager = new DefaultOAuth2AuthorizedClientManager(
        clientRegistrationRepository,
        authorizedClientRepository
    );
    manager.setAuthorizedClientProvider(authorizedClientProvider);
    return manager;
}

The refreshToken() provider enables automatic IdP token refresh when the access token expires.

6. JWT Relay Filter

6.1. AdminServiceJwtRelayFilter

The filter intercepts requests to /services/admin-service/** and injects the backend JWT from session.

Filter Logic:

relay-filter-logic

6.2. Token Expiry Handling

The filter handles token expiry differently based on authentication type:

Scenario Detection Action

Hash-based, no expiry info

BACKEND_JWT_EXPIRY is null

Relay token as-is; backend handles expiry

OIDC, token valid

Expiry > now + 30 seconds

Relay token

OIDC, token expiring soon

Expiry ≤ now + 30 seconds

Refresh via IdP, then relay new token

OIDC, refresh fails

Exception or null result

Redirect to IdP login

No OIDC auth, token expired

Not OAuth2AuthenticationToken

Clear session, pass through without header

6.3. OIDC Token Refresh

When the backend JWT is expired and OIDC authentication is present:

  1. Get OAuth2AuthorizedClient - Use OAuth2AuthorizedClientManager which automatically refreshes if needed

  2. Check IdP token validity - If still expired after manager call, redirect to IdP

  3. Exchange for new backend JWT - Call BackendTokenExchangeClient

  4. Update session - Store new token and expiry

  5. Relay request - Continue with fresh token

7. Security Configuration

7.1. SecurityFilterChain Configuration

Key configuration points in SecurityConfiguration:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http, MvcRequestMatcher.Builder mvc,
        BackendTokenExchangeSuccessHandler successHandler) throws Exception {
    http
        .authorizeHttpRequests(authz -> authz
            // Gateway endpoints that require authentication
            .requestMatchers(mvc.pattern("/api/**")).authenticated()
            // Proxied requests - let backend decide (gateway just relays)
            .requestMatchers(mvc.pattern("/services/**")).permitAll()
            // ...
        )
        .oauth2Login(oauth2 -> oauth2
            .loginPage("/")
            .userInfoEndpoint(userInfo -> userInfo.oidcUserService(this.oidcUserService()))
            .successHandler(successHandler)  // Token exchange handler
        )
        .oauth2Client(withDefaults());
    return http.build();
}

Important: /services/** is permitAll() because:

  • The gateway should not enforce authentication for proxied requests

  • The backend is responsible for all authorization decisions

  • Public endpoints are handled by backend returning appropriate responses

  • Hash-based and OIDC users with valid tokens get JWT relayed

  • Guest/anonymous users get no Authorization header; backend decides response

8.1. Production Configuration

server:
  forward-headers-strategy: native  # Trust X-Forwarded-* headers
  servlet:
    session:
      cookie:
        name: JSESSIONID
        http-only: true      # Prevents JavaScript access
        secure: true         # Cookie only sent over HTTPS
        same-site: strict    # CSRF protection
      timeout: 30m

8.2. Development Configuration

# application-dev.yml
server:
  servlet:
    session:
      cookie:
        secure: false  # Allow over HTTP during development
Never set secure: false in production environments.

8.3. Kubernetes and Reverse Proxy Considerations

In production Kubernetes deployments, TLS is typically terminated at the ingress controller:

kubernetes-ingress-cookie

This works correctly with secure: true cookies because:

  1. NGINX ingress adds X-Forwarded-Proto: https header

  2. With forward-headers-strategy: native, Spring Boot treats request as HTTPS

  3. Spring Security sets secure flag on cookies

  4. Browser receives cookies over HTTPS and saves them correctly

9. Backend Token Exchange Endpoint

The backend implements a claims-based token exchange endpoint for OAuth2 authentication:

Endpoint: POST /api/auth/token-exchange/oauth2

Request (from Gateway, authenticated via API key):

{
  "registrationSystemId": 5,
  "subjectId": "google-oauth2|abc123",
  "email": "[email protected]",
  "displayName": "John Doe",
  "providerType": "GOOGLE"
}

Response:

{
  "token": "eyJhbG...",
  "expiresAt": "2026-01-30T10:30:00Z"
}

Backend Responsibilities:

  1. Resolve RegistrationSystemOrganisation from registrationSystemId

  2. Look up OrgUser by (organisation, externalUserId = subjectId)

  3. If not found: JIT provisioning — create Person + OrgUser from provided claims

  4. Extract personId and linkedOrgClaims from OrgUser

  5. Mint backend JWT with standard claims (orgId, personId, linkedOrgIds)

  6. Return token with expiry timestamp

Key difference from hash-based flow: The Gateway has already validated the IdP token via the standard OAuth2/OIDC flow. The admin-service trusts the Gateway (API key authenticated) and does not re-validate the IdP token. The subjectId value comes from the configurable subjectClaimName (default: sub, Microsoft: oid).

For JIT provisioning details, see OAuth2 User Provisioning.

9.1. Legacy Token Exchange Endpoint

The original endpoint (POST /auth/token-exchange) accepted raw IdP tokens for server-side validation:

{
  "accessToken": "eyJhbG...",
  "idToken": "eyJhbG...",
  "clientRegistrationId": "oidc"
}

This is being replaced by the claims-based endpoint above. The claims-based approach is simpler because:

  • Gateway already validates the IdP token

  • Admin-service doesn’t need IdP JWKS configuration

  • Works consistently across OIDC (JWT) and non-OIDC (Facebook opaque token) providers

10. Why Sessions Instead of Bearer Tokens?

The choice to use session cookies rather than having Angular manage bearer tokens directly:

Aspect Session Cookies Angular-managed Tokens

Storage

Server-side (HttpSession)

Client-side (localStorage/sessionStorage)

XSS Vulnerability

Low - httpOnly prevents JS access

High - Tokens accessible to malicious scripts

CSRF Vulnerability

Mitigated with SameSite + CSRF tokens

N/A (but XSS can steal tokens)

Token Refresh

Handled server-side transparently

Requires client-side refresh logic

Front-end Complexity

Minimal - browser handles cookies

Requires interceptors, storage, refresh logic

11. Cluster Considerations

For clustered deployments:

Option 1: Distributed Sessions

Use Spring Session with Redis:

spring:
  session:
    store-type: redis
  redis:
    host: redis-server
    port: 6379

Option 2: Sticky Sessions

Configure load balancer for session affinity:

  • Kubernetes: Use sessionAffinity on Service

  • NGINX: Use ip_hash or sticky cookie

Backend Remains Stateless:

  • Backend only validates JWT tokens

  • No session state required in backend

  • Horizontal scaling without session concerns

12. Testing Configuration

For integration tests, provide mock implementations:

@TestConfiguration
public class TestSecurityConfiguration {

    @Bean
    @Primary
    OAuth2AuthorizedClientManager authorizedClientManager() {
        return mock(OAuth2AuthorizedClientManager.class);
    }

    @Bean
    @Primary
    BackendTokenExchangeClient backendTokenExchangeClient() {
        BackendTokenExchangeClient mockClient = mock(BackendTokenExchangeClient.class);
        when(mockClient.exchangeForBackendJwt(anyString(), anyString(), anyString()))
            .thenReturn(new BackendTokenResponse("test-jwt", Instant.now().plusSeconds(3600)));
        return mockClient;
    }
}