Skip to content

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/authenticate endpoint
  • Target: NO CHANGES - continues using existing authentication
  • Changes Required: None

Why tqweb-pub Doesn't Need OIDC

  1. Anonymous Browsing: Most public site users browse without logging in. The api-roles.properties already defines guest access for read-only endpoints (flight search, cruise listings, etc.)

  2. Customer Authentication: Registered customers use the existing form-based login:

  3. POST /user/authenticate with username/password
  4. Returns userCode stored in sessionStorage as ss_user_uid
  5. This token is passed in request body (not as Authorization header)
  6. Works independently of OIDC

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

  1. Current Architecture
  2. Target Architecture
  3. Backend Implementation
  4. Frontend Implementation (tqweb-adm only)
  5. Public Site (tqweb-pub) - No Changes Required
  6. Configuration Changes
  7. Keycloak Configuration
  8. Migration Rollout
  9. Testing Strategy
  10. Security Considerations
  11. 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.js module, 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_token renamed to ss_user_uid with separate ss_access_token for 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() uses ss_access_token fallback 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?

  1. Guest Access Already Works: The api-roles.properties file already defines guest access for all read-only endpoints. When no authentication is provided, the backend defaults to guest role.

  2. Form-Based Login Continues: Customer authentication uses /user/authenticate:

    // Existing login flow in tqweb-pub/globals.js - NO CHANGES NEEDED
    tlinq('user/authenticate', {username: usrName, password: usrPwd}).then((authUser) => {
        sessionStorage.setItem("ss_user_uid", authUser.userCode);
        sessionStorage.setItem("ss_loggedin", true);
        // ... existing code
    });
    

  3. Session Token Handling: The session parameter 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)

  1. Added Nimbus dependencies to build.gradle.kts
  2. Added OIDC classes to tqapi (com.perun.tlinq.oidc package with 6 classes)
  3. Modified AuthenticationFilter (three-tier auth pipeline) and TQProApiServer (OIDC config loading)
  4. Updated tlinqapi.properties with auth-mode=hybrid
  5. Backend deployed
  6. Validated: Both oauth2-proxy headers AND JWT tokens work

Step 2: Deploy Frontend with OIDC -- COMPLETE

Completed: 2026-02-19 (TQ-51)

  1. Added oidc-client-ts library
  2. Added auth-service.js (229 lines), oidc-config.js, callback.html, silent-renew.html
  3. Modified globals.js — async tlinq() with Authorization header, OIDC login/logout
  4. Updated 24 admin pages with setGlobalHandlers({ requireAuth: true })
  5. Updated header_bootstrap.html and logout.html for OIDC-aware flows
  6. Validated:
  7. New logins use OIDC flow
  8. Existing oauth2-proxy sessions continue working
  9. API calls include Authorization header

Step 3: Monitor and Validate

  1. Monitor server logs for authentication issues
  2. Check both auth paths are working
  3. Verify token refresh is working (silent-renew)
  4. Test logout flow

Step 4: Switch to Native OIDC

  1. Update tlinqapi.properties with auth-mode=native-oidc
  2. Remove oauth2-proxy from architecture
  3. Update NGINX to proxy directly to TQPro API
  4. Deploy
  5. 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 matchingCORSResponseFilter 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 headersX-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 PKCEcode_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 sanitizationAuthService.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 renamess_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 fixloggedout.html uses sessionStorage.clear() with comment explaining HttpOnly cookie limitation 10. Silent renew iframe fixsilent-renew.html uses empty config {} instead of hardcoded response_mode

Step 6: Cleanup

  1. Remove legacy header-based auth code paths (optional)
  2. Remove oauth2-proxy references from config
  3. 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

  1. Login Flow:
  2. Navigate to protected page
  3. Redirect to Keycloak
  4. Enter credentials
  5. Redirect back with token
  6. Access protected resource

  7. Token Refresh:

  8. Login
  9. Wait for token to near expiration
  10. Verify silent refresh works
  11. API calls continue working

  12. Logout Flow:

  13. Login
  14. Click logout
  15. Verify redirect to Keycloak logout
  16. Verify session cleared
  17. 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