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

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 home page on 401

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 must implement a token exchange endpoint:

Endpoint: POST /auth/token-exchange

Request:

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

Response:

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

Backend Responsibilities:

  1. Validate IdP token (verify signature, issuer, audience)

  2. Extract user identity from token claims

  3. Look up or create OrgUser based on IdP subject claim and RegistrationSystem

  4. Mint backend JWT with standard claims (orgId, personId, etc.)

  5. Return token with expiry timestamp

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