Skip to content

JWT Integration

CoSec uses the Auth0 java-jwt library to create and verify JSON Web Tokens. The integration is encapsulated in the cosec-jwt module, which provides JwtTokenConverter (issue tokens) and JwtTokenVerifier (verify and extract principals). Spring Boot auto-configuration wires everything together.

Token Lifecycle

Token Validity Defaults

Token TypeDefault ValidityConfigurable Via
Access Token10 minutescosec.jwt.token-validity.access
Refresh Token7 dayscosec.jwt.token-validity.refresh

These defaults are defined in JwtProperties:

kotlin
data class TokenValidity(
    var access: Duration = Duration.ofMinutes(10),
    var refresh: Duration = Duration.ofDays(7)
)

Supported Algorithms

The auto-configuration supports three HMAC algorithms, selected via cosec.jwt.algorithm:

ValueAlgorithmJavadoc
HMAC256 (default)HS256Algorithm.HMAC256(secret)
HMAC384HS384Algorithm.HMAC384(secret)
HMAC512HS512Algorithm.HMAC512(secret)

JWT Claims Structure

JwtTokenConverter builds a JWT with the following claims structure for access tokens:

json
{
  "jti": "<generated-unique-id>",
  "sub": "<principal.id>",
  "iat": 1684000000,
  "exp": 1684000600,
  "policies": ["policy-id-1", "policy-id-2"],
  "roles": ["admin", "user"],
  "attributes": {"key": "value"},
  "tenantId": "tenant-123"
}

Key mappings:

  • sub (Subject): Set to principal.id -- the unique user identifier
  • jti (JWT ID): Generated by an IdGenerator (default: UUID). Used for token revocation (see Token Revocation (Logout)) and refresh token binding
  • policies: PolicyCapable.POLICY_KEY claim -- list of policy IDs assigned to the principal (written only when non-empty)
  • roles: RoleCapable.ROLE_KEY claim -- list of role IDs (written only when non-empty)
  • attributes: CoSecPrincipal::attributes.name claim -- arbitrary key-value metadata (written only when non-empty)
  • tenantId: Tenant.TENANT_ID_KEY claim -- present only when the principal implements TenantCapable

Refresh tokens have a simpler structure:

json
{
  "jti": "<refresh-token-id>",
  "sub": "<access-token-id>",
  "iat": 1684000000,
  "exp": 1685209600
}

The refresh token's sub claim is set to the access token's jti, creating a binding between the two tokens.

Key Classes

JwtTokenConverter

JwtTokenConverter implements TokenConverter and converts a CoSecPrincipal into a CompositeToken:

kotlin
class JwtTokenConverter(
    private val idGenerator: IdGenerator,
    private val algorithm: Algorithm,
    private val accessTokenValidity: Duration = Duration.ofMinutes(10),
    private val refreshTokenValidity: Duration = Duration.ofDays(7)
) : TokenConverter

JwtTokenVerifier

JwtTokenVerifier implements TokenVerifier and provides:

  • verify(AccessToken): Validates signature, checks expiry, extracts TokenPrincipal
  • refresh(CompositeToken): Verifies the refresh token, verifies the access token's signature (its expiry may have passed), ensures its sub matches the access token's jti, then extracts the principal from the (possibly expired) access token

Jwts Utility

Jwts provides helper functions:

  • decode(token): Strips Bearer prefix and decodes the JWT without verification
  • toPrincipal(decodedJWT): Extracts all claims and constructs a TokenPrincipal (or TokenTenantPrincipal when tenantId is present)
  • removeBearerPrefix(): String extension that removes the "Bearer " prefix if present

Architecture Diagrams

Token Creation Flow

mermaid
sequenceDiagram
    autonumber
    participant Auth as TokenCompositeAuthentication
    participant CA as CompositeAuthentication
    participant AP as AuthenticationProvider
    participant Impl as Authentication Impl
    participant Conv as JwtTokenConverter
    participant JWT as JWT.create()

    Auth->>CA: authenticate(credentials)
    CA->>AP: getRequired(credentialsType)
    AP-->>CA: Authentication instance
    CA->>Impl: authenticate(credentials)
    Impl-->>Auth: CoSecPrincipal
    Auth->>Conv: toToken(principal)
    Conv->>JWT: create access token (sub=policies=roles=tenantId)
    JWT-->>Conv: signed access token string
    Conv->>JWT: create refresh token (sub=accessTokenId)
    JWT-->>Conv: signed refresh token string
    Conv-->>Auth: CompositeToken(accessToken, refreshToken)

Token Verification Flow

mermaid
flowchart TD
    A["Incoming AccessToken"] --> B["removeBearerPrefix()"]
    B --> C["jwtVerifier.verify(token)"]
    C --> D{"Verification result"}
    D -->|"TokenExpiredException"| E["throw TokenExpiredException"]
    D -->|"Other Exception"| F["throw TokenVerificationException"]
    D -->|"Valid DecodedJWT"| N{"revoked (jti in TokenStore)?"}
    N -->|"yes"| R["throw TokenRevokedException (401)"]
    N -->|"no"| G["Jwts.toPrincipal(decodedJWT)"]
    G --> H["Extract sub, policies, roles, attributes"]
    H --> I{"tenantId claim present?"}
    I -->|"yes"| J["return TokenTenantPrincipal"]
    I -->|"no"| K["return TokenPrincipal"]

    style A fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style B fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style C fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style D fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style E fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style F fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style G fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style H fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style I fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style J fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style K fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style N fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style R fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

