OIDC Integration
Groovy script to be added to the API Proxy request pipeline:
//v16
import groovy.json.JsonSlurper
import groovy.json.JsonOutput
import java.net.URLEncoder
import java.net.URLDecoder
import java.util.zip.GZIPOutputStream
import java.util.zip.GZIPInputStream
import java.io.ByteArrayOutputStream
import java.io.ByteArrayInputStream
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.security.SecureRandom
import java.time.Instant
import java.util.Base64
import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import org.apache.http.client.methods.HttpGet
import org.apache.http.client.methods.HttpPost
import org.apache.http.impl.client.HttpClients
import org.apache.http.entity.StringEntity
import org.apache.http.util.EntityUtils
// ################## OIDC Configuration ##################
def OIDC_CONFIG = [
clientId: "client_name",
clientSecret: "client_secret",
realm: "realm_name",
scope: "openid email",
discovery: "https://auth.keycloak.local/realms/realm_name/.well-known/openid-configuration",
authorizationEndpoint: "https://auth.keycloak.local/realms/realm_name/protocol/openid-connect/auth",
introspectionEndpoint: "https://auth.keycloak.local/realms/realm_name/protocol/openid-connect/token/introspect",
tokenEndpoint: "https://auth.keycloak.local/realms/realm_name/protocol/openid-connect/token",
redirectAfterLogoutUri: "https://auth.keycloak.local/realms/realm_name/protocol/openid-connect/logout",
postLogoutRedirectUri: "https://application.local/application_ui/", // Mandatory
logoutPath: "/logout",
redirectUri: "https://application.local/application_ui/",
redirectAfterLogoutWithIdTokenHint: true,
usePkce: true,
useNonce: true,
bearerJwtAuthEnable: false, // Enable authentication with Header
accessTokenHeaderName: "Authorization", // Which header to authenticate with
accessTokenAsBearer: true, // Should the header be added as a bearer token
addAccessTokenHeader: true, // Should we add the access token to the request?
authAcceptTokenAs: "header_cookie", // "header", "cookie", or "header_cookie"
addTokenToCookie: false, // Move the token from the Header to the cookie
addIdTokenHeader: true, // Should we add the id token value to the request if it exists?
idTokenHeaderName: "IdToken", // What name should we add the id token value with?
disableUserinfoHeader: false, // Disable Userinfo header
userinfoHeaderName: "UserInfo", // Which header will send user information
ignoreRequestMethods: ["OPTIONS"],
ignoreRequestRegex: "static/media,static/js,static/css,static/html,*.json,*.ico,*.png,*.svg,*.js,*.woff2,*.css,*.html"
accessTokenCookieName: "authorization",
enableRefreshTokenCookie: false,
refreshTokenCookieName: "refresh-token-cookie",
enableIdTokenCookie: false,
idTokenCookieName: "id-token-cookie",
validateAccessTokenWithApi: false,
validateIssuer: false, // If access token is JWT, should Issuer be validated?
expectedIssuer: "https://auth.keycloak.local/realms/realm_name", // Expected issuer value
validateAudience: false, // If access token is JWT, should Audience be validated?
expectedAudience: "task-public", // Expected audience value (usually same as clientId)
// Session settings
sessionCookieName: "aurax-app",
sessionCookieSecure: true, // Use false for HTTP testing
sessionAbsoluteTimeout: 34560000,
// Encryption settings (for cookie)
encryptionKey: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", // 32-byte key for AES-256
encryptionIv: "a1b2c3d4e5f6g7h8", // 16-byte IV for AES
debugEnabled: false
setCookieDelimiter: "#"
]
// Stop flow according to business logic
def stopFlow = { Integer statusCode, String message ->
statusCodeToTargetAPI = statusCode
requestErrorMessageToTargetAPI = message
}
// Handle OPTIONS requests immediately (pre-flight)
if (request_httpMethod == "OPTIONS") {
stopFlow(200,"CORS preflight handled")
return
}
// Debug helper - consistent function
def debug = { String key, String value ->
if(OIDC_CONFIG.debugEnabled){
requestHeaderMapToTargetAPI.put(key, value)
}
}
// Parse JWT token and extract expiry
def parseJwtToken = { String token ->
try {
if (token == null || !token.contains(".")) return null
def parts = token.split("\\.")
if (parts.length < 2) { // Need payload even if no signature
debug("jwt-parse-error", "Invalid JWT structure: less than 2 parts")
return null
}
// Get the payload part (2nd part)
def payload = parts[1]
// Fix padding for Base64Url decode
while (payload.length() % 4 != 0) {
payload += "="
}
// Decode and parse
def decodedPayload = new String(Base64.getUrlDecoder().decode(payload))
def claims = new JsonSlurper().parseText(decodedPayload)
return claims
} catch (Exception e) {
debug("jwt-parse-error", "Error parsing JWT token: " + e.getMessage())
return null
}
}
// Create userinfo header from token
def createUserinfoHeader = { String accessToken, String idToken ->
try {
def userinfo = [:]
// Get information from ID Token (priority)
if (idToken) {
def idClaims = parseJwtToken(idToken)
if (idClaims) {
userinfo.sub = idClaims.sub
userinfo.name = idClaims.name
userinfo.given_name = idClaims.given_name
userinfo.family_name = idClaims.family_name
userinfo.preferred_username = idClaims.preferred_username
userinfo.email = idClaims.email
userinfo.email_verified = idClaims.email_verified
// Other standard OIDC claims can be added
}
}
// Information can also be obtained from Access Token (if ID token is missing)
if (!userinfo.sub && accessToken) {
def accessClaims = parseJwtToken(accessToken)
if (accessClaims && accessClaims.sub) {
userinfo.sub = accessClaims.sub
// Access tokens usually have less information
if (accessClaims.preferred_username) {
userinfo.preferred_username = accessClaims.preferred_username
}
}
}
// Convert Userinfo object to JSON string and Base64 encode
if (userinfo && userinfo.sub) {
def userinfoJson = JsonOutput.toJson(userinfo)
return userinfoJson
}
return null
} catch (Exception e) {
debug("userinfo-create-error", "Error creating userinfo header: " + e.getMessage())
return null
}
}
// Helper method to concatenate Set-Cookie headers
def appendSetCookieHeader = { String newCookieValue ->
try {
def existingValue = customVariableMap.get("Set-Cookie")
def finalValue
if (existingValue) {
finalValue = existingValue + OIDC_CONFIG.setCookieDelimiter + newCookieValue
} else {
finalValue = newCookieValue
}
customVariableMap.put("Set-Cookie", finalValue)
debug("Set-Cookie-finalValue", finalValue)
} catch (Exception e) {
debug("append-set-cookie-error", "Error appending Set-Cookie: ${e.message}")
}
}
// Check if token is expired based on expires_at field
def isTokenExpired = { Long expiresAt ->
if (!expiresAt) return true
def currentTime = Instant.now().epochSecond
return currentTime >= expiresAt
}
def compressData = { String data ->
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream()
GZIPOutputStream gzipOut = new GZIPOutputStream(baos)
gzipOut.write(data.getBytes("UTF-8"))
gzipOut.close()
return Base64.getEncoder().encodeToString(baos.toByteArray())
} catch (Exception e) {
debug("compressError", "Error compressing data: ${e.message}")
throw e
}
}
def decompressData = { String compressedData ->
try {
byte[] decoded = Base64.getDecoder().decode(compressedData)
GZIPInputStream gzipIn = new GZIPInputStream(new ByteArrayInputStream(decoded))
ByteArrayOutputStream baos = new ByteArrayOutputStream()
byte[] buffer = new byte[1024]
int len
while ((len = gzipIn.read(buffer)) > 0) {
baos.write(buffer, 0, len)
}
gzipIn.close()
baos.close()
return new String(baos.toByteArray(), "UTF-8")
} catch (Exception e) {
debug("decompressError", "Error decompressing data: ${e.message}")
throw e
}
}
// URL encode utility
def encodeURIComponent = { String str ->
try {
return URLEncoder.encode(str, "UTF-8")
.replace("+", "%20")
.replace("%21", "!")
.replace("%27", "'")
.replace("%28", "(")
.replace("%29", ")")
.replace("%7E", "~")
} catch (Exception e) {
debug("encode-error", "Error encoding: " + e.getMessage())
return str
}
}
// Encrypt data
def encryptData = { String data ->
try {
def cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
def secretKey = new SecretKeySpec(OIDC_CONFIG.encryptionKey.getBytes(), "AES")
def ivSpec = new IvParameterSpec(OIDC_CONFIG.encryptionIv.getBytes())
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec)
def encryptedBytes = cipher.doFinal(data.getBytes())
return Base64.getEncoder().encodeToString(encryptedBytes)
} catch (Exception e) {
debug("encryptError", "Error encrypting data: ${e.message}")
throw e
}
}
// Decrypt data
def decryptData = { String encryptedData ->
try {
def cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
def secretKey = new SecretKeySpec(OIDC_CONFIG.encryptionKey.getBytes(), "AES")
def ivSpec = new IvParameterSpec(OIDC_CONFIG.encryptionIv.getBytes())
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
def decodedBytes = Base64.getDecoder().decode(encryptedData)
def decryptedBytes = cipher.doFinal(decodedBytes)
return new String(decryptedBytes)
} catch (Exception e) {
debug("decryptError", "Error decrypting data: ${e.message}")
throw e
}
}
// Validate JWT token locally (EXPIRY, ISSUER, AUDIENCE control - DOES NOT VALIDATE SIGNATURE!)
// SECURITY WARNING: This function alone does not guarantee token validity.
def validateJWTAccessToken = { String token ->
try {
def claims = parseJwtToken(token)
if (!claims) return false
// 1. Expiration control
def exp = claims.exp
if (exp) {
def currentTime = Instant.now().epochSecond
if (currentTime >= exp) {
debug("jwt-expired", "JWT token expired")
return false
}
}
// 2. Issuer control (configuration based)
if (OIDC_CONFIG.validateIssuer) {
def iss = claims.iss
if (!iss || !iss.contains(OIDC_CONFIG.expectedIssuer)) {
debug("jwt-wrong-issuer", "JWT has incorrect issuer. Expected: ${OIDC_CONFIG.expectedIssuer}, Actual: ${iss}")
return false
}
}
// 3. Audience control (configuration based)
if (OIDC_CONFIG.validateAudience) {
def aud = claims.aud
// Audience value can sometimes be a string, sometimes an array
boolean audValid = false
if (aud instanceof List) {
// Array of audiences
audValid = aud.contains(OIDC_CONFIG.expectedAudience)
} else if (aud instanceof String) {
// Single audience
audValid = (aud == OIDC_CONFIG.expectedAudience)
}
if (!audValid) {
debug("jwt-wrong-audience", "JWT has incorrect audience. Expected: ${OIDC_CONFIG.expectedAudience}, Actual: ${aud}")
return false
}
}
// Token considered valid
return true
} catch (Exception e) {
debug("jwt-validation-error", "Error validating JWT token locally: " + e.getMessage())
return false
}
}
// Determine if token is JWT
def isJwtToken = { String token ->
try {
if (!token) return false
// JWT format: header.payload.signature
def parts = token.split("\\.")
if (parts.length < 2) return false // Must have at least header and payload
// A rough check: Each part should conform to Base64Url format
def validBase64UrlPattern = ~/^[A-Za-z0-9_-]*$/
return parts.every { it.matches(validBase64UrlPattern) }
} catch (Exception e) {
debug("jwt-check-error", "Error checking if token is JWT: " + e.getMessage())
return false
}
}
// Get tokens from cookies
def getTokensFromCookies = {
debug("getTokensFromCookies", "Attempting to read token cookies.")
def tokens = [access_token: null, refresh_token: null, id_token: null]
try {
def cookieHeader = requestHeaderMapFromClient.get("Cookie")
if (cookieHeader == null) {
debug("token-cookies-read", "No Cookie header in request.")
return tokens // Return empty map
}
// Parse cookies into a map
def cookies = [:]
cookieHeader.split(";").each { cookie ->
def parts = cookie.trim().split("=", 2)
if (parts.length == 2 && !parts[0].isEmpty()) {
cookies[parts[0]] = parts[1] // Get value as is; URL decode will happen later
}
}
// Helper closure to get a single token (UNENCRYPTED and UNCOMPRESSED)
def getSingleToken = { String cookieName ->
def encodedValue = cookies.get(cookieName)
if (encodedValue == null) return null
try {
// ONLY URL Decode (No decrypt or decompress)
def rawTokenValue = URLDecoder.decode(encodedValue, StandardCharsets.UTF_8.toString())
if (rawTokenValue == null) throw new Exception("URL decoding failed for ${cookieName}")
return rawTokenValue
} catch (Exception e) {
debug("get-single-token-error", "Error reading/decoding ${cookieName}: ${e.message}. Cookie will be ignored.")
return null
}
}
// Get each token
tokens.access_token = getSingleToken(OIDC_CONFIG.accessTokenCookieName)
// ID Token cookie check
if (OIDC_CONFIG.enableIdTokenCookie) {
tokens.id_token = getSingleToken(OIDC_CONFIG.idTokenCookieName)
} else {
debug("id-token-cookie-read-skipped", "ID token cookie reading is disabled")
}
// Refresh Token cookie check
if (OIDC_CONFIG.enableRefreshTokenCookie) {
tokens.refresh_token = getSingleToken(OIDC_CONFIG.refreshTokenCookieName)
} else {
debug("refresh-token-cookie-read-skipped", "Refresh token cookie reading is disabled")
}
if (tokens.any { it.value != null }) {
debug("token-cookies-read", "Token cookies read. Found: " +
(tokens.access_token ? "AT " : "") +
(tokens.refresh_token ? "RT " : "") +
(tokens.id_token ? "IDT" : "") )
} else {
debug("token-cookies-read", "No valid token cookies found.")
}
} catch (Exception e) {
debug("token-cookies-read-error", "General error reading token cookies: ${e.message}")
// In case of error, returning an empty map is generally the safest
return [access_token: null, refresh_token: null, id_token: null]
}
return tokens
}
// Case-insensitive header reading function
def getHeaderCaseInsensitive = { String headerName ->
try {
// First, try directly
def headerValue = requestHeaderMapFromClient.get(headerName)
if (headerValue) {
return headerValue
}
// If not found, search case-insensitively
def lowerHeaderName = headerName.toLowerCase()
for (entry in requestHeaderMapFromClient) {
if (entry.key.toLowerCase() == lowerHeaderName) {
debug("header-case-insensitive", "Found header '${entry.key}' for requested '${headerName}'")
return entry.value
}
}
return null
} catch (Exception e) {
debug("header-case-insensitive-error", "Error reading header case-insensitive: " + e.getMessage())
return null
}
}
// Extract token based on authAcceptTokenAs configuration
def extractTokenBasedOnConfig = {
def token = null
def tokenSource = null // will be "header" or "cookie"
try {
// Read token from header
if (OIDC_CONFIG.authAcceptTokenAs.contains("header")) {
def authHeader = getHeaderCaseInsensitive(OIDC_CONFIG.accessTokenHeaderName)
if (authHeader) {
if (OIDC_CONFIG.accessTokenAsBearer && (authHeader.startsWith("Bearer ") || authHeader.startsWith("bearer "))) {
token = authHeader.substring(7)
tokenSource = "header"
debug("token-extract", "Token found in header")
} else if (!OIDC_CONFIG.accessTokenAsBearer) {
token = authHeader
tokenSource = "header"
debug("token-extract", "Token found in header (non-bearer)")
}
}
}
// Read token from cookie (if not found in header)
if (!token && OIDC_CONFIG.authAcceptTokenAs.contains("cookie")) {
def tokenData = getTokensFromCookies()
if (tokenData && tokenData.access_token) {
token = tokenData.access_token
tokenSource = "cookie"
debug("token-extract", "Token found in cookie")
}
}
return [token: token, source: tokenSource]
} catch (Exception e) {
debug("token-extract-error", "Error extracting token: " + e.getMessage())
return [token: null, source: null]
}
}
// Set separate token cookies
// CAUTION: Be aware of the 4KB cookie limit!
def setTokenCookies = { String accessToken, String refreshToken, String idToken ->
debug("setTokenCookies", "Attempting to set token cookies.")
boolean success = true // Overall success status
// Helper closure to set a single encrypted/compressed cookie
def setSingleTokenCookie = { String cookieName, String rawTokenValue ->
if (rawTokenValue == null) return // If no token, don't set cookie
try {
// ONLY URL Encode (No encryption or compression)
def encodedValue = encodeURIComponent(rawTokenValue) // Direct use of rawTokenValue
if (encodedValue == null) throw new Exception("URL encoding failed for ${cookieName}")
// Size check (optional but recommended)
// Tokens can be large, so size warning is still relevant.
if (encodedValue.length() > 3800) { // Leaving some room for the 4KB limit
debug("cookie-size-warning", "${cookieName} size (${encodedValue.length()} bytes) is dangerously close to/over the limit!")
}
// Build cookie string
def cookieValue = cookieName + "=" + encodedValue + "; " +
"Path=/; " +
"Max-Age=" + OIDC_CONFIG.sessionAbsoluteTimeout + "; " +
"HttpOnly; " + // Keep HttpOnly for security, preventing JS access
"SameSite=Lax"
if (OIDC_CONFIG.sessionCookieSecure) {
cookieValue += "; Secure"
}
// Append using helper method
appendSetCookieHeader(cookieValue)
debug("set-token-cookie-success", "${cookieName} set successfully (unencrypted).") // Updated debug message
} catch (Exception e) {
debug("set-single-token-cookie-error", "Error setting ${cookieName}: ${e.message}")
success = false // Set overall status to false if any token setting fails
}
}
// Set each token cookie
setSingleTokenCookie(OIDC_CONFIG.accessTokenCookieName, accessToken)
// Refresh Token cookie check
if (OIDC_CONFIG.enableRefreshTokenCookie) {
setSingleTokenCookie(OIDC_CONFIG.refreshTokenCookieName, refreshToken)
} else {
debug("refresh-token-cookie-skipped", "Refresh token cookie is disabled")
}
// ID Token cookie check
if (OIDC_CONFIG.enableIdTokenCookie) {
setSingleTokenCookie(OIDC_CONFIG.idTokenCookieName, idToken)
} else {
debug("id-token-cookie-skipped", "ID token cookie is disabled")
}
return success // Return overall success status
}
// Remove token cookies
def removeTokenCookies = {
debug("removeTokenCookies", "Attempting to remove token cookies.")
boolean success = true
// Helper closure to remove a single cookie
def removeSingleCookie = { String cookieName ->
try {
def cookieValue = cookieName + "=; " +
"Path=/; " +
"Max-Age=0; " + // Expire immediately
"Expires=Thu, 01 Jan 1970 00:00:00 GMT; " + // Past date
"HttpOnly; " +
"SameSite=Lax"
if (OIDC_CONFIG.sessionCookieSecure) {
cookieValue += "; Secure"
}
appendSetCookieHeader(cookieValue)
} catch (Exception e) {
debug("remove-single-cookie-error", "Error removing ${cookieName}: ${e.message}")
success = false
}
}
// Remove each cookie
removeSingleCookie(OIDC_CONFIG.accessTokenCookieName)
// ID Token cookie check
if (OIDC_CONFIG.enableIdTokenCookie) {
removeSingleCookie(OIDC_CONFIG.idTokenCookieName)
} else {
debug("id-token-cookie-remove-skipped", "ID token cookie removal is disabled")
}
// Refresh Token cookie check
if (OIDC_CONFIG.enableRefreshTokenCookie) {
removeSingleCookie(OIDC_CONFIG.refreshTokenCookieName)
} else {
debug("refresh-token-cookie-remove-skipped", "Refresh token cookie removal is disabled")
}
if (success) debug("token-cookies-remove", "Token removal headers appended.")
return success
}
// Create cookie and add to headers
def setOidcCookie = { Map data ->
try {
// Convert to JSON and encrypt
def jsonData = JsonOutput.toJson(data)
debug("setOidcCookie jsonData ", jsonData )
// Compress the data
def compressedData = compressData(jsonData)
debug("setOidcCookie compressedData ", compressedData )
// Encrypt the compressed data
def encryptedData = encryptData(compressedData)
debug("setOidcCookie compressed and encryptedData", encryptedData )
// URL encode the cookie value
def encodedValue = URLEncoder.encode(encryptedData, "UTF-8")
debug("setOidcCookie compressed and encrypted and encodedValue", encodedValue )
// Log the size of compressed data
debug("compression-stats", "Original size: ${jsonData.length()}, " +
"Compressed size: ${compressedData.length()}, " +
"Encrypted size: ${encryptedData.length()}, " +
"Final size: ${encodedValue.length()}")
// Cookie template
def cookieValue = OIDC_CONFIG.sessionCookieName + "=" + encodedValue + "; " +
"Path=/; " +
"Max-Age=" + OIDC_CONFIG.sessionAbsoluteTimeout + "; " +
"HttpOnly; " +
"SameSite=Lax"
if (OIDC_CONFIG.sessionCookieSecure) {
cookieValue += "; Secure"
}
appendSetCookieHeader(cookieValue)
// Add debug information
debug("cookie-set", "Compressed OIDC cookie set, length: " + encodedValue.length())
return true
} catch (Exception e) {
debug("cookie-set-error", "Error setting compressed OIDC cookie: " + e.getMessage())
return false
}
}
def getOidcCookie = {
try {
// Get Cookie header
def cookieHeader = requestHeaderMapFromClient.get("Cookie")
if (!cookieHeader) {
debug("cookie-read", "No cookies in request")
return null
}
// Parse cookies
def cookies = [:]
cookieHeader.split(";").each { cookie ->
def parts = cookie.trim().split("=", 2)
if (parts.length == 2) {
cookies[parts[0]] = URLDecoder.decode(parts[1], "UTF-8")
}
}
// Find OIDC cookie
if (!cookies.containsKey(OIDC_CONFIG.sessionCookieName)) {
debug("cookie-read", "OIDC cookie not found")
return null
}
// Decrypt
def encryptedData = cookies[OIDC_CONFIG.sessionCookieName]
debug("getOidcCookie compressed and encrypted and decodedValue", encryptedData )
def compressedData = decryptData(encryptedData)
debug("getOidcCookie compressed and decrypted and decodedValue", compressedData )
// Decompress data
def jsonData = decompressData(compressedData)
debug("getOidcCookie decompressed and decrypted and decodedValue (plain json value)", jsonData )
// Convert to JSON
def sessionData = new JsonSlurper().parseText(jsonData)
debug("getOidcCookie cookie-value", sessionData.toString() )
debug("getOidcCookie result", "Compressed OIDC cookie read successfully")
return sessionData
} catch (Exception e) {
debug("cookie-read-error", "Error reading compressed OIDC cookie: " + e.getMessage())
return null
}
}
// Function to remove cookie
def removeOidcCookie = {
try {
// Reset cookie and set Max-Age to 0
def cookieValue = OIDC_CONFIG.sessionCookieName + "=; " +
"Path=/; " +
"Max-Age=0; " +
"HttpOnly; " +
"SameSite=Lax"
if (OIDC_CONFIG.sessionCookieSecure) {
cookieValue += "; Secure"
}
// Add cookie to customVariableMap
appendSetCookieHeader(cookieValue)
debug("oidc-cookie-remove", "OIDC Login Flow cookie removal header appended.")
return true
} catch (Exception e) {
debug("cookie-remove-error", "Error removing OIDC cookie: " + e.getMessage())
return false
}
}
// Validate token
def validateAccessTokenWithApi = { String token ->
if (!token) return false
debug("validateAccessTokenWithApi", "Validating token via introspection endpoint.")
try {
def introspectionEndpoint = OIDC_CONFIG.introspectionEndpoint
// Prepare introspection request parameters
def params = [
token: token,
client_id: OIDC_CONFIG.clientId,
client_secret: OIDC_CONFIG.clientSecret
]
// Build request body
StringBuilder bodyBuilder = new StringBuilder()
boolean first = true
params.each { key, value ->
if (!first) {
bodyBuilder.append("&")
}
bodyBuilder.append(encodeURIComponent(key.toString()))
bodyBuilder.append("=")
bodyBuilder.append(encodeURIComponent(value.toString()))
first = false
}
def requestBody = bodyBuilder.toString()
// Prepare HTTP client and request
def httpClient = HttpClients.createDefault()
def introspectRequest = new HttpPost(introspectionEndpoint)
introspectRequest.setHeader("Content-Type", "application/x-www-form-urlencoded")
introspectRequest.setEntity(new StringEntity(requestBody))
// Execute request
def response = httpClient.execute(introspectRequest)
def statusCode = response.getStatusLine().getStatusCode()
def responseBody = EntityUtils.toString(response.getEntity())
if (statusCode == 200) {
def introspectionResult = new JsonSlurper().parseText(responseBody)
boolean active = introspectionResult.active == true
debug("introspection-result", "Token active: ${active}")
return active
}else {
debug("introspection-error", "Introspection failed. Status: ${statusCode}, Body: ${responseBody}")
}
} catch (Exception e) {
debug("tokenValidationError", "Error validating token: " + e.getMessage())
}
return false
}
// Helper function to start login flow
def startLoginFlow = { String originalPath ->
debug("Login", "Login Process started")
// Create state, code_verifier and nonce
def state = UUID.randomUUID().toString()
// PKCE code verifier and challenge
def secureRandom = new SecureRandom()
byte[] bytes = new byte[32]
secureRandom.nextBytes(bytes)
def codeVerifier = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
def digest = MessageDigest.getInstance("SHA-256")
byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.UTF_8))
def codeChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(hash)
// Create nonce
def nonce = OIDC_CONFIG.useNonce ? UUID.randomUUID().toString() : null
// Save original URL
def originalUrl = request_requestURI
if (request_queryString) {
originalUrl += "?" + request_queryString
}
if (originalUrl.trim() == "" || originalUrl == "/") {
originalUrl = "/"
}
// Store all data in a single cookie
def sessionData = [
flow: "login", // Flow type
state: state, // CSRF protection
code_verifier: codeVerifier, // For PKCE
code_challenge: codeChallenge, // For debugging
nonce: nonce, // Replay protection
original_url: originalUrl, // Redirect after callback
created_at: Instant.now().epochSecond // Timestamp
]
// Clear all token cookies
removeTokenCookies()
// Set cookie - use duration from OIDC_CONFIG for login
setOidcCookie(sessionData )
// Debug: Log state and code_verifier values
debug("state", state)
debug("code_verifier", codeVerifier.substring(0, 10) + "...")
// Create Keycloak auth URL
def authParams = [
client_id: OIDC_CONFIG.clientId,
response_type: "code",
redirect_uri: OIDC_CONFIG.redirectUri,
scope: OIDC_CONFIG.scope,
state: state
]
// Add PKCE
if (OIDC_CONFIG.usePkce) {
authParams.code_challenge = codeChallenge
authParams.code_challenge_method = "S256"
}
// Add nonce
if (nonce && OIDC_CONFIG.useNonce) {
authParams.nonce = nonce
}
// Create URL
StringBuilder urlBuilder = new StringBuilder(OIDC_CONFIG.authorizationEndpoint)
urlBuilder.append("?")
boolean first = true
authParams.each { k, v ->
if (!first) {
urlBuilder.append("&")
}
urlBuilder.append(encodeURIComponent(k.toString()))
urlBuilder.append("=")
urlBuilder.append(encodeURIComponent(v.toString()))
first = false
}
def authUrl = urlBuilder.toString()
// Debug: Log auth URL
debug("auth_url", authUrl)
// Set redirection
customVariableMap.put("Location", authUrl)
// Redirect with 302
stopFlow(302,"Redirecting to authentication provider")
}
// ################## Path Matching Methods ##################
// Method to check if a request should be ignored based on patterns from config
def shouldIgnoreRequest(String requestPath, String configPatternsString) {
// Get patterns from config and trim each one
def patterns = configPatternsString.split(",").collect { it.trim() }
// Check each pattern
for (pattern in patterns) {
if (matchesPattern(requestPath, pattern)) {
return true
}
}
return false
}
// Method to check if a path matches a specific pattern (wildcard or exact)
def matchesPattern(String path, String pattern) {
// File extension wildcard (*.ext)
if (pattern.startsWith("*.")) {
String extension = pattern.substring(2)
return path.endsWith("." + extension)
}
// Path wildcard (dir/*)
else if (pattern.endsWith("/*")) {
String prefix = pattern.substring(0, pattern.length() - 2)
return path.startsWith(prefix) || path.contains("/" + prefix + "/") || path.contains(prefix + "/")
}
// Exact pattern match
else {
return path.contains(pattern)
}
}
// ################## Main Process ##################
def path = request_pathInfo
def method = request_httpMethod
// Check code and state parameters
def code = requestUrlParamMapFromClient.get("code")
def state = requestUrlParamMapFromClient.get("state")
// ----------------- Callback Process -----------------
if (code && state) {
debug("Callback", "Callback path detected: " + path)
// Get login data from cookie
def sessionData = getOidcCookie()
// Return error if no session or not a login flow
if (!sessionData || sessionData.flow != "login") {
debug("Callback-error", "No valid login session found")
stopFlow(400,"No valid login session found")
return
}
// Compare state value
if (state != sessionData.state) {
debug("Callback-error", "State mismatch. Expected: ${sessionData.state}, Got: ${state}")
stopFlow(400,"Invalid state parameter")
return
}
// Prepare parameters for token exchange
def tokenParams = [
grant_type: "authorization_code",
code: code,
redirect_uri: OIDC_CONFIG.redirectUri,
client_id: OIDC_CONFIG.clientId,
client_secret: OIDC_CONFIG.clientSecret
]
// Add PKCE code_verifier
if (sessionData.code_verifier && OIDC_CONFIG.usePkce) {
tokenParams.code_verifier = sessionData.code_verifier
debug("Callback-pkce", "Added code_verifier to token request")
} else {
debug("Callback-error", "No code_verifier in session")
stopFlow(400,"PKCE code_verifier missing")
return
}
// Make HTTP request for token exchange
try {
// Create POST body
StringBuilder tokenBody = new StringBuilder()
boolean first = true
tokenParams.each { key, value ->
if (!first) tokenBody.append("&")
tokenBody.append(encodeURIComponent(key))
tokenBody.append("=")
tokenBody.append(encodeURIComponent(value))
first = false
}
// Retrieve certificate from environment and handle sslContext if needed
java.security.cert.X509Certificate cert= environment_certificateMap.get("wildcard.local");
// Create a KeyStore with the certificate
def keyStore = java.security.KeyStore.getInstance(java.security.KeyStore.getDefaultType());
keyStore.load(null, null);
keyStore.setCertificateEntry("cert-alias", cert);
// Create SSL context with the KeyStore
def sslContext = org.apache.http.ssl.SSLContexts.custom()
.loadTrustMaterial(keyStore, null)
.build();
// Create a custom SSL socket factory with the SSL context
def sslSocketFactory = new org.apache.http.conn.ssl.SSLConnectionSocketFactory( sslContext, null, null, org.apache.http.conn.ssl.NoopHostnameVerifier.INSTANCE);
// Register the SSL socket factory with connection manager
def connManager = new org.apache.http.impl.conn.PoolingHttpClientConnectionManager(
org.apache.http.config.RegistryBuilder.<org.apache.http.conn.socket.ConnectionSocketFactory>create()
.register("https", sslSocketFactory)
.register("http", new org.apache.http.conn.socket.PlainConnectionSocketFactory())
.build()
);
// Build the HTTP client with the custom connection manager
def httpClient = HttpClients.custom()
.setConnectionManager(connManager)
.build();
// Create and execute the token request
def tokenRequest = new HttpPost(OIDC_CONFIG.tokenEndpoint)
tokenRequest.setHeader("Content-Type", "application/x-www-form-urlencoded")
tokenRequest.setEntity(new StringEntity(tokenBody.toString()))
def response = httpClient.execute(tokenRequest)
def statusCode = response.getStatusLine().getStatusCode()
def responseBody = EntityUtils.toString(response.getEntity())
debug("tokenResponse responseBody", responseBody)
// Process token response
if (statusCode == 200) {
def tokenResponse = new JsonSlurper().parseText(responseBody)
debug("callback-tokens", "Got tokens - AT: ${tokenResponse.access_token?.substring(0,20)}..., RT: ${tokenResponse.refresh_token ? 'yes' : 'no'}, IDT: ${tokenResponse.id_token ? 'yes' : 'no'}")
debug("tokenResponse id_token", tokenResponse.id_token)
// Extract user information from token
def idToken = tokenResponse.id_token
def tokenExpiration = null
def username = null
def email = null
def userId = null
String receivedNonce = null // Variable to store the received nonce
if (idToken) {
try {
def parts = idToken.split("\\.")
if (parts.length >= 2) {
def payload = parts[1]
while (payload.length() % 4 != 0) {
payload += "="
}
def decodedPayload = new String(Base64.getUrlDecoder().decode(payload))
def claims = new JsonSlurper().parseText(decodedPayload)
tokenExpiration = claims.exp
username = claims.preferred_username ?: claims.name
email = claims.email
userId = claims.sub
receivedNonce = claims.nonce
}
} catch (Exception e) {
debug("token-parse-error", "Error parsing ID token: " + e.getMessage())
}
}
// ################## NONCE VALIDATION ##################
if (OIDC_CONFIG.useNonce) {
// sessionData was retrieved at the beginning of the callback block with getOidcCookie().
def expectedNonce = sessionData.nonce
if (!expectedNonce || !receivedNonce || expectedNonce != receivedNonce) {
// If nonces do not match or one/both are missing, throw error and stop.
debug("Callback-error", "Nonce mismatch or missing. Expected: '${expectedNonce}', Received: '${receivedNonce}'")
stopFlow(400, "Invalid nonce") // 400 Bad Request or 401 Unauthorized for security breach
removeOidcCookie() // Clear invalid session cookie
removeTokenCookies() // Clear potential token cookies
return
} else {
// Nonce is valid
debug("Nonce-validation", "Nonce validated successfully.")
}
}
// New session data
def newSessionData = [
flow: "authenticated",
created_at: Instant.now().epochSecond,
expires_at: tokenExpiration ?: (Instant.now().epochSecond + tokenResponse.expires_in),
user_info: [
username: username,
email: email,
id: userId
]
]
// Set long-term session cookie - get duration from OIDC_CONFIG
setOidcCookie(newSessionData)
// Also set token cookies
setTokenCookies(
tokenResponse.access_token,
tokenResponse.refresh_token,
tokenResponse.id_token
)
// Redirect to original URL
def redirectUrl = sessionData.original_url ?: OIDC_CONFIG.postLogoutRedirectUri;
customVariableMap.put("Location", redirectUrl)
debug("Authentication successful redirect path Location", redirectUrl)
stopFlow(302,"Authentication successful")
return
} else {
debug("token-error", "Failed to exchange code for tokens: " + responseBody)
stopFlow(500,"Token exchange failed")
return
}
} catch (Exception e) {
debug("token-exception", "Error during token exchange: " + e.getMessage())
stopFlow(500,"Error during token exchange")
return
}
}
// ----------------- Logout Process -----------------
else if (path.contains(OIDC_CONFIG.logoutPath)) {
debug("Logout", "Logout path detected: " + path)
// Read session cookie
def sessionData = getOidcCookie()
def tokenData = getTokensFromCookies()
// Remove cookie
removeOidcCookie()
removeTokenCookies()
// Create Keycloak logout URL
def logoutUrl = OIDC_CONFIG.redirectAfterLogoutUri
// Add ID token as hint if available
if (tokenData && tokenData.id_token && OIDC_CONFIG.redirectAfterLogoutWithIdTokenHint) {
logoutUrl += "?id_token_hint=" + encodeURIComponent(tokenData.id_token)
// Add post-logout redirect URL
if (OIDC_CONFIG.postLogoutRedirectUri) {
logoutUrl += "&post_logout_redirect_uri=" + encodeURIComponent(OIDC_CONFIG.postLogoutRedirectUri)
}
} else if (OIDC_CONFIG.postLogoutRedirectUri) {
// Add only redirect URL if no ID token
logoutUrl += "?post_logout_redirect_uri=" + encodeURIComponent(OIDC_CONFIG.postLogoutRedirectUri)
}
debug("logoutUrl", logoutUrl)
// Redirect to Keycloak logout page
customVariableMap.put("Location", logoutUrl)
stopFlow(302,"Logout successful")
return
}
// ----------------- Regular API Requests -----------------
else {
debug("ProtectedRequest", "Protected resource requested: " + path)
// Ignore path check
if (OIDC_CONFIG.ignoreRequestMethods.contains(request_httpMethod)) {
debug("IgnoreReason", "Request method " + request_httpMethod + " is ignored")
return
}
if (shouldIgnoreRequest(path, OIDC_CONFIG.ignoreRequestPatterns)) {
debug("PathIgnored", "Request will proceed without authentication")
return
}
// Bearer JWT authentication check
def bearerTokenHandled = false
if (OIDC_CONFIG.bearerJwtAuthEnable) {
def tokenInfo = extractTokenBasedOnConfig()
def bearerToken = tokenInfo.token
def tokenSource = tokenInfo.source
if (bearerToken) {
debug("bearer-auth", "Bearer token found from: " + tokenSource)
// Token validation
boolean isJwt = isJwtToken(bearerToken)
boolean tokenValid = false
if (isJwt) {
boolean locallyValid = validateJWTAccessToken(bearerToken)
if (locallyValid) {
if (OIDC_CONFIG.validateAccessTokenWithApi) {
tokenValid = validateAccessTokenWithApi(bearerToken)
} else {
tokenValid = true
}
} else {
tokenValid = false
}
} else {
tokenValid = validateAccessTokenWithApi(bearerToken)
}
if (tokenValid) {
debug("bearer-auth", "Bearer token is valid")
// TOKEN MUST ALWAYS BE SENT TO THE BACKEND
if (OIDC_CONFIG.addAccessTokenHeader) {
def tokenPrefix = OIDC_CONFIG.accessTokenAsBearer ? "Bearer " : ""
requestHeaderMapToTargetAPI.put(OIDC_CONFIG.accessTokenHeaderName, tokenPrefix + bearerToken)
debug("bearer-auth", "Access token added to backend request headers from " + tokenSource)
}
// addTokenToCookie logic - save to cookie only if coming from header
if (OIDC_CONFIG.addTokenToCookie && tokenSource == "header") {
// Save the token from the header to the cookie as well
def existingTokens = getTokensFromCookies()
setTokenCookies(
bearerToken,
existingTokens?.refresh_token, // Preserve existing refresh token
existingTokens?.id_token // Preserve existing ID token
)
debug("bearer-auth", "Token moved from header to cookie, existing tokens preserved")
} else if (tokenSource == "cookie") {
debug("bearer-auth", "Token from cookie used for backend authentication")
}
// Add Userinfo header
if (!OIDC_CONFIG.disableUserinfoHeader) {
// Try to get ID token from cookie
def tokenData = getTokensFromCookies()
def idToken = tokenData?.id_token
def userinfoHeader = createUserinfoHeader(bearerToken, idToken)
if (userinfoHeader) {
requestHeaderMapToTargetAPI.put(OIDC_CONFIG.userinfoHeaderName, userinfoHeader)
debug("userinfo-header", "Added userinfo header to request")
}
}
bearerTokenHandled = true
return
} else {
// Token invalid - always return 401
debug("bearer-auth", "Bearer token is invalid")
removeTokenCookies()
removeOidcCookie()
stopFlow(401, "Invalid token")
return
}
} else if (OIDC_CONFIG.addTokenToCookie) {
// If no token in header and addTokenToCookie is true, clear cookies
def existingAuthHeader = getHeaderCaseInsensitive(OIDC_CONFIG.accessTokenHeaderName)
// If no token at all, proceed to normal cookie-based auth
debug("bearer-auth", "No bearer token found, falling back to cookie-based authentication")
}
}
// If no bearer token, cookie-based session control
if (!bearerTokenHandled) {
def sessionData = getOidcCookie()
def tokenData = getTokensFromCookies()
// If bearerJwtAuthEnable is true and authAcceptTokenAs includes cookie,
// also check the token in the cookie with Bearer token logic
if (OIDC_CONFIG.bearerJwtAuthEnable &&
OIDC_CONFIG.authAcceptTokenAs.contains("cookie") &&
tokenData && tokenData.access_token) {
debug("cookie-bearer-auth", "Checking cookie token with Bearer auth logic")
// Validate the token in the cookie with Bearer token logic
boolean isJwt = isJwtToken(tokenData.access_token)
boolean tokenValid = false
if (isJwt) {
boolean locallyValid = validateJWTAccessToken(tokenData.access_token)
if (locallyValid) {
if (OIDC_CONFIG.validateAccessTokenWithApi) {
tokenValid = validateAccessTokenWithApi(tokenData.access_token)
} else {
tokenValid = true
}
} else {
tokenValid = false
}
} else {
tokenValid = validateAccessTokenWithApi(tokenData.access_token)
}
if (tokenValid) {
debug("cookie-bearer-auth", "Cookie token is valid")
// TOKEN MUST ALWAYS BE SENT TO THE BACKEND
if (OIDC_CONFIG.addAccessTokenHeader) {
def tokenPrefix = OIDC_CONFIG.accessTokenAsBearer ? "Bearer " : ""
requestHeaderMapToTargetAPI.put(OIDC_CONFIG.accessTokenHeaderName, tokenPrefix + tokenData.access_token)
debug("cookie-bearer-auth", "Access token added to backend request headers from cookie")
}
if (OIDC_CONFIG.addIdTokenHeader && tokenData.id_token) {
requestHeaderMapToTargetAPI.put(OIDC_CONFIG.idTokenHeaderName, tokenData.id_token)
}
// Add Userinfo header
if (!OIDC_CONFIG.disableUserinfoHeader) {
def userinfoHeader = createUserinfoHeader(tokenData.access_token, tokenData.id_token)
if (userinfoHeader) {
requestHeaderMapToTargetAPI.put(OIDC_CONFIG.userinfoHeaderName, userinfoHeader)
debug("userinfo-header", "Added userinfo header to request from cookie")
}
}
return // Operation successful, proceed
} else {
// Token in cookie is invalid - return 401
debug("cookie-bearer-auth", "Cookie token is invalid")
removeTokenCookies()
removeOidcCookie()
stopFlow(401, "Invalid token")
return
}
}
// If no session → Redirect to Login
if (!sessionData || sessionData.flow != "authenticated" ||
!tokenData || !tokenData.access_token) {
debug("no-session", "No valid session found, redirecting to login")
startLoginFlow(path)
return
}
// If token expired → Redirect to Login
if (isTokenExpired(sessionData.expires_at)) {
debug("token-expired", "Session token expired, redirecting to login")
startLoginFlow(path)
return
}
// If session exists, validate token (optional)
if (tokenData && tokenData.access_token) {
boolean isJwt = isJwtToken(tokenData.access_token)
boolean tokenValid = false
if (isJwt) {
boolean locallyValid = validateJWTAccessToken(tokenData.access_token)
if (locallyValid) {
if (OIDC_CONFIG.validateAccessTokenWithApi) {
tokenValid = validateAccessTokenWithApi(tokenData.access_token)
} else {
tokenValid = true
}
} else {
tokenValid = false
}
} else if (OIDC_CONFIG.validateAccessTokenWithApi) {
tokenValid = validateAccessTokenWithApi(tokenData.access_token)
} else {
tokenValid = true
}
if (!tokenValid) {
debug("session-token-invalid", "Session token is invalid, redirecting to login")
startLoginFlow(path)
return
}
}
// Add tokens from cookie to backend
if (OIDC_CONFIG.addAccessTokenHeader && tokenData.access_token) {
def tokenPrefix = OIDC_CONFIG.accessTokenAsBearer ? "Bearer " : ""
requestHeaderMapToTargetAPI.put(OIDC_CONFIG.accessTokenHeaderName, tokenPrefix + tokenData.access_token)
debug("session-auth", "Access token added to backend request headers from cookie")
}
if (OIDC_CONFIG.addIdTokenHeader && tokenData.id_token) {
requestHeaderMapToTargetAPI.put(OIDC_CONFIG.idTokenHeaderName, tokenData.id_token)
}
// Add Userinfo header
if (!OIDC_CONFIG.disableUserinfoHeader && tokenData) {
def userinfoHeader = createUserinfoHeader(tokenData.access_token, tokenData.id_token)
if (userinfoHeader) {
requestHeaderMapToTargetAPI.put(OIDC_CONFIG.userinfoHeaderName, userinfoHeader)
debug("userinfo-header", "Added userinfo header to request from session")
}
}
return
}
}
Groovy script to be added to the API Proxy error pipeline:
if(customVariableMap.get("Location")!=null ){
responseHeaderMapToClient.put("Location", customVariableMap.get("Location"))
statusCodeToClient=302;
}
customVariableMap.each { key, value ->
if (key.toLowerCase().contains("cookie")) {
responseHeaderMapToClient.put(key, value)
}
}
When implementing OIDC (OpenID Connect) integration with gateway solutions, there are some critical considerations that must be addressed.
Issue 1: OIDC Parameters Transmission Mode
By default, OIDC authentication returns parameters using URL fragments (#). However, fragment values remain in the browser and are not sent to the server. This causes authentication failures when a gateway solution is placed in between.
# Using fragments (WILL NOT WORK): https://example.com/callback#access_token=eyJ0...&token_type=bearer&...
Solution:
You must configure the response_mode parameter in your OIDC configuration to "query". This ensures parameters are transmitted as query parameters (?) instead of fragments, allowing them to be successfully passed to the server.
# Using query parameters (WILL WORK): https://example.com/callback?access_token=eyJ0...&token_type=bearer&...
Configuration:
- For Keycloak: In client settings, under "Advanced Settings," set the "Response Mode" value to "query".
- For other OIDC providers: Add the "response_mode=query" parameter in the relevant client configuration.
Issue 2: Header Size Limitations with Nginx Ingress Controller
Issue:
When using OIDC with Nginx Ingress Controller, authentication cookies and headers may exceed the default buffer size limits. This results in 400 Bad Request
errors or truncated headers during the authentication process.
Solution:
Increase the buffer size settings in your Nginx Ingress Controller configuration:
nginx.ingress.kubernetes.io/proxy-buffer-size: "8k"
nginx.ingress.kubernetes.io/client-header-buffer-size: "8k"
nginx.ingress.kubernetes.io/large-client-header-buffers: "4 8k"
These settings allow the Nginx Ingress Controller to properly handle the larger headers that are common with OIDC authentication tokens and cookies