TQPro Native OIDC Client Migration - Implementation Plan¶
Executive Summary¶
This document details the migration of TQPro from oauth2-proxy-based authentication to native OIDC client authentication with Keycloak. The migration supports a hybrid transition period where both authentication methods work simultaneously.
Key Decisions: - OIDC Provider: Keycloak (optimized for Keycloak-specific features) - Migration Strategy: Hybrid mode (both oauth2-proxy AND native OIDC during transition) - Token Storage: SessionStorage (cleared on browser close for security) - Public Site (tqweb-pub): NO OIDC changes - continues using form-based login and guest access
Important: Dual Authentication Model¶
TQPro serves two distinct user groups with different authentication needs:
1. Admin Site (tqweb-adm) - OIDC Authentication¶
- Users: Agents and administrators
- Current: oauth2-proxy with Keycloak
- Target: Native OIDC with Keycloak
- Changes Required: Yes - full OIDC implementation
2. Public Site (tqweb-pub) - Form-Based Authentication¶
- Users: Anonymous visitors and registered customers
- Current: Form-based login via
/user/authenticateendpoint - Target: NO CHANGES - continues using existing authentication
- Changes Required: None
Why tqweb-pub Doesn't Need OIDC¶
-
Anonymous Browsing: Most public site users browse without logging in. The
api-roles.propertiesalready definesguestaccess for read-only endpoints (flight search, cruise listings, etc.) -
Customer Authentication: Registered customers use the existing form-based login:
- POST
/user/authenticatewith username/password - Returns
userCodestored in sessionStorage asss_user_uid - This token is passed in request body (not as Authorization header)
-
Works independently of OIDC
-
Separation of Concerns: Admin/agent authentication (OIDC) is completely separate from customer authentication (form-based)
Backend Must Support All Three Scenarios¶
┌─────────────────────────────────────────────────────────────────┐
│ API Request Arrives │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 1. Check Authorization: Bearer <token> header │
│ (tqweb-adm with OIDC) │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────┴─────────────────┐
│ │
Token Present Token Missing
│ │
▼ ▼
Validate JWT ┌───────────────────────┐
│ │ 2. Check X-User, │
┌──────┴──────┐ │ X-Roles headers │
│ │ │ (oauth2-proxy) │
Valid Invalid └───────────────────────┘
│ │ │
▼ │ ┌─────────────┴─────────────┐
Use JWT │ │ │
claims │ Headers Present Headers Missing
│ │ │
│ ▼ ▼
│ Use header ┌─────────────┐
│ values │ 3. Default │
│ │ to GUEST │
│ │ (tqweb-pub│
└──────────────────────────────────▶│ anonymous)│
(HYBRID mode falls through) └─────────────┘
│
▼
Check api-roles.properties
for guest access
The session token in request body (session parameter) is handled at the business logic layer (facades), not at the authentication filter level. This remains unchanged.
Table of Contents¶
- Current Architecture
- Target Architecture
- Backend Implementation
- Frontend Implementation (tqweb-adm only)
- Public Site (tqweb-pub) - No Changes Required
- Configuration Changes
- Keycloak Configuration
- Migration Rollout
- Testing Strategy
- Security Considerations
- File Reference
1. Current Architecture¶
1.1 Authentication Flow¶
┌─────────┐ ┌─────────┐ ┌──────────────┐ ┌──────────┐
│ Browser │────▶│ NGINX │────▶│ oauth2-proxy │────▶│ Keycloak │
└─────────┘ └─────────┘ └──────────────┘ └──────────┘
│
│ Sets HTTP Headers:
│ - X-User
│ - X-Roles
│ - X-Email
│ - X-Name
│ - Authorization
▼
┌──────────────┐
│ TQPro API │
│ (reads hdrs) │
└──────────────┘
1.2 Current Implementation Details¶
AuthenticationFilter.java (Lines 45-115)¶
@Provider
@PreMatching
public class AuthenticationFilter implements ContainerRequestFilter {
@Override
public void filter(ContainerRequestContext requestContext) {
// Reads headers set by oauth2-proxy
String userId = requestContext.getHeaderString("X-User");
String roles = requestContext.getHeaderString("X-Roles");
String userEmail = requestContext.getHeaderString("X-Email");
String userName = requestContext.getHeaderString("X-Name");
String authToken = requestContext.getHeaderString("Authorization");
// Falls back to dev-mode or guest if headers missing
if(userId == null || roles == null) {
if(devModeConfig.isEnabled()) {
userId = devModeConfig.getDefaultUserId();
roles = devModeConfig.getDefaultUserRoles();
} else {
userId = "guest";
roles = "guest";
}
}
// Parses roles from "role:admin,role:agent" format
ArrayList<String> roleList = new ArrayList<>();
for(String role : roles.split(",")) {
String[] parts = role.split(":");
roleList.add(parts.length > 1 ? parts[parts.length-1] : parts[0]);
}
// Sets SecurityContext for downstream
requestContext.setSecurityContext(new SecurityContext() {
@Override public Principal getUserPrincipal() { return () -> finalUserId; }
@Override public boolean isUserInRole(String r) { return roleList.contains(r); }
// ...
});
// Checks authorization via ApiRoleManager
if(!apiRoleManager.isUserAuthorized(apiPath, userRoles)) {
// Returns 403 FORBIDDEN
}
}
}
Frontend globals.js - tlinq() function¶
// Current: No Authorization header sent
export function tlinq(apiName, apiData) {
return new Promise(((resolve, reject) => {
const apiUrl = "/tlinq-api/" + apiName;
$.ajax({
type: "POST",
url: apiUrl,
data: JSON.stringify(apiData),
dataType: "json",
contentType: "application/json"
// NOTE: No headers set - relies on oauth2-proxy
});
}));
}
1.3 Current Configuration¶
tlinqapi.properties:
auth-server=http://tqadm-dev.vanevski.net
redir-server=http://tqadm-dev.vanevski.net
dev-mode=true
dev-user-id=dev-admin
dev-user-roles=admin,agent
2. Target Architecture¶
2.1 Native OIDC Flow¶
┌─────────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌─────────────────┐ ┌──────────────────────────────────┐ │
│ │ auth-service.js │ │ Application Pages │ │
│ │ │◀──▶│ │ │
│ │ - login() │ │ globals.js: │ │
│ │ - logout() │ │ tlinq() sends Authorization │ │
│ │ - getToken() │ │ header with Bearer token │ │
│ └────────┬────────┘ └──────────────────────────────────┘ │
│ │ │
└───────────┼──────────────────────────────────────────────────────┘
│
│ OIDC Authorization Code Flow + PKCE
▼
┌──────────────┐
│ Keycloak │
│ │
│ - /auth │
│ - /token │
│ - /logout │
│ - /certs │
└──────────────┘
│
│ JWT Access Token
▼
┌──────────────────────────────────────────────────────────────┐
│ TQPro API │
│ ┌───────────────────┐ ┌────────────────────────────────┐ │
│ │ AuthenticationFilter│ │ JWTValidator │ │
│ │ │──▶│ │ │
│ │ Extracts Bearer │ │ - Validates signature (RS256) │ │
│ │ token from header │ │ - Checks issuer, audience, exp │ │
│ │ │ │ - Extracts roles from claims │ │
│ └───────────────────┘ └────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────────┐ ┌────────────────────────────────┐ │
│ │ JWKSManager │ │ ApiRoleManager │ │
│ │ │ │ │ │
│ │ Fetches & caches │ │ Checks endpoint authorization │ │
│ │ public keys from │ │ via api-roles.properties │ │
│ │ Keycloak │ │ │ │
│ └───────────────────┘ └────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
2.2 Hybrid Mode Support¶
During transition, both authentication methods work:
Request arrives
│
▼
┌─────────────────────────────────┐
│ Check for Authorization header │
│ with Bearer token │
└─────────────────────────────────┘
│
├── Token present ──▶ Validate JWT
│ │
│ ┌────┴────┐
│ │ │
│ Valid Invalid
│ │ │
│ ▼ │
│ Use JWT claims │
│ │
├── Token missing ◀────────────┘
│
▼
┌─────────────────────────────────┐
│ Check for X-User, X-Roles │
│ headers (oauth2-proxy) │
└─────────────────────────────────┘
│
├── Headers present ──▶ Use header values
│
├── Headers missing
│
▼
┌─────────────────────────────────┐
│ Dev mode enabled? │
└─────────────────────────────────┘
│
├── Yes ──▶ Use dev credentials
│
├── No ──▶ Default to guest
│
▼
Continue with authorization check
3. Backend Implementation¶
3.1 Dependencies¶
File: tqapi/build.gradle.kts
Add the following dependencies:
dependencies {
// Existing dependencies...
// JWT validation library
implementation("com.nimbusds:nimbus-jose-jwt:9.37.3")
implementation("com.nimbusds:oauth2-oidc-sdk:11.10.1")
}
3.2 New Classes¶
3.2.1 AuthMode.java¶
File: tqapi/src/main/java/com/perun/tlinq/oidc/AuthMode.java
package com.perun.tlinq.oidc;
/**
* Defines the authentication mode for the API server.
*/
public enum AuthMode {
/**
* Current mode: rely on X-User/X-Roles headers from oauth2-proxy
*/
OAUTH2_PROXY,
/**
* New mode: validate JWT tokens directly
*/
NATIVE_OIDC,
/**
* Transition mode: try JWT first, fall back to headers
*/
HYBRID
}
3.2.2 OIDCConfig.java¶
File: tqapi/src/main/java/com/perun/tlinq/oidc/OIDCConfig.java
package com.perun.tlinq.oidc;
/**
* Configuration holder for OIDC settings loaded from tlinqapi.properties.
*/
public class OIDCConfig {
private final String issuer;
private final String clientId;
private final String jwksUri;
private final String endSessionEndpoint;
private final String postLogoutRedirectUri;
private final String rolesClaim;
private final AuthMode authMode;
private final long jwksCacheLifetimeSeconds;
private final long jwksRefreshTimeSeconds;
private OIDCConfig(Builder builder) {
this.issuer = builder.issuer;
this.clientId = builder.clientId;
this.jwksUri = builder.jwksUri != null ? builder.jwksUri
: builder.issuer + "/protocol/openid-connect/certs";
this.endSessionEndpoint = builder.endSessionEndpoint != null ? builder.endSessionEndpoint
: builder.issuer + "/protocol/openid-connect/logout";
this.postLogoutRedirectUri = builder.postLogoutRedirectUri;
this.rolesClaim = builder.rolesClaim != null ? builder.rolesClaim : "realm_access.roles";
this.authMode = builder.authMode;
this.jwksCacheLifetimeSeconds = builder.jwksCacheLifetimeSeconds > 0 ? builder.jwksCacheLifetimeSeconds : 300;
this.jwksRefreshTimeSeconds = builder.jwksRefreshTimeSeconds > 0 ? builder.jwksRefreshTimeSeconds : 30;
}
// Getters for all fields
public String getIssuer() { return issuer; }
public String getClientId() { return clientId; }
public String getJwksUri() { return jwksUri; }
public String getEndSessionEndpoint() { return endSessionEndpoint; }
public String getPostLogoutRedirectUri() { return postLogoutRedirectUri; }
public String getRolesClaim() { return rolesClaim; }
public AuthMode getAuthMode() { return authMode; }
public long getJwksCacheLifetimeSeconds() { return jwksCacheLifetimeSeconds; }
public long getJwksRefreshTimeSeconds() { return jwksRefreshTimeSeconds; }
public boolean isOidcEnabled() {
return authMode == AuthMode.NATIVE_OIDC || authMode == AuthMode.HYBRID;
}
public static class Builder {
private String issuer;
private String clientId;
private String jwksUri;
private String endSessionEndpoint;
private String postLogoutRedirectUri;
private String rolesClaim;
private AuthMode authMode = AuthMode.OAUTH2_PROXY;
private long jwksCacheLifetimeSeconds = 300;
private long jwksRefreshTimeSeconds = 30;
public Builder issuer(String issuer) { this.issuer = issuer; return this; }
public Builder clientId(String clientId) { this.clientId = clientId; return this; }
public Builder jwksUri(String jwksUri) { this.jwksUri = jwksUri; return this; }
public Builder endSessionEndpoint(String ep) { this.endSessionEndpoint = ep; return this; }
public Builder postLogoutRedirectUri(String uri) { this.postLogoutRedirectUri = uri; return this; }
public Builder rolesClaim(String claim) { this.rolesClaim = claim; return this; }
public Builder authMode(AuthMode mode) { this.authMode = mode; return this; }
public Builder jwksCacheLifetimeSeconds(long secs) { this.jwksCacheLifetimeSeconds = secs; return this; }
public Builder jwksRefreshTimeSeconds(long secs) { this.jwksRefreshTimeSeconds = secs; return this; }
public OIDCConfig build() {
if (issuer == null || issuer.isBlank()) {
throw new IllegalStateException("OIDC issuer is required");
}
if (clientId == null || clientId.isBlank()) {
throw new IllegalStateException("OIDC clientId is required");
}
return new OIDCConfig(this);
}
}
}
3.2.3 ValidatedToken.java¶
File: tqapi/src/main/java/com/perun/tlinq/oidc/ValidatedToken.java
package com.perun.tlinq.oidc;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
/**
* Represents a validated JWT token with extracted claims.
*/
public class ValidatedToken {
private final String userId;
private final String email;
private final String name;
private final List<String> roles;
private final Instant expiresAt;
private final String rawToken;
public ValidatedToken(String userId, String email, String name,
List<String> roles, Instant expiresAt, String rawToken) {
this.userId = userId;
this.email = email;
this.name = name;
this.roles = roles != null ? List.copyOf(roles) : Collections.emptyList();
this.expiresAt = expiresAt;
this.rawToken = rawToken;
}
public String getUserId() { return userId; }
public String getEmail() { return email; }
public String getName() { return name; }
public List<String> getRoles() { return roles; }
public Instant getExpiresAt() { return expiresAt; }
public String getRawToken() { return rawToken; }
public boolean isExpired() {
return expiresAt != null && Instant.now().isAfter(expiresAt);
}
public String getRolesAsString() {
return String.join(",", roles);
}
}
3.2.4 TokenValidationException.java¶
File: tqapi/src/main/java/com/perun/tlinq/oidc/TokenValidationException.java
package com.perun.tlinq.oidc;
/**
* Exception thrown when JWT token validation fails.
*/
public class TokenValidationException extends Exception {
public enum Reason {
INVALID_FORMAT,
INVALID_SIGNATURE,
EXPIRED,
INVALID_ISSUER,
INVALID_AUDIENCE,
MISSING_CLAIMS,
JWKS_FETCH_ERROR,
UNKNOWN
}
private final Reason reason;
public TokenValidationException(String message, Reason reason) {
super(message);
this.reason = reason;
}
public TokenValidationException(String message, Reason reason, Throwable cause) {
super(message, cause);
this.reason = reason;
}
public Reason getReason() {
return reason;
}
}
3.2.5 JWKSManager.java¶
File: tqapi/src/main/java/com/perun/tlinq/oidc/JWKSManager.java
package com.perun.tlinq.oidc;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSKeySelector;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import java.net.URL;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Manages fetching and caching of JSON Web Key Sets (JWKS) from Keycloak.
* Uses Nimbus RemoteJWKSet with automatic caching and refresh.
*/
public class JWKSManager {
private static final Logger logger = Logger.getLogger(JWKSManager.class.getName());
private final OIDCConfig config;
private volatile JWKSource<SecurityContext> jwkSource;
private final Object lock = new Object();
public JWKSManager(OIDCConfig config) {
this.config = config;
}
/**
* Gets the JWK source, initializing lazily if needed.
*/
public JWKSource<SecurityContext> getJWKSource() throws TokenValidationException {
if (jwkSource == null) {
synchronized (lock) {
if (jwkSource == null) {
try {
URL jwksUrl = new URL(config.getJwksUri());
logger.info("Initializing JWKS source from: " + jwksUrl);
jwkSource = JWKSourceBuilder.create(jwksUrl)
.cache(config.getJwksCacheLifetimeSeconds(), TimeUnit.SECONDS)
.refreshAheadCache(config.getJwksRefreshTimeSeconds(), TimeUnit.SECONDS)
.build();
} catch (Exception e) {
logger.log(Level.SEVERE, "Failed to initialize JWKS source", e);
throw new TokenValidationException(
"Failed to fetch JWKS from " + config.getJwksUri(),
TokenValidationException.Reason.JWKS_FETCH_ERROR,
e
);
}
}
}
}
return jwkSource;
}
/**
* Creates a key selector for RS256 algorithm.
*/
public JWSKeySelector<SecurityContext> createKeySelector() throws TokenValidationException {
return new JWSVerificationKeySelector<>(
JWSAlgorithm.RS256,
getJWKSource()
);
}
/**
* Forces a refresh of the JWKS cache.
* Useful when key rotation is detected.
*/
public void forceRefresh() {
synchronized (lock) {
jwkSource = null;
}
logger.info("JWKS cache cleared, will refresh on next use");
}
}
3.2.6 JWTValidator.java¶
File: tqapi/src/main/java/com/perun/tlinq/oidc/JWTValidator.java
package com.perun.tlinq.oidc;
import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import java.text.ParseException;
import java.time.Instant;
import java.util.*;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Validates JWT access tokens from Keycloak.
* Performs signature verification, issuer/audience validation, and claims extraction.
*/
public class JWTValidator {
private static final Logger logger = Logger.getLogger(JWTValidator.class.getName());
private final OIDCConfig config;
private final JWKSManager jwksManager;
private final ConfigurableJWTProcessor<SecurityContext> jwtProcessor;
public JWTValidator(OIDCConfig config, JWKSManager jwksManager) {
this.config = config;
this.jwksManager = jwksManager;
this.jwtProcessor = createProcessor();
}
private ConfigurableJWTProcessor<SecurityContext> createProcessor() {
ConfigurableJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
// Skip type verification (Keycloak may not include "typ" header)
processor.setJWSTypeVerifier(null);
try {
processor.setJWSKeySelector(jwksManager.createKeySelector());
} catch (TokenValidationException e) {
logger.log(Level.SEVERE, "Failed to create key selector", e);
throw new RuntimeException("Failed to initialize JWT validator", e);
}
// Configure claims verification
processor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
new JWTClaimsSet.Builder()
.issuer(config.getIssuer())
.build(),
new HashSet<>(Arrays.asList("sub", "iat", "exp"))
));
return processor;
}
/**
* Validates a JWT token and extracts claims.
*
* @param token The raw JWT token string (without "Bearer " prefix)
* @return ValidatedToken with extracted claims
* @throws TokenValidationException if validation fails
*/
public ValidatedToken validateToken(String token) throws TokenValidationException {
if (token == null || token.isBlank()) {
throw new TokenValidationException("Token is empty",
TokenValidationException.Reason.INVALID_FORMAT);
}
try {
// Parse token to check basic structure
SignedJWT signedJWT = SignedJWT.parse(token);
// Process and validate
JWTClaimsSet claims = jwtProcessor.process(signedJWT, null);
// Validate audience (client_id)
List<String> audience = claims.getAudience();
if (audience == null || !audience.contains(config.getClientId())) {
// Keycloak may include client_id in "azp" claim instead
String azp = claims.getStringClaim("azp");
if (!config.getClientId().equals(azp)) {
// WARNING: generic message (no expected/actual values to avoid leaking client ID)
logger.warning("Token audience mismatch for user: " + claims.getSubject());
logger.fine("Audience mismatch detail — expected: " + config.getClientId()
+ ", got aud: " + audience + ", azp: " + azp);
throw new TokenValidationException("Invalid audience",
TokenValidationException.Reason.INVALID_AUDIENCE);
}
}
// Extract standard claims
String userId = claims.getSubject();
String email = claims.getStringClaim("email");
String name = claims.getStringClaim("name");
if (name == null) {
name = claims.getStringClaim("preferred_username");
}
// Extract roles from Keycloak-specific claim path
List<String> roles = extractRoles(claims);
// Get expiration
Date expDate = claims.getExpirationTime();
Instant expiresAt = expDate != null ? expDate.toInstant() : null;
logger.fine("Token validated for user: " + userId + ", roles: " + roles);
return new ValidatedToken(userId, email, name, roles, expiresAt, token);
} catch (ParseException e) {
throw new TokenValidationException("Invalid token format: " + e.getMessage(),
TokenValidationException.Reason.INVALID_FORMAT, e);
} catch (com.nimbusds.jose.proc.BadJOSEException e) {
if (e.getMessage().contains("Expired")) {
throw new TokenValidationException("Token expired",
TokenValidationException.Reason.EXPIRED, e);
}
throw new TokenValidationException("Token validation failed: " + e.getMessage(),
TokenValidationException.Reason.INVALID_SIGNATURE, e);
} catch (com.nimbusds.jose.JOSEException e) {
throw new TokenValidationException("Token processing failed: " + e.getMessage(),
TokenValidationException.Reason.UNKNOWN, e);
}
}
/**
* Extracts roles from the JWT claims using Keycloak's realm_access.roles path.
*/
@SuppressWarnings("unchecked")
private List<String> extractRoles(JWTClaimsSet claims) {
try {
// Keycloak stores realm roles in realm_access.roles
Map<String, Object> realmAccess = claims.getJSONObjectClaim("realm_access");
if (realmAccess != null) {
Object rolesObj = realmAccess.get("roles");
if (rolesObj instanceof List) {
return new ArrayList<>((List<String>) rolesObj);
}
}
// Fallback: check resource_access.{client-id}.roles
Map<String, Object> resourceAccess = claims.getJSONObjectClaim("resource_access");
if (resourceAccess != null) {
Map<String, Object> clientAccess = (Map<String, Object>) resourceAccess.get(config.getClientId());
if (clientAccess != null) {
Object rolesObj = clientAccess.get("roles");
if (rolesObj instanceof List) {
return new ArrayList<>((List<String>) rolesObj);
}
}
}
// Fallback: check "roles" claim directly
List<String> directRoles = claims.getStringListClaim("roles");
if (directRoles != null) {
return directRoles;
}
logger.warning("No roles found in token for user: " + claims.getSubject());
return Collections.emptyList();
} catch (Exception e) {
logger.log(Level.WARNING, "Error extracting roles from token", e);
return Collections.emptyList();
}
}
}
3.3 Modified Classes¶
3.3.1 AuthenticationFilter.java (Modified)¶
File: tqapi/src/main/java/com/perun/tlinq/AuthenticationFilter.java
Updated in TQ-53: Correlation IDs added for request tracing, JWT validation log messages sanitized (generic at WARNING, details at FINE), guest fallback now logged for auditing,
abortWithUnauthorized()uses generic client messages to avoid leaking internal details.
package com.perun.tlinq;
import com.perun.tlinq.oidc.*;
import com.perun.tlinq.util.RequestContext;
// ... other imports
@Provider
@PreMatching
public class AuthenticationFilter implements ContainerRequestFilter {
@Override
public void filter(ContainerRequestContext requestContext) {
// Generate or propagate correlation ID for request tracing
String correlationId = requestContext.getHeaderString("X-Correlation-ID");
if (correlationId == null || correlationId.isBlank()) {
correlationId = UUID.randomUUID().toString().substring(0, 8);
}
requestContext.setProperty("correlationId", correlationId);
// ... (userId, roleList, etc. initialization)
// ========== Tier 1: JWT validation (NATIVE_OIDC or HYBRID mode) ==========
if (authMode != AuthMode.OAUTH2_PROXY && jwtValidator != null) {
String authHeader = requestContext.getHeaderString("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
ValidatedToken validated = jwtValidator.validateToken(token);
// ... extract userId, email, roles from validated token
} catch (TokenValidationException e) {
// WARNING: generic message only (no token content or internal details)
logger.warning("[" + correlationId + "] JWT validation failed for request to "
+ requestContext.getUriInfo().getPath());
// FINE: full details for debugging
logger.fine("[" + correlationId + "] JWT validation detail: " + e.getMessage());
if (authMode == AuthMode.NATIVE_OIDC) {
abortWithUnauthorized(requestContext, e.getReason());
return;
}
}
}
}
// ========== Tier 2: Header-based auth (OAUTH2_PROXY or HYBRID) ==========
// ... (same as before)
// ========== Tier 3a: Dev mode fallback ==========
// ... (same as before)
// ========== Tier 3b: Default to guest ==========
if (userId == null) {
userId = "guest";
roleList.add("guest");
logger.info("[" + correlationId + "] No authentication provided, defaulting to guest for "
+ requestContext.getUriInfo().getPath());
}
// Populate thread-local RequestContext for safe logging in facades/services
RequestContext.set(new RequestContext(userId, userName, userEmail, correlationId));
logger.info("[" + correlationId + "] API call: " + apiPath + " from user " + userName
+ "/" + userEmail + " [" + finalUserId + "]");
// ... (logout handling, SecurityContext, authorization check)
}
private void abortWithUnauthorized(ContainerRequestContext requestContext,
TokenValidationException.Reason reason) {
// Generic messages to avoid leaking internal details
String clientMessage;
switch (reason) {
case EXPIRED: clientMessage = "Token expired"; break;
case INVALID_AUDIENCE: clientMessage = "Invalid token audience"; break;
default: clientMessage = "Invalid or missing authentication token"; break;
}
// ... abort with 401
}
}
3.3.2 TQProApiServer.java (Modified)¶
File: tqapi/src/main/java/com/perun/tlinq/TQProApiServer.java
Add the following to the loadProperties() method:
// Add imports at top of file
import com.perun.tlinq.oidc.AuthMode;
import com.perun.tlinq.oidc.OIDCConfig;
// Add instance variable
private OIDCConfig oidcConfig;
// In loadProperties() method, after loading existing properties:
// ========== OIDC Configuration ==========
String authModeStr = props.getProperty("auth-mode", "oauth2-proxy");
AuthMode authMode;
try {
authMode = AuthMode.valueOf(authModeStr.toUpperCase().replace("-", "_"));
} catch (IllegalArgumentException e) {
logger.warning("Invalid auth-mode: " + authModeStr + ", defaulting to OAUTH2_PROXY");
authMode = AuthMode.OAUTH2_PROXY;
}
if (authMode == AuthMode.NATIVE_OIDC || authMode == AuthMode.HYBRID) {
String issuer = props.getProperty("oidc-issuer");
String clientId = props.getProperty("oidc-client-id");
if (issuer == null || clientId == null) {
throw new RuntimeException("OIDC is enabled but oidc-issuer or oidc-client-id is not configured");
}
oidcConfig = new OIDCConfig.Builder()
.issuer(issuer)
.clientId(clientId)
.jwksUri(props.getProperty("oidc-jwks-uri"))
.endSessionEndpoint(props.getProperty("oidc-end-session-endpoint"))
.postLogoutRedirectUri(props.getProperty("oidc-post-logout-redirect-uri",
redirServer + "/loggedout.html"))
.rolesClaim(props.getProperty("oidc-roles-claim", "realm_access.roles"))
.authMode(authMode)
.jwksCacheLifetimeSeconds(Long.parseLong(
props.getProperty("oidc-jwks-cache-lifetime", "300")))
.jwksRefreshTimeSeconds(Long.parseLong(
props.getProperty("oidc-jwks-refresh-time", "30")))
.build();
logger.info("OIDC configuration loaded. Issuer: " + issuer + ", Mode: " + authMode);
}
Modify the filter registration in initServer():
// Create and configure authentication filter
AuthenticationFilter authFilter = new AuthenticationFilter();
authFilter.setDevModeConfig(devModeConfig);
if (oidcConfig != null) {
authFilter.setOidcConfig(oidcConfig);
}
ResourceConfig rc = new ResourceConfig()
.packages("com.perun.tlinq.api")
.register(MultiPartFeature.class)
.register(authFilter)
.register(CORSResponseFilter.class);
4. Frontend Implementation¶
4.1 OIDC Client Library¶
Add oidc-client-ts to HTML pages. Either via CDN:
<script src="https://cdn.jsdelivr.net/npm/oidc-client-ts@3.0.1/dist/browser/oidc-client-ts.min.js"></script>
Or download and serve locally from /js/vendor/oidc-client-ts.min.js.
4.2 New Files¶
4.2.1 auth-service.js¶
File: tqweb-adm/js/modules/auth-service.js
Updated in TQ-53: The implementation below reflects the current code after TQ-53 quality fixes. Key changes from the original TQ-51 plan: OIDC configuration moved to separate
oidc-config.jsmodule, lazy async initialization (UserManager created on first use),sanitizeReturnUrl()to prevent open redirects, silent renewal error handling with auto-redirect after 2 consecutive failures,ss_tokenrenamed toss_user_uidwith separatess_access_tokenfor JWT caching.
/**
* OIDC Authentication Service for TQPro Admin
* Wraps oidc-client-ts UserManager for Keycloak integration.
*
* Uses lazy async initialization: the UserManager is created on first use
* after fetching OIDC config from the backend auth/config endpoint.
*/
import { loadOidcConfig, buildOidcSettings } from './oidc-config.js';
class AuthService {
/**
* Sanitizes a return URL to prevent open redirects and redirect loops.
* Rejects external URLs (different origin) and auth-flow pages.
*/
static sanitizeReturnUrl(url) {
const blocked = ['/login.html', '/adm/login.html', '/logout.html',
'/adm/logout.html', '/callback.html', '/loggedout.html'];
try {
const parsed = new URL(url, window.location.origin);
if (parsed.origin !== window.location.origin) return '/index.html';
if (blocked.includes(parsed.pathname)) return '/index.html';
return parsed.pathname + parsed.search + parsed.hash;
} catch {
return '/index.html';
}
}
constructor() {
this.enabled = false;
this.userManager = null;
this._initPromise = null;
this._clientId = null;
this._silentRenewFailCount = 0;
}
/**
* Initializes the OIDC UserManager by fetching config from the backend.
* Safe to call multiple times -- subsequent calls return the same promise.
*/
async init() {
if (this._initPromise) return this._initPromise;
this._initPromise = this._doInit();
return this._initPromise;
}
async _doInit() {
if (typeof oidc === 'undefined') {
this.enabled = false;
return;
}
const config = await loadOidcConfig();
if (!config.enabled) {
console.info('OIDC disabled by backend configuration');
this.enabled = false;
return;
}
this._clientId = config.client_id;
const settings = buildOidcSettings(config.authority, config.client_id);
this.enabled = true;
this.userManager = new oidc.UserManager({
...settings,
userStore: new oidc.WebStorageStateStore({ store: sessionStorage })
});
// Event handlers
this.userManager.events.addAccessTokenExpiring(() => {
console.log('Token expiring, will auto-refresh...');
});
this.userManager.events.addAccessTokenExpired(() => {
console.warn('Token expired');
this.clearSession();
});
this.userManager.events.addSilentRenewError((error) => {
this._silentRenewFailCount++;
console.warn('Silent renew error (attempt ' + this._silentRenewFailCount + '):', error);
if (this._silentRenewFailCount >= 2) {
console.error('Silent renewal failed consecutively, redirecting to login');
sessionStorage.setItem('ss_prelogin_loc',
AuthService.sanitizeReturnUrl(window.location.href));
this.clearSession();
window.location = '/login.html';
}
});
this.userManager.events.addUserLoaded((user) => {
console.log('User loaded/refreshed');
this._silentRenewFailCount = 0;
this.storeUserSession(user);
});
}
// ... login(), handleCallback(), logout(), getAccessToken(), getUser(), isAuthenticated() ...
// (See full source in tqweb-adm/js/modules/auth-service.js)
/**
* Stores user session data for backward compatibility.
*/
storeUserSession(user) {
if (!user) return;
sessionStorage.setItem('ss_loggedin', 'true');
sessionStorage.setItem('ss_user_uid', user.profile.sub);
sessionStorage.setItem('ss_access_token', user.access_token);
sessionStorage.setItem('ss_id_token', user.id_token);
const profile = user.profile;
sessionStorage.setItem('ss_username', profile.preferred_username || profile.email || '');
sessionStorage.setItem('ss_greeting', `Hello, ${profile.given_name || profile.name || 'User'}`);
// Build reguser object for compatibility
const reguser = {
userCode: profile.sub,
userLogin: profile.preferred_username || profile.email,
fullName: profile.name,
employee: this.hasRole(user, 'agent') || this.hasRole(user, 'admin'),
contact: { /* ... */ },
auth: 'YES'
};
sessionStorage.setItem('reguser', JSON.stringify(reguser));
sessionStorage.setItem('usercontacts', JSON.stringify(reguser.contact));
}
/**
* Clears all session data.
*/
clearSession() {
sessionStorage.removeItem('ss_loggedin');
sessionStorage.removeItem('ss_user_uid');
sessionStorage.removeItem('ss_access_token');
sessionStorage.removeItem('ss_id_token');
sessionStorage.removeItem('ss_username');
sessionStorage.removeItem('ss_greeting');
sessionStorage.removeItem('ss_cart');
sessionStorage.removeItem('ss_oldtoken');
sessionStorage.removeItem('ss_product');
sessionStorage.removeItem('reguser');
sessionStorage.removeItem('usercontacts');
}
// ...
}
// Export singleton instance and class (class needed for sanitizeReturnUrl static method)
export const authService = new AuthService();
export { AuthService };
4.2.1b oidc-config.js (Added in TQ-53)¶
File: tqweb-adm/js/modules/oidc-config.js
OIDC configuration was extracted from auth-service.js into a dedicated module. Configuration
is loaded from the backend auth/config endpoint at runtime — there are no hardcoded Keycloak
URLs. If the backend is unreachable, OIDC is disabled (safe fallback).
/**
* Loads OIDC configuration from the backend API.
* @returns {Promise<{authority: string, client_id: string, enabled: boolean}>}
*/
export async function loadOidcConfig() {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch('/tlinq-api/auth/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) throw new Error('HTTP ' + response.status);
const data = await response.json();
if (data.apiStatus && data.apiStatus.errorCode === 'OK' && data.apiData) {
const cfg = data.apiData;
if (cfg.enabled) {
return { enabled: true, authority: cfg.authority, client_id: cfg.clientId };
}
return { enabled: false, authority: null, client_id: null };
}
throw new Error('Invalid response format');
} catch (e) {
console.warn('Failed to load OIDC config from backend, OIDC disabled:', e);
return { enabled: false, authority: null, client_id: null };
}
}
/**
* Builds the full OIDC settings for oidc-client-ts UserManager.
*/
export function buildOidcSettings(authority, clientId) {
return {
authority, client_id: clientId,
redirect_uri: window.location.origin + '/callback.html',
post_logout_redirect_uri: window.location.origin + '/loggedout.html',
response_type: 'code',
scope: 'openid profile email',
code_challenge_method: 'S256', // Explicit PKCE
automaticSilentRenew: true,
silentRequestTimeoutInSeconds: 30,
silent_redirect_uri: window.location.origin + '/silent-renew.html',
filterProtocolClaims: true,
loadUserInfo: true
};
}
4.2.2 callback.html¶
File: tqweb-adm/callback.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authenticating...</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.loader {
text-align: center;
}
.spinner {
width: 50px;
height: 50px;
border: 3px solid #e0e0e0;
border-top-color: #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error {
color: #e74c3c;
display: none;
}
</style>
</head>
<body>
<div class="loader">
<div class="spinner"></div>
<p>Processing authentication...</p>
<p class="error" id="error-message"></p>
</div>
<script src="https://cdn.jsdelivr.net/npm/oidc-client-ts@3.0.1/dist/browser/oidc-client-ts.min.js"></script>
<script type="module">
import { authService } from './js/modules/auth-service.js';
(async () => {
try {
const { returnUrl } = await authService.handleCallback();
window.location.href = returnUrl;
} catch (error) {
console.error('Authentication callback failed:', error);
document.getElementById('error-message').textContent =
'Authentication failed. Redirecting to login...';
document.getElementById('error-message').style.display = 'block';
document.querySelector('.spinner').style.display = 'none';
setTimeout(() => {
window.location.href = '/adm/login.html?error=auth_failed';
}, 2000);
}
})();
</script>
</body>
</html>
4.2.3 silent-renew.html¶
File: tqweb-adm/silent-renew.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Silent Renew</title>
</head>
<body>
<script src="https://cdn.jsdelivr.net/npm/oidc-client-ts@3.0.1/dist/browser/oidc-client-ts.min.js"
integrity="sha384-..." crossorigin="anonymous"></script>
<script>
// Handle silent token refresh callback.
// Empty config — signinSilentCallback() only parses the URL response
// and tries all parsing strategies automatically.
new oidc.UserManager({})
.signinSilentCallback()
.then(() => {
console.log('Silent renew successful');
})
.catch(err => {
console.error('Silent renew callback error:', err);
sessionStorage.removeItem('ss_loggedin');
});
</script>
</body>
</html>
4.3 Modified Files¶
4.3.1 globals.js (Modified tlinq and setGlobalHandlers functions)¶
File: tqweb-adm/js/modules/globals.js
Updated in TQ-53: The implementation below reflects the current code. Key changes:
tlinq()usesss_access_tokenfallback for app pages where oidc-client-ts is not loaded,setGlobalHandlers()accepts{ requireAuth: true }option, handles lazy OIDC init, redirects unauthenticated users, and reveals page body after auth passes.
import { authService, AuthService } from './auth-service.js';
export async function tlinq(apiName, apiData) {
// Get access token: try OIDC UserManager first, fall back to sessionStorage
const headers = { 'Content-Type': 'application/json' };
try {
const accessToken = await authService.getAccessToken();
if (accessToken) {
headers['Authorization'] = 'Bearer ' + accessToken;
} else {
// oidc-client-ts is only on auth pages; on app pages fall back to the
// access token cached in sessionStorage during the OIDC callback
const storedJwt = sessionStorage.getItem('ss_access_token');
if (storedJwt) {
headers['Authorization'] = 'Bearer ' + storedJwt;
}
}
} catch (e) {
console.warn('Could not get access token:', e);
const storedJwt = sessionStorage.getItem('ss_access_token');
if (storedJwt) {
headers['Authorization'] = 'Bearer ' + storedJwt;
}
}
return new Promise((resolve, reject) => {
const apiUrl = "/tlinq-api/" + apiName;
let res = $.ajax({
type: "POST", url: apiUrl,
data: JSON.stringify(apiData),
dataType: "json", headers: headers,
// ...
});
res.done((data, status) => {
const apiStatus = data.apiStatus;
if (apiStatus.errorCode === "OK") resolve(data.apiData);
else reject(apiStatus);
});
res.fail((jqXHR, status, err) => {
// Check for auth errors — distinguish API permission errors (ERR0008)
// from session expiry (generic 401/403)
if (jqXHR.status === 403 || jqXHR.status === 401) {
// ... handles auth errors and session expiry redirect
}
// ...
});
});
}
export async function setGlobalHandlers(options = {}) {
const requireAuth = options.requireAuth || false;
// Ensure OIDC is initialized (fetches config from backend)
try {
await Promise.race([
authService.init(),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))
]);
} catch (e) {
console.error('Auth initialization failed or timed out:', e);
}
// Check if authenticated via OIDC and populate session if so
if (authService.enabled) {
const isAuthenticated = await authService.isAuthenticated();
if (isAuthenticated) {
const user = await authService.getUser();
if (user) authService.storeUserSession(user);
} else if (requireAuth) {
// Not authenticated — redirect to Keycloak
sessionStorage.setItem("ss_prelogin_loc",
AuthService.sanitizeReturnUrl(window.location.href));
await authService.login();
return; // browser will navigate to Keycloak
}
}
let token = sessionStorage.getItem("ss_user_uid");
if (!token) {
token = document.cookie.split('; ').find(c => c.startsWith('_oauth2_proxy='));
const accessToken = token ? token.split('=')[1] : "";
sessionStorage.setItem("ss_user_uid", accessToken);
}
// Non-OIDC auth check
if (!authService.enabled && requireAuth && !sessionStorage.getItem("ss_loggedin")) {
sessionStorage.setItem("ss_prelogin_loc",
AuthService.sanitizeReturnUrl(window.location.href));
window.location = '/login.html';
return;
}
// Reveal body content now that auth has passed (pages hide body with
// visibility:hidden to prevent content flash before authentication)
if (requireAuth) {
document.body.style.visibility = 'visible';
}
loadShoppingCart(token);
// ... rest of initialization
}
5. Public Site (tqweb-pub) - No Changes Required¶
IMPORTANT: The public site (tqweb-pub) requires NO changes for OIDC migration.
5.1 Why No Changes?¶
-
Guest Access Already Works: The
api-roles.propertiesfile already definesguestaccess for all read-only endpoints. When no authentication is provided, the backend defaults toguestrole. -
Form-Based Login Continues: Customer authentication uses
/user/authenticate: -
Session Token Handling: The
sessionparameter in API requests is handled by the business logic layer (facades), not by the AuthenticationFilter. This is completely independent of OIDC.
5.2 API Endpoints Supporting Guest Access¶
From api-roles.properties, these endpoints (and many more) allow guest access:
# Flight search - public
flight/search=guest,agent,admin
flight/fetchResults=guest,agent,admin
# Cruise browsing - public
cruise/searchCruises=guest,agent,admin
cruise/company/list=guest,agent,admin
cruise/itinerary/list=guest,agent,admin
# User registration - public
user/register=guest,agent,admin
user/authenticate=guest,agent,admin
5.3 How tqweb-pub Authentication Works (Unchanged)¶
┌─────────────────────────────────────────────────────────────────┐
│ tqweb-pub User Flow │
└─────────────────────────────────────────────────────────────────┘
Anonymous Browsing:
Browser → API (no auth headers) → AuthenticationFilter defaults to "guest"
→ api-roles.properties allows guest access → Data returned
Customer Login:
Browser → POST /user/authenticate (username, password in body)
→ UserApi validates credentials against database
→ Returns userCode (session token)
→ Browser stores in sessionStorage
Authenticated API Call:
Browser → POST /api/endpoint { session: "userCode", ...data }
→ AuthenticationFilter sees no Bearer token, defaults to "guest"
→ api-roles.properties allows guest access (most endpoints)
→ Facade layer extracts "session" from request body
→ Facade uses session to identify customer for business logic
Key Point: The customer's userCode session token is NOT an OAuth token. It's a database-generated identifier used by the business logic layer.
6. Configuration Changes¶
6.1 tlinqapi.properties¶
File: config/tlinqapi.properties
Add the following properties:
#####
# Authentication Mode
# Options:
# oauth2-proxy - Current mode: rely on X-User/X-Roles headers from oauth2-proxy
# native-oidc - New mode: validate JWT tokens directly
# hybrid - Transition mode: try JWT first, fall back to headers
# Default: oauth2-proxy
auth-mode=hybrid
#####
# OIDC Configuration (required when auth-mode is native-oidc or hybrid)
# Keycloak realm issuer URL
oidc-issuer=https://dev-auth.vanevski.net/realms/tqpro-adm
# OIDC client ID (must match Keycloak client)
oidc-client-id=tqweb-adm
# JWKS endpoint (optional, defaults to {issuer}/protocol/openid-connect/certs)
# oidc-jwks-uri=https://dev-auth.vanevski.net/realms/tqpro-adm/protocol/openid-connect/certs
# End session endpoint (optional, defaults to {issuer}/protocol/openid-connect/logout)
# oidc-end-session-endpoint=https://dev-auth.vanevski.net/realms/tqpro-adm/protocol/openid-connect/logout
# Post-logout redirect URI
oidc-post-logout-redirect-uri=http://tqadm-dev.vanevski.net/loggedout.html
# Claim path for extracting roles from JWT (Keycloak default)
oidc-roles-claim=realm_access.roles
# JWKS cache settings (seconds)
oidc-jwks-cache-lifetime=300
oidc-jwks-refresh-time=30
7. Keycloak Configuration¶
7.1 Client Configuration¶
Ensure the Keycloak client tqweb-adm is configured correctly:
| Setting | Value |
|---|---|
| Client ID | tqweb-adm |
| Client Protocol | openid-connect |
| Access Type | public (no client secret) |
| Standard Flow Enabled | ON |
| Direct Access Grants Enabled | OFF |
| Implicit Flow Enabled | OFF |
| Service Accounts Enabled | OFF |
| Authorization Enabled | OFF |
7.2 PKCE Configuration¶
Ensure PKCE is required:
| Setting | Value |
|---|---|
| Proof Key for Code Exchange Code Challenge Method | S256 |
7.3 Valid Redirect URIs¶
http://localhost:*/callback.html
http://localhost:*/silent-renew.html
http://tqadm-dev.vanevski.net/callback.html
http://tqadm-dev.vanevski.net/silent-renew.html
https://tqadm.vanevski.net/callback.html
https://tqadm.vanevski.net/silent-renew.html
7.4 Valid Post-Logout Redirect URIs¶
http://localhost:*/loggedout.html
http://localhost:*/index.html
http://tqadm-dev.vanevski.net/loggedout.html
https://tqadm.vanevski.net/loggedout.html
7.5 Web Origins¶
(This allows all valid redirect URI origins)7.6 Role Mapping¶
Ensure users have appropriate realm roles:
- admin - Full administrative access
- agent - Standard agent access
- (No role defaults to guest access)
8. Migration Rollout¶
Step 1: Deploy Backend with Hybrid Mode -- COMPLETE¶
Completed: 2026-02-19 (TQ-51)
- Added Nimbus dependencies to
build.gradle.kts - Added OIDC classes to
tqapi(com.perun.tlinq.oidcpackage with 6 classes) - Modified
AuthenticationFilter(three-tier auth pipeline) andTQProApiServer(OIDC config loading) - Updated
tlinqapi.propertieswithauth-mode=hybrid - Backend deployed
- Validated: Both oauth2-proxy headers AND JWT tokens work
Step 2: Deploy Frontend with OIDC -- COMPLETE¶
Completed: 2026-02-19 (TQ-51)
- Added
oidc-client-tslibrary - Added
auth-service.js(229 lines),oidc-config.js,callback.html,silent-renew.html - Modified
globals.js— asynctlinq()with Authorization header, OIDC login/logout - Updated 24 admin pages with
setGlobalHandlers({ requireAuth: true }) - Updated
header_bootstrap.htmlandlogout.htmlfor OIDC-aware flows - Validated:
- New logins use OIDC flow
- Existing oauth2-proxy sessions continue working
- API calls include Authorization header
Step 3: Monitor and Validate¶
- Monitor server logs for authentication issues
- Check both auth paths are working
- Verify token refresh is working (silent-renew)
- Test logout flow
Step 4: Switch to Native OIDC¶
- Update
tlinqapi.propertieswithauth-mode=native-oidc - Remove oauth2-proxy from architecture
- Update NGINX to proxy directly to TQPro API
- Deploy
- Validation: Only JWT authentication works
Step 5: Post-Implementation Quality Fixes -- COMPLETE¶
Completed: 2026-02-21 (TQ-53)
Security and UX defect fixes applied after the initial TQ-51 implementation:
Backend (tqapi):
1. CORS origin matching — CORSResponseFilter now reads cors-allowed-origins from config, supports wildcard subdomains, echoes specific origin instead of * with credentials, adds Vary: Origin
2. Log sanitization — JWT validation failures log generic message at WARNING, full details at FINE. Audience mismatch no longer leaks expected client ID.
3. Guest fallback logging — Unauthenticated requests defaulting to guest are now logged at INFO with correlation ID for auditing
4. Correlation IDs — All request logs include [correlationId] prefix; propagated in response via X-Correlation-ID header
5. Token revocation documentation — Comment in JWTValidator.java explaining JWT revocation trade-offs (self-contained tokens, short lifetimes as mitigation)
6. Security headers — X-Content-Type-Options, X-Frame-Options, Content-Security-Policy, Referrer-Policy added to all API responses
Frontend (tqweb-adm):
1. OIDC config extraction — Hardcoded Keycloak URLs moved to oidc-config.js which loads config from backend auth/config endpoint at runtime
2. Safe fallback — If backend is unreachable, OIDC returns { enabled: false } (no hardcoded fallback)
3. Explicit PKCE — code_challenge_method: 'S256' added to OIDC settings
4. SSO support — Removed prompt: 'login' to allow Keycloak SSO (users with active sessions log in automatically)
5. Silent renewal error handling — Counter tracks consecutive failures; after 2, clears session and redirects to login
6. Return URL sanitization — AuthService.sanitizeReturnUrl() prevents open redirects and auth-flow redirect loops
7. Content flash prevention — Protected pages use <body style="visibility:hidden">, revealed by setGlobalHandlers() after authentication passes
8. Session key rename — ss_token renamed to ss_user_uid (holds Keycloak UUID, not a JWT); separate ss_access_token added for caching the real OIDC JWT
9. Cookie clearing fix — loggedout.html uses sessionStorage.clear() with comment explaining HttpOnly cookie limitation
10. Silent renew iframe fix — silent-renew.html uses empty config {} instead of hardcoded response_mode
Step 6: Cleanup¶
- Remove legacy header-based auth code paths (optional)
- Remove oauth2-proxy references from config
- Update documentation
9. Testing Strategy¶
9.1 Unit Tests¶
Create tests for:
- JWTValidator with valid/invalid/expired tokens
- Role extraction from different claim paths
- OIDCConfig builder validation
9.2 Integration Tests¶
- Full OIDC flow with Keycloak test realm
- Token refresh scenarios
- Logout with session termination
9.3 E2E Tests¶
- Login Flow:
- Navigate to protected page
- Redirect to Keycloak
- Enter credentials
- Redirect back with token
-
Access protected resource
-
Token Refresh:
- Login
- Wait for token to near expiration
- Verify silent refresh works
-
API calls continue working
-
Logout Flow:
- Login
- Click logout
- Verify redirect to Keycloak logout
- Verify session cleared
- Verify cannot access protected resources
10. Security Considerations¶
10.1 Token Security¶
- RS256 signatures: Asymmetric, public key verification only
- Issuer validation: Tokens must come from configured Keycloak realm
- Audience validation: Tokens must be for this client
- Expiration check: Reject expired tokens immediately
10.2 PKCE¶
- Required: Prevents authorization code interception
- S256 method: Cryptographically secure
10.3 Token Storage¶
- SessionStorage: Cleared on browser close
- No localStorage: Tokens don't persist across sessions
- No cookies: Tokens not vulnerable to CSRF
10.4 Token Lifetime¶
- Short access tokens: Keycloak default 5 minutes
- Silent refresh: Automatic token refresh before expiration
- ID token for logout: Ensures clean session termination
10.5 CORS¶
Updated in TQ-53: CORSResponseFilter.java now implements origin matching:
- Reads allowed origins from cors-allowed-origins property in tlinqapi.properties
- Supports wildcard subdomains (*.example.com), localhost (any port), and exact origins
- Echoes the matched Origin header back (instead of using wildcard * with credentials)
- Only sets Access-Control-Allow-Credentials: true when a specific origin is echoed
- Adds Vary: Origin header so caches don't mix up responses for different origins
- Also adds security headers: X-Content-Type-Options, X-Frame-Options, Content-Security-Policy, Referrer-Policy
- Propagates correlation ID (X-Correlation-ID) to responses for request tracing
11. File Reference¶
11.1 New Files¶
| Path | Purpose |
|---|---|
tqapi/src/main/java/com/perun/tlinq/oidc/AuthMode.java |
Authentication mode enum |
tqapi/src/main/java/com/perun/tlinq/oidc/OIDCConfig.java |
OIDC configuration holder |
tqapi/src/main/java/com/perun/tlinq/oidc/ValidatedToken.java |
Validated token DTO |
tqapi/src/main/java/com/perun/tlinq/oidc/TokenValidationException.java |
Validation exception |
tqapi/src/main/java/com/perun/tlinq/oidc/JWKSManager.java |
JWKS fetching and caching |
tqapi/src/main/java/com/perun/tlinq/oidc/JWTValidator.java |
JWT token validation |
tqweb-adm/js/modules/auth-service.js |
Frontend OIDC service |
tqweb-adm/js/modules/oidc-config.js |
OIDC configuration loader (backend-driven) |
tqweb-adm/callback.html |
OIDC callback handler |
tqweb-adm/silent-renew.html |
Silent token refresh |
tqweb-adm/loggedout.html |
Post-logout page with session cleanup |
11.2 Modified Files¶
| Path | Changes |
|---|---|
tqapi/build.gradle.kts |
Add Nimbus JWT dependencies |
tqapi/src/main/java/com/perun/tlinq/AuthenticationFilter.java |
Add JWT validation, hybrid auth, correlation IDs, sanitized logs |
tqapi/src/main/java/com/perun/tlinq/CORSResponseFilter.java |
Origin matching, security headers, correlation ID propagation |
tqapi/src/main/java/com/perun/tlinq/TQProApiServer.java |
Load OIDC config, init validator, pass CORS config |
config/tlinqapi.properties |
Add OIDC properties, CORS allowed origins |
tqweb-adm/js/modules/globals.js |
Add Authorization header, OIDC login/logout, content flash prevention |
11.3 Unchanged Files (tqweb-pub)¶
The public site (tqweb-pub) requires NO changes. It continues to use:
- Form-based login via /user/authenticate
- Session token passed in request body
- Guest access for anonymous browsing
| Path | Status |
|---|---|
tqweb-pub/js/modules/globals.js |
No changes - continues without Authorization header |
tqweb-pub/loginpage.html |
No changes - continues using form-based login |
tqweb-pub/*.html |
No changes - all pages work with guest/form-based auth |
Appendix A: Troubleshooting¶
A.1 Token Validation Fails¶
Symptoms: 401 Unauthorized responses
Checks:
1. Verify oidc-issuer matches Keycloak realm URL exactly
2. Verify oidc-client-id matches Keycloak client ID
3. Check JWKS endpoint is accessible from API server
4. Verify clock sync between API server and Keycloak
A.2 Roles Not Extracted¶
Symptoms: User gets guest access despite having roles
Checks:
1. Verify roles are assigned in Keycloak (realm roles, not client roles)
2. Check oidc-roles-claim configuration
3. Inspect JWT token payload for realm_access.roles
A.3 Silent Refresh Fails¶
Symptoms: User gets logged out after token expiration
Checks:
1. Verify silent-renew.html is accessible
2. Check browser console for iframe errors
3. Verify Keycloak session timeout settings
4. Check CORS configuration allows iframe
A.4 Logout Doesn't Clear Session¶
Symptoms: User can still access protected resources after logout
Checks:
1. Verify Keycloak end_session_endpoint is correct
2. Check id_token_hint is being sent
3. Verify post_logout_redirect_uri is whitelisted in Keycloak