JWT Validation
The EDK provides framework integrations for JWT validation with support for multiple Identity Providers (IdPs) and per-tenant routing. The core validation logic lives in the IDK, with EDK providing Ktor and Spring Boot integrations.
Overview
The JWT validation framework provides:
- Multi-IdP support - Keycloak, Azure AD, Auth0, Okta, generic OIDC
- Per-tenant routing - Different IdPs per tenant
- Automatic discovery - OIDC discovery for JWKS URIs
- Scope checking - Built-in scope validation helpers
- Framework integration - Ktor plugin and Spring Boot auto-configuration
Architecture
IdP Configuration
Supported IdP Types
| Type | Description |
|---|---|
OIDC | Generic OpenID Connect |
KEYCLOAK | Keycloak with realm support |
AZURE_AD | Azure Active Directory / Entra ID |
AUTH0 | Auth0 |
OKTA | Okta |
CUSTOM | Custom with explicit JWKS URI |
Configuration Examples
// Keycloak
val keycloak = IdpConfig.keycloak(
id = "keycloak-prod",
baseUrl = "https://auth.example.com",
realm = "master",
audience = "my-api"
)
// Azure AD / Entra ID
val azureAd = IdpConfig.azureAd(
id = "azure-prod",
tenantId = "your-azure-tenant-id",
audience = "api://my-api"
)
// Auth0
val auth0 = IdpConfig.auth0(
id = "auth0-prod",
domain = "your-tenant.auth0.com",
audience = "https://api.example.com"
)
// Generic OIDC (uses discovery)
val oidc = IdpConfig.oidc(
id = "custom-idp",
issuer = "https://idp.example.com",
audience = "my-api"
)
// Custom with explicit JWKS URI
val custom = IdpConfig.custom(
id = "legacy-idp",
issuer = "https://legacy.example.com",
jwksUri = "https://legacy.example.com/.well-known/jwks.json",
audience = "my-api"
)
IdpConfig Properties
data class IdpConfig(
val id: String,
val type: IdpType = IdpType.OIDC,
val issuer: String,
val audience: String? = null,
val jwksUri: String? = null, // Explicit JWKS URI
val discoveryUri: String? = null, // OIDC discovery URL
val tenantClaim: String = "tenant_id", // Claim for tenant ID
val tenantClaimAlternatives: List<String> = listOf("azp", "client_id"),
val allowedAlgorithms: List<String> = listOf("RS256", "ES256"),
val clockSkewSeconds: Long = 60,
val jwksCacheTtlSeconds: Long = 3600,
val requiredClaims: List<String> = emptyList()
)
ValidatedAccessToken
The result of successful validation:
data class ValidatedAccessToken(
val subject: String, // The 'sub' claim
val issuer: String, // Verified 'iss' claim
val audiences: List<String>, // The 'aud' claim(s)
val expiresAt: Long, // Expiration timestamp
val issuedAt: Long, // Issued-at timestamp
val notBefore: Long?, // Not-before timestamp
val scopes: Set<String>, // OAuth scopes
val tenantId: String?, // Extracted tenant ID
val clientId: String?, // Client ID (azp/client_id)
val jwtId: String?, // JWT ID (jti)
val rawToken: String, // Original token string
val claims: Map<String, JsonElement>, // All claims
val idpId: String // Which IdP validated this
) {
fun hasScope(scope: String): Boolean = scopes.contains(scope)
fun hasAllScopes(required: Set<String>): Boolean = scopes.containsAll(required)
fun hasAnyScope(allowed: Set<String>): Boolean = scopes.any { allowed.contains(it) }
}
Ktor Integration
Plugin Installation
import com.sphereon.oauth2.jwt.validation.ktor.*
import io.ktor.server.application.*
fun Application.module() {
// Install JWT authentication
install(JwtAuthentication) {
validationService = myJwtValidationService
anonymousConfig = AnonymousAccessConfig(
allowed = false,
allowedPaths = listOf("/health", "/api/public/*")
)
onAuthenticationFailed = { call, message ->
call.respond(HttpStatusCode.Unauthorized, mapOf("error" to message))
}
}
}
Accessing Validated Token
import com.sphereon.oauth2.jwt.validation.ktor.ValidatedTokenKey
routing {
get("/protected") {
val token = call.attributes.getOrNull(ValidatedTokenKey)
if (token != null) {
call.respond("Hello ${token.subject}, tenant: ${token.tenantId}")
} else {
call.respond(HttpStatusCode.Unauthorized)
}
}
get("/admin") {
val token = call.attributes[ValidatedTokenKey]
if (token.hasScope("admin")) {
call.respond("Admin access granted")
} else {
call.respond(HttpStatusCode.Forbidden, "Requires admin scope")
}
}
}
Tenant and Principal Resolution
import com.sphereon.oauth2.jwt.validation.ktor.*
fun Application.module() {
install(JwtAuthentication) { ... }
// Use JWT-based resolvers for user context
install(KotlinInjectPlugin) {
appComponent = myAppComponent
tenantResolver = ValidatingTenantResolver(defaultTenantId = "default")
principalResolver = ValidatingPrincipalResolver()
}
}
Spring Boot Integration
Configuration
sphereon:
auth:
jwt:
enabled: true
default-idp:
issuer: https://auth.example.com/realms/master
audience: my-api
tenant-claim: tenant_id
tenant-idps:
tenant-a:
issuer: https://login.microsoftonline.com/tenant-a-id/v2.0
type: AZURE_AD
tenant-claim: tid
tenant-b:
issuer: https://tenant-b.auth0.com/
type: AUTH0
audience: api://tenant-b
anonymous:
allowed: false
allowed-paths:
- /health
- /actuator/**
- /.well-known/**
Properties Reference
@ConfigurationProperties(prefix = "sphereon.auth.jwt")
class JwtValidationProperties {
var enabled: Boolean = true
var defaultIdp: IdpProperties = IdpProperties()
var tenantIdps: Map<String, IdpProperties> = emptyMap()
var anonymous: AnonymousProperties = AnonymousProperties()
class IdpProperties {
var type: IdpType = IdpType.OIDC
var issuer: String = ""
var audience: String? = null
var jwksUri: String? = null
var tenantClaim: String = "tenant_id"
var tenantClaimAlternatives: List<String> = listOf("azp", "client_id")
var allowedAlgorithms: List<String> = listOf("RS256", "ES256")
var jwksCacheTtlSeconds: Long = 3600
var clockSkewSeconds: Long = 60
var requireExp: Boolean = true
var requireIat: Boolean = false
}
class AnonymousProperties {
var allowed: Boolean = false
var allowedPaths: List<String> = listOf("/health", "/actuator/**")
}
}
Controller Usage
import com.sphereon.oauth2.jwt.validation.spring.ValidatingAuthenticationResolver
import org.springframework.web.bind.annotation.*
import jakarta.servlet.http.HttpServletRequest
@RestController
@RequestMapping("/api")
class MyController(
private val authResolver: ValidatingAuthenticationResolver
) {
@GetMapping("/profile")
fun getProfile(request: HttpServletRequest): Map<String, Any> {
val token = authResolver.requireValidatedToken(request)
return mapOf(
"subject" to token.subject,
"tenant" to (token.tenantId ?: "unknown"),
"scopes" to token.scopes,
"expires_at" to token.expiresAt
)
}
@PostMapping("/admin/action")
fun adminAction(request: HttpServletRequest): ResponseEntity<Any> {
if (!authResolver.hasScope(request, "admin")) {
return ResponseEntity.status(403)
.body(mapOf("error" to "Requires admin scope"))
}
return ResponseEntity.ok(mapOf("result" to "success"))
}
@GetMapping("/resources")
fun getResources(request: HttpServletRequest): ResponseEntity<Any> {
val requiredScopes = setOf("read:resources", "list:resources")
if (!authResolver.hasAnyScope(request, *requiredScopes.toTypedArray())) {
return ResponseEntity.status(403)
.body(mapOf("error" to "Insufficient scopes"))
}
val tenantId = authResolver.resolveTenantId(request)
return ResponseEntity.ok(getResourcesForTenant(tenantId))
}
}
Token Forwarding
Forward tokens to downstream services:
@Service
class DownstreamClient(
private val authResolver: ValidatingAuthenticationResolver,
private val restTemplate: RestTemplate
) {
fun callDownstream(request: HttpServletRequest): String {
val rawToken = authResolver.getRawToken(request)
?: throw UnauthorizedException("No token")
val headers = HttpHeaders().apply {
setBearerAuth(rawToken)
}
val entity = HttpEntity<Void>(headers)
return restTemplate.exchange(
"http://downstream/api/resource",
HttpMethod.GET,
entity,
String::class.java
).body ?: ""
}
}
Multi-Tenant JWT Decoder
The Spring integration includes a multi-tenant decoder that routes to the correct IdP based on token issuer:
class MultiTenantJwtDecoder(
private val defaultIdpConfig: IdpConfig,
private val tenantIdpConfigs: Map<String, IdpConfig>
) : JwtDecoder {
override fun decode(token: String): Jwt {
// Extract issuer from unverified token
val issuer = extractIssuerFromToken(token)
?: throw JwtException("Cannot extract issuer")
// Get or create decoder for this issuer
val decoder = getOrCreateDecoder(issuer)
// Decode and validate
return decoder.decode(token)
}
// Register new IdP at runtime
fun registerIdp(config: IdpConfig)
// Remove IdP
fun removeIdp(issuer: String)
// Clear decoder cache (for key rotation)
fun clearCache()
}
Error Handling
Error Types
enum class JwtValidationErrorType {
MISSING_TOKEN,
INVALID_TOKEN_FORMAT,
SIGNATURE_INVALID,
TOKEN_EXPIRED,
TOKEN_NOT_YET_VALID,
UNTRUSTED_ISSUER,
INVALID_AUDIENCE,
MISSING_REQUIRED_CLAIM,
JWKS_UNAVAILABLE,
KEY_NOT_FOUND,
ALGORITHM_NOT_ALLOWED,
IDP_CONFIGURATION_ERROR,
DISCOVERY_FAILED,
VALIDATION_ERROR
}
Error Response
data class JwtValidationError(
val type: JwtValidationErrorType,
val message: String,
val issuer: String? = null,
val audience: String? = null,
val claim: String? = null,
val cause: String? = null
)
Integration with AuthZEN
Forward validated tokens to the PDP for fine-grained authorization:
@Service
class AuthorizedService(
private val authResolver: ValidatingAuthenticationResolver,
private val policyEngine: PolicyEngine
) {
suspend fun performAction(request: HttpServletRequest, resourceId: String) {
val token = authResolver.requireValidatedToken(request)
// Build policy request with token info
val policyRequest = PolicyRequest(
principal = PolicyPrincipal(
type = "User",
id = token.subject,
attributes = mapOf(
"jwt" to JsonPrimitive(token.rawToken),
"scopes" to JsonArray(token.scopes.map { JsonPrimitive(it) })
)
),
action = PolicyAction(name = "resource.read"),
resource = PolicyResource(type = "Resource", id = resourceId),
context = PolicyContext(
tenantId = token.tenantId,
accessToken = token.rawToken
)
)
val decision = policyEngine.evaluate(policyRequest).getOrThrow()
if (decision.isDenied) {
throw ForbiddenException("Access denied")
}
// Proceed with action
}
}
Security Best Practices
Always validate audience - Configure expected audience to prevent token confusion attacks.
Use short clock skew - Keep clockSkewSeconds minimal (30-60s) for accurate expiration.
Cache JWKS appropriately - Balance between security (shorter TTL) and performance (longer TTL).
Restrict algorithms - Only allow expected algorithms (RS256, ES256) to prevent algorithm confusion.
Validate required claims - Use requiredClaims for critical claims like tenant_id.
Use TLS everywhere - Protect tokens in transit between services.