Security Implementation

1. Overview

This document describes the implementation details for security in the Registration Portal. The gateway supports three authentication methods—hash-based, OIDC, and anonymous registration session—all 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

SessionAuthenticationService

Shared session establishment from backend JWT

security

AuthenticationResource

Hash-based authentication endpoint

web.rest

RegisterSessionResource

Anonymous registration session 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:

  • Endpoints: POST /api/auth/external-login (XHR) and GET /api/auth/external-login (redirect)

  • Extracts registrationSystemId from TenantContext (populated by TenantResolutionFilter)

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

  • Delegates to SessionAuthenticationService.establishSession() to store JWT and set SecurityContext

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

  • POST returns 200 OK with empty body; GET redirects back to returnUrl

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. Anonymous Registration Session

5.1. Flow Overview

Anonymous users accessing event registration pages (e.g., /register?orgId=4&eventId=10) need authenticated sessions to call gateway-protected APIs. The browser’s SessionService UUID is exchanged for a backend JWT via a full-page redirect.

anon-session-flow

5.2. RegisterSessionResource Implementation

The RegisterSessionResource handles anonymous registration session creation:

Key Implementation Points:

  • Endpoints: GET /api/auth/register-session (redirect, primary) and POST /api/auth/register-session (XHR, legacy)

  • Accepts uuid (browser SessionService UUID) and orgId (organisation ID)

  • Calls backend via AdminServiceAuthClient.registerSession() with X-API-KEY authentication

  • Delegates to SessionAuthenticationService.establishSession() to store JWT and set SecurityContext

  • GET endpoint validates returnUrl (must be relative path, no open redirect) then 302 redirects

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

5.3. Frontend: Redirect-Based Session Establishment

The Angular frontend uses a full-page redirect pattern (mirroring JHipster’s OAuth2 login) to ensure the JSESSIONID cookie is reliably set by the browser:

  1. RegistrationComponent.handleNavigation() calls accountService.identity(true) to check authentication

  2. If account is null and orgId is present, RegisterSessionService.redirectToRegisterSession() navigates the browser to the GET endpoint

  3. A sessionStorage timestamp flag prevents redirect loops (auto-expires after 10 seconds)

  4. After the server redirect back, identity() returns the account and the component proceeds

Why full-page redirect instead of XHR?

The webpack dev proxy (http-proxy-middleware) can drop Set-Cookie headers from XHR responses. Full-page redirects (302) reliably set cookies because the browser handles them as navigation responses, not XHR responses.

5.4. SessionAuthenticationService

Shared service used by both AuthenticationResource and RegisterSessionResource to establish Spring Security sessions from backend JWTs:

@Service
public class SessionAuthenticationService {

    public void establishSession(String jwt, HttpServletRequest request,
                                  HttpServletResponse response) {
        HttpSession session = request.getSession(true);
        session.setAttribute(SessionConstants.BACKEND_JWT, jwt);

        Jwt springJwt = parseJwt(jwt);
        JwtAuthenticationToken authentication =
            new JwtAuthenticationToken(springJwt, extractAuthorities(springJwt));

        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(authentication);
        SecurityContextHolder.setContext(context);

        // Write directly to session (see Implementation Note below)
        session.setAttribute(
            HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
            context);
    }
}

5.5. Implementation Note: Direct SecurityContext Session Write

The SecurityContext is written directly to the HTTP session via session.setAttribute(SPRING_SECURITY_CONTEXT_KEY, context) rather than using SecurityContextRepository.saveContext().

Root cause: In Spring Security 6.4.5 (with the default requireExplicitSave=true), SecurityContextRepository.saveContext() silently fails when called from within a controller — it does not persist the SPRING_SECURITY_CONTEXT attribute to the HTTP session. This was verified by:

  1. Calling saveContext() from a controller endpoint

  2. Inspecting session attributes via a debug endpoint: BACKEND_JWT was present, but SPRING_SECURITY_CONTEXT was absent

  3. Switching to direct session.setAttribute(): SPRING_SECURITY_CONTEXT was present and loaded correctly by SecurityContextHolderFilter on subsequent requests

This direct write approach is safe because SecurityContextHolderFilter (the default in Spring Security 6.x) loads the SecurityContext from the session by reading the SPRING_SECURITY_CONTEXT attribute directly, regardless of how it was written.

5.6. Token Lifecycle (Anonymous Registration)

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

  • Expiry handling: Backend returns 401 when token expires

  • No refresh: User’s browser UUID remains stable; a new session can be established by repeating the redirect flow

  • Frontend behaviour: On 401, the redirect-based session establishment retries automatically (timestamp flag auto-expires)

6. OIDC Authentication

6.1. Flow Overview

oidc-auth-flow

6.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)

6.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

6.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.

7. JWT Relay Filter

7.1. AdminServiceJwtRelayFilter

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

Filter Logic:

relay-filter-logic

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

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

8. Security Configuration

8.1. SecurityFilterChain Configuration

Key configuration points in SecurityConfiguration:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http, MvcRequestMatcher.Builder mvc,
        BackendTokenExchangeSuccessHandler successHandler) throws Exception {
    http
        .csrf(csrf -> csrf
            // ...
            .ignoringRequestMatchers(mvc.pattern("/api/auth/external-login"))
            .ignoringRequestMatchers(mvc.pattern("/api/auth/register-session"))
        )
        .authorizeHttpRequests(authz -> authz
            .requestMatchers(mvc.pattern("/api/auth/external-login")).permitAll()
            .requestMatchers(mvc.pattern("/api/auth/register-session")).permitAll()
            .requestMatchers(mvc.pattern("/api/account")).permitAll()
            // ...
            // 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 notes:

  • /services/** is permitAll() because the backend is responsible for all authorization decisions. The gateway just relays the JWT; guests get no Authorization header.

  • /api/auth/external-login and /api/auth/register-session are permitAll() (and CSRF-exempt) because they are entry points for unauthenticated users establishing sessions.

  • /api/account is permitAll() because the Angular frontend calls it to determine authentication state. For unauthenticated users, it returns a minimal response rather than triggering an OAuth2 redirect.

9.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

9.2. Development Configuration

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

9.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

10. 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.

10.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

11. 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

12. 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

13. 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;
    }
}