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 |
|---|---|---|
|
Shared session attribute names |
|
|
Hash-based authentication endpoint |
|
|
OIDC success handler with token exchange |
|
|
Calls backend token exchange endpoint |
|
|
Injects JWT into backend requests |
|
|
Spring Security configuration |
|
|
JPA entity for per-tenant IdP configuration |
|
|
Repository for TenantIdentityProvider queries |
|
|
Enum: ENTRA_ID, GOOGLE, FACEBOOK, APPLE, CUSTOM_OIDC |
|
|
Dynamic OAuth2 ClientRegistration loading per tenant |
|
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.2. AuthenticationResource Implementation
The AuthenticationResource handles hash-based authentication from external systems:
Key Implementation Points:
-
Endpoint:
POST /api/auth/external-login -
Extracts
registrationSystemIdfromTenantContext(populated by TenantResolutionFilter) -
Calls backend via
AdminServiceAuthClientwith 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 OKwith 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 |
|
Invalid Token |
401 Unauthorized |
No |
Missing Token |
401 Unauthorized |
No |
4.4.2. JWT Session Preservation
Critical: The JWT must be preserved in the HTTP session even when expired.
The AdminServiceJwtRelayFilter must:
-
NOT null/clear the JWT when it expires
-
Still attempt to relay the token (backend will reject with 401 + X-Token-Expired)
-
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.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
OAuth2AuthorizedClientfromOAuth2AuthorizedClientService -
Extracts IdP access token and ID token (if OIDC)
-
Calls
BackendTokenExchangeClientto 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
BackendTokenExchangeExceptionon 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:
6.2. Token Expiry Handling
The filter handles token expiry differently based on authentication type:
| Scenario | Detection | Action |
|---|---|---|
Hash-based, no expiry info |
|
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 |
Clear session, pass through without header |
6.3. OIDC Token Refresh
When the backend JWT is expired and OIDC authentication is present:
-
Get OAuth2AuthorizedClient - Use
OAuth2AuthorizedClientManagerwhich automatically refreshes if needed -
Check IdP token validity - If still expired after manager call, redirect to IdP
-
Exchange for new backend JWT - Call
BackendTokenExchangeClient -
Update session - Store new token and expiry
-
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. Session Cookie Configuration
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:
This works correctly with secure: true cookies because:
-
NGINX ingress adds
X-Forwarded-Proto: httpsheader -
With
forward-headers-strategy: native, Spring Boot treats request as HTTPS -
Spring Security sets
secureflag on cookies -
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:
-
Resolve
RegistrationSystem→OrganisationfromregistrationSystemId -
Look up
OrgUserby(organisation, externalUserId = subjectId) -
If not found: JIT provisioning — create Person + OrgUser from provided claims
-
Extract
personIdandlinkedOrgClaimsfrom OrgUser -
Mint backend JWT with standard claims (
orgId,personId,linkedOrgIds) -
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 - |
High - Tokens accessible to malicious scripts |
CSRF Vulnerability |
Mitigated with |
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_hashor 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;
}
}