Refresh Token Flow

mermaid
sequenceDiagram
    autonumber
    participant Client
    participant Verifier as JwtTokenVerifier
    participant JWT as JWT.require()
    participant Jwts as Jwts

    Client->>Verifier: refresh(CompositeToken)
    Verifier->>JWT: verify(refreshToken)
    JWT-->>Verifier: DecodedJWT (refresh)
    Verifier->>Jwts: decode(accessToken) -- signature verified next, expiry not re-checked
    Jwts-->>Verifier: DecodedJWT (access, possibly expired)
    Verifier->>Verifier: verifyAccessTokenSignature(accessJWT)
    Verifier->>Verifier: require(refresh.sub == access.jti)
    Verifier->>Jwts: toPrincipal(accessJWT)
    Jwts-->>Verifier: TokenPrincipal
    Verifier-->>Client: TokenPrincipal

Token Revocation (Logout)

JWTs are stateless by default -- deleting the token on the client does not invalidate it on the server. CoSec closes this gap with an opt-in revocation mechanism keyed by the token's jti claim.

What It Does

  • TokenRevoker.revoke(accessToken) verifies the token and records its jti in a revocation store. Call it from your own logout endpoint.
  • A revoked access token is rejected immediately with a 401 -- every verification goes through RevocableTokenVerifier, which checks the store before accepting the token.
  • The refresh token is bound to the access token's jti (its sub claim), so refreshing a revoked token is rejected as well.
  • The revocation entry is kept for the refresh token validity (cosec.jwt.token-validity.refresh), so it never expires before the bound refresh token does.

Logout stays idempotent when the token is already invalid -- catch and ignore the verification failure:

kotlin
@PostMapping("/logout")
fun logout(@RequestHeader(HttpHeaders.AUTHORIZATION) authorization: String): ResponseEntity<Void> {
    try {
        tokenRevoker.revoke(SimpleAccessToken(authorization)) // the verifier strips the Bearer prefix
    } catch (ignored: TokenVerificationException) {
        // token already invalid or expired -- nothing left to revoke
    }
    return ResponseEntity.noContent().build()
}

Note: revoke before the access token expires. An already-expired access token fails verification, and its still-valid bound refresh token can then no longer be killed through TokenRevoker.

How to Enable

yaml
cosec:
  jwt:
    token-revocation:
      enabled: true

This wires the Redis-backed CoCacheTokenStore, which requires the cosec-cocache dependency (the starter's cacheSupport Gradle feature) and a Redis connection:

kotlin
dependencies {
    implementation("me.ahoo.cosec:cosec-spring-boot-starter") {
        capabilities {
            requireCapability("me.ahoo.cosec:cosec-spring-boot-starter-cache-support")
        }
    }
}

TokenStore SPI

Revocation storage is pluggable through the TokenStore SPI:

  • Default NoOp -- stateless and a no-op. Upgrading CoSec changes nothing until you opt in, so the stateless default behavior is preserved.
  • CoCacheTokenStore -- the ready-made Redis-backed implementation (two-level CoCache: local + Redis), wired automatically when the property is enabled and cosec-cocache is on the classpath.
  • Custom -- provide your own TokenStore bean to store revocations anywhere else.

Operational Notes

  • Inject mode: downstream services that inject the security context from gateway headers do not verify the JWT signature, so they cannot check revocation either. Enforcement happens at the verifying edge (the gateway).
  • Propagation: CoCache evicts local entries via Redis pub/sub, so revocation takes effect on all instances near-instantly when the cluster is healthy. In the worst case, propagation is bounded by the local cache TTL (default 30 seconds, configurable via cosec.authorization.cache.token.*).
  • Fail-open on Redis outage: with CoCache's default strictFailure=false, an unreachable Redis makes isRevoked fall back to false (revoked tokens may authenticate again) and revoke writes are dropped. Deployments that prefer fail-closed behavior can set cocache.redis.strict-failure=true.

Spring Boot Auto-Configuration

CoSecJwtAutoConfiguration is activated when:

  1. cosec.enabled=true (default)
  2. cosec.jwt.enabled=true (default)
  3. JwtTokenConverter is on the classpath

It registers five beans:

BeanTypePurpose
cosecTokenAlgorithmAlgorithmHMAC algorithm from config
cosecTokenConverterTokenConverterCreates JWT tokens
cosecTokenStoreTokenStoreRevocation store (NoOp unless the cache-backed one is wired, see Token Revocation (Logout))
cosecJwtTokenVerifierTokenVerifierVerifies JWT tokens and rejects revoked ones
cosecTokenRevokerTokenRevokerRevokes tokens for logout

When authentication is also enabled, it additionally registers TokenCompositeAuthentication which chains credential-based authentication with token issuance.

Configuration Example

yaml
cosec:
  jwt:
    enabled: true
    algorithm: HMAC256
    secret: your-secret-key-must-be-long-enough
    token-validity:
      access: 10m
      refresh: 7d
    token-revocation:
      enabled: false # opt-in logout; true wires the Redis-backed CoCacheTokenStore

The revocation local cache is tuned under cosec.authorization.cache.token.* (default: 30s expire-after-write, 100k entries -- also the worst-case logout propagation window across instances).

References

Licensed under the Apache License, Version 2.0.