Skip to main content
Version: v0.25.0 (Latest)

Authorization Server

The IDK provides AuthorizationServerService, a command-based service for building standards-compliant OAuth 2.0 and OpenID Connect authorization servers. Rather than a monolithic builder, each operation (parsing requests, verifying grants, creating tokens) is a discrete command that you compose into your own endpoint handlers.

A consistent pattern runs through the entire API: parse, verify, create. You parse raw HTTP input into a typed request object, verify it (checking client authentication, scopes, PKCE, etc.), then create the output (tokens, authorization codes, responses). The steps are deliberately separate so you can insert your own logic between them. Need to authenticate the user after parsing but before creating a code? Need to log an audit event after verification? Need to check a custom policy before issuing a token? The gaps between commands are where your application-specific behavior lives.

Supported Standards

StandardDescription
RFC 6749OAuth 2.0 Authorization Framework
RFC 7636Proof Key for Code Exchange (PKCE)
RFC 8693OAuth 2.0 Token Exchange
RFC 9449OAuth 2.0 Demonstrating Proof of Possession (DPoP)
RFC 9126OAuth 2.0 Pushed Authorization Requests (PAR)
RFC 7662OAuth 2.0 Token Introspection
RFC 7009OAuth 2.0 Token Revocation
RFC 8414OAuth 2.0 Authorization Server Metadata
OpenID Connect CoreID Tokens, UserInfo, JWKS
draft-ietf-oauth-attestation-based-client-authAttestation-based client authentication

Getting the Service

The AuthorizationServerService is available on the session graph:

val authServer = session.graph.authorizationServerService

All methods on AuthorizationServerService are suspending functions that return IdkResult. This means every call either succeeds with a typed value or fails with a structured error. There are no thrown exceptions to catch during normal operation.

Grant Types

The service supports five grant types. Each has its own verification command so you can apply grant-specific validation logic.

Grant TypeRFCDescription
AUTHORIZATION_CODERFC 6749 Section 4.1Standard authorization code flow with optional PKCE
REFRESH_TOKENRFC 6749 Section 6Exchanging a refresh token for new access/refresh tokens
CLIENT_CREDENTIALSRFC 6749 Section 4.4Machine-to-machine authentication with no user context
TOKEN_EXCHANGERFC 8693Exchanging one token for another with delegation or impersonation semantics
PRE_AUTHORIZED_CODEOpenID4VCIPre-authorized code flow for credential issuance

Authorization Code Flow

The authorization code flow is the standard browser-based OAuth 2.0 flow. A user authenticates and grants consent in their browser, and the client application receives a short-lived authorization code that it exchanges for tokens via a back-channel request. It is the most complex flow because it involves the most steps, but the IDK breaks each step into a separate command.

The sequence below is split into distinct commands: parse the incoming request, verify it, create a session, and generate the code. This gives you full control over where authentication and consent happen in between.

val authServer = session.graph.authorizationServerService

// 1. Parse the incoming authorization request (takes query parameters as a map)
val queryParameters: Map<String, String> = extractQueryParameters(rawHttpRequest)
val parseResult = authServer.parseAuthorizationRequest(queryParameters)
if (parseResult.isErr) {
return errorResponse(parseResult.error)
}
val requestData = parseResult.value

// 2. Verify the parsed request (validates client, redirect URI, scopes, PKCE)
val verifyResult = authServer.verifyAuthorizationRequest(requestData)
if (verifyResult.isErr) {
return errorResponse(verifyResult.error)
}
val verifiedRequest = verifyResult.value

// 3. Authenticate the user and collect consent (your application logic)
val user = authenticateUser()
val approvedScopes = collectConsent(user, verifiedRequest)

// 4. Create an authorization session to track state
val sessionResult = authServer.createAuthorizationSession(request = verifiedRequest)
val authSession = sessionResult.value

// 5. Generate the authorization code
val codeResult = authServer.createAuthorizationCode(
session = authSession,
userId = user.id,
consent = ConsentDecision(approvedScopes)
)
val code = codeResult.value

// 6. Build the redirect response
val responseResult = authServer.createAuthorizationResponse(
code = code,
state = requestData.state,
redirectUri = requestData.redirectUri
)
val response = responseResult.value

Token Endpoint

The token endpoint is where clients exchange grants (authorization codes, refresh tokens, client credentials) for access tokens. In your HTTP server, you will typically expose a single /token route that handles all grant types.

The token endpoint follows the same parse-then-verify pattern. You parse the incoming request once, then branch by grant type. This parse-once-then-branch approach means one HTTP handler and one parsing call, with grant-specific verification and token creation inside each branch:

val authServer = session.graph.authorizationServerService

// Parse the token request (takes form parameters as a map)
val parameters: Map<String, String> = extractFormParameters(rawHttpRequest)
val parseResult = authServer.parseTokenRequest(parameters)
if (parseResult.isErr) {
return errorResponse(parseResult.error)
}
val tokenRequest = parseResult.value

// Branch on grant type
when (tokenRequest.grantType) {
GrantType.AUTHORIZATION_CODE -> {
val verifyResult = authServer.verifyAuthorizationCodeGrant(
code = tokenRequest.code,
redirectUri = tokenRequest.redirectUri,
clientId = tokenRequest.clientId,
codeVerifier = tokenRequest.codeVerifier
)
if (verifyResult.isErr) {
return errorResponse(verifyResult.error)
}
val grant = verifyResult.value

// Create tokens
val accessToken = authServer.createAccessToken(
subject = grant.subject, clientId = grant.clientId, scope = grant.scope
).value
val refreshToken = authServer.createRefreshToken(
subject = grant.subject, clientId = grant.clientId, scope = grant.scope
).value

// Build the token response
val response = authServer.createTokenResponse(
accessToken = accessToken,
refreshToken = refreshToken,
scope = grant.scope
)
return jsonResponse(response.value)
}

GrantType.REFRESH_TOKEN -> {
val verifyResult = authServer.verifyRefreshTokenGrant(
refreshToken = tokenRequest.refreshToken,
clientId = tokenRequest.clientId
)
val grant = verifyResult.value

val accessToken = authServer.createAccessToken(
subject = grant.subject, clientId = grant.clientId, scope = grant.scope
).value
val refreshToken = authServer.createRefreshToken(
subject = grant.subject, clientId = grant.clientId, scope = grant.scope
).value
val response = authServer.createTokenResponse(
accessToken = accessToken,
refreshToken = refreshToken,
scope = grant.scope
)
return jsonResponse(response.value)
}

GrantType.CLIENT_CREDENTIALS -> {
val verifyResult = authServer.verifyClientCredentialsGrant(
clientId = tokenRequest.clientId,
requestedScope = tokenRequest.scope
)
val grant = verifyResult.value

val accessToken = authServer.createAccessToken(
subject = grant.subject, clientId = grant.clientId, scope = grant.scope
).value
val response = authServer.createTokenResponse(
accessToken = accessToken,
scope = grant.scope
)
return jsonResponse(response.value)
}

GrantType.TOKEN_EXCHANGE -> {
val verifyResult = authServer.verifyTokenExchangeGrant(
request = tokenRequest.toTokenExchangeRequest()
)
val grant = verifyResult.value

val accessToken = authServer.createAccessToken(
subject = grant.subject, clientId = grant.clientId, scope = grant.scope
).value
val response = authServer.createTokenResponse(
accessToken = accessToken,
scope = grant.scope
)
return jsonResponse(response.value)
}

else -> return errorResponse("unsupported_grant_type")
}

Client Credentials

The client credentials grant is the simplest OAuth 2.0 flow: no user, no browser, no consent screen. The client authenticates directly with the authorization server and receives an access token. This is the typical choice for service-to-service communication.

The code below is a focused example of this grant type in isolation. The pattern is the same as the CLIENT_CREDENTIALS branch in the token endpoint section above, just shown end-to-end for clarity:

val authServer = session.graph.authorizationServerService

val parameters: Map<String, String> = extractFormParameters(rawHttpRequest)
val parseResult = authServer.parseTokenRequest(parameters)
val tokenRequest = parseResult.value

val verifyResult = authServer.verifyClientCredentialsGrant(
clientId = tokenRequest.clientId,
requestedScope = tokenRequest.scope
)
if (verifyResult.isOk) {
val grant = verifyResult.value
// grant.clientId -- the authenticated client
// grant.scope -- the granted scopes

val accessToken = authServer.createAccessToken(
subject = grant.subject, clientId = grant.clientId, scope = grant.scope
).value
val response = authServer.createTokenResponse(
accessToken = accessToken,
scope = grant.scope
).value
// response.accessToken, response.expiresIn, response.tokenType
} else {
// Client authentication failed or scopes were invalid
}

Token Exchange (RFC 8693)

Token exchange solves a common problem in microservice architectures: Service A receives a request from a user but needs to call Service B on the user's behalf. Rather than forwarding the user's original token (which may have too-broad scopes or the wrong audience), Service A exchanges its token for a new one scoped specifically for Service B. The exchange can operate in two modes: delegation, where the resulting token preserves the chain of who acted on behalf of whom, or impersonation, where the resulting token is indistinguishable from one the user obtained directly.

Token exchange allows a client to exchange one security token for another. This is commonly used for delegation (acting on behalf of a user) and impersonation (acting as a user).

Delegation vs. Impersonation

Delegation means the issued token carries both the original subject and the actor performing the action. The actor claim (act) identifies who is acting on behalf of whom. This preserves an audit trail.

Impersonation means the issued token looks exactly like the original subject's token. The resource server cannot distinguish it from a token the subject obtained directly. Use impersonation only when delegation is not feasible.

Token Exchange Parameters

The token exchange grant uses these parameters:

ParameterDescription
subjectTokenThe token representing the subject of the exchange
subjectTokenTypeType identifier for the subject token
actorTokenOptional token representing the actor (for delegation)
actorTokenTypeType identifier for the actor token
resourcesTarget resources for the new token
audiencesTarget audiences for the new token
scopeRequested scope for the new token
requestedTokenTypeDesired type of the issued token

Token type identifiers are defined as constants on TokenTypeIdentifier:

ConstantURI
ACCESS_TOKENurn:ietf:params:oauth:token-type:access_token
REFRESH_TOKENurn:ietf:params:oauth:token-type:refresh_token
ID_TOKENurn:ietf:params:oauth:token-type:id_token
SAML1urn:ietf:params:oauth:token-type:saml1
SAML2urn:ietf:params:oauth:token-type:saml2
JWTurn:ietf:params:oauth:token-type:jwt

Performing a Token Exchange

val authServer = session.graph.authorizationServerService

// Parse the incoming token exchange request
val parameters: Map<String, String> = extractFormParameters(rawHttpRequest)
val parseResult = authServer.parseTokenRequest(parameters)
val tokenRequest = parseResult.value

// Verify the token exchange grant
// This validates the subject token, optional actor token, and applies policy
val verifyResult = authServer.verifyTokenExchangeGrant(
request = tokenRequest.toTokenExchangeRequest()
)
if (verifyResult.isOk) {
val grant = verifyResult.value
// grant.subject -- the original subject identity
// grant.clientId -- the requesting client
// grant.scope -- granted scopes (may be narrowed by policy)
// grant.audience -- granted audiences
// grant.resource -- target resources
// grant.issuedTokenType -- the type of token to issue
// grant.isDelegation -- true for delegation, false for impersonation
// grant.actorSubject -- the actor identity (delegation only)
// grant.actorClaim -- the nested actor claim chain

val accessToken = authServer.createAccessToken(
subject = grant.subject, clientId = grant.clientId, scope = grant.scope
).value
val response = authServer.createTokenResponse(
accessToken = accessToken,
scope = grant.scope
).value
} else {
// Token exchange denied by policy or validation failed
}

Actor Claims

The act claim creates an audit trail for delegation. In a delegation chain, each hop adds a nested act claim so you can trace exactly who acted on behalf of whom. If a gateway calls Service A, which calls Service B on behalf of a user, the token that Service B receives will contain a nested chain: the user as the subject, Service A as the actor, and the gateway nested inside Service A's actor claim.

When delegation is used, the issued token contains an act claim that records the delegation chain. The ActorClaim model supports nested delegation:

{
"sub": "user-123",
"act": {
"sub": "service-A",
"act": {
"sub": "gateway-B"
}
}
}

Each ActorClaim has:

  • sub: the actor's subject identifier
  • act: an optional nested ActorClaim for multi-hop delegation chains
  • additionalClaims: any extra claims about the actor

Token Exchange Policy

Not every client should be allowed to perform token exchanges, and not every exchange should produce the same type of token. The TokenExchangePolicy interface gives you per-request control over whether to allow the exchange, whether to use delegation or impersonation, and what scopes and audiences the resulting token should carry. You can base these decisions on the requesting client, the subject token's claims, the target audience, or any other context available in the request.

You control which exchanges are allowed by implementing TokenExchangePolicy:

class MyTokenExchangePolicy : TokenExchangePolicy {
override suspend fun evaluate(
request: TokenExchangeRequest
): TokenExchangePolicyDecision {
// Example: only allow delegation for trusted clients
if (request.clientId !in trustedClients) {
return TokenExchangePolicyDecision(
allowed = false,
denyReason = "Client not authorized for token exchange"
)
}

return TokenExchangePolicyDecision(
allowed = true,
isDelegation = true, // issue delegation token with act claim
issuedTokenType = TokenTypeIdentifier.ACCESS_TOKEN,
grantedScope = request.scope,
grantedAudience = request.audiences
)
}
}

The TokenExchangePolicyDecision fields:

FieldDescription
allowedWhether the exchange is permitted
isDelegationtrue for delegation (includes act claim), false for impersonation
issuedTokenTypeThe type of token to issue
grantedScopeThe scopes to include in the issued token (may be narrower than requested)
grantedAudienceThe audiences to include in the issued token
denyReasonHuman-readable reason when allowed is false

OpenID Connect

OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0. When a client includes the openid scope in its authorization request, the authorization server issues an ID token alongside the access token. The ID token is a signed JWT containing claims about the authenticated user (subject identifier, name, email, authentication time, etc.). While the access token is opaque to the client and meant for calling APIs, the ID token is meant for the client itself to learn who the user is.

ID Token Creation

When the openid scope is requested, create an ID token alongside the access token:

val authServer = session.graph.authorizationServerService

// After verifying the authorization code grant
val grant = authServer.verifyAuthorizationCodeGrant(
code = tokenRequest.code,
redirectUri = tokenRequest.redirectUri,
clientId = tokenRequest.clientId,
codeVerifier = tokenRequest.codeVerifier
).value

val accessToken = authServer.createAccessToken(
subject = grant.subject, clientId = grant.clientId, scope = grant.scope
).value
val refreshToken = authServer.createRefreshToken(
subject = grant.subject, clientId = grant.clientId, scope = grant.scope
).value

// Create an ID token if the openid scope was granted
val idToken = if ("openid" in grant.scopes) {
authServer.createIdToken(grant).value
} else null

val response = authServer.createTokenResponse(
accessToken = accessToken,
refreshToken = refreshToken,
scope = grant.scope
).value

UserInfo Endpoint

The getUserInfo command returns claims about the authenticated user based on the granted scopes:

val authServer = session.graph.authorizationServerService

val userInfoResult = authServer.getUserInfo(accessToken)
if (userInfoResult.isOk) {
val userInfo = userInfoResult.value
// userInfo contains claims based on granted scopes:
// sub, name, email, email_verified, etc.
} else {
// Invalid or expired token
}

JWKS Endpoint

Serve the JSON Web Key Set so clients and resource servers can verify token signatures:

val authServer = session.graph.authorizationServerService

val jwksResult = authServer.getJwks()
if (jwksResult.isOk) {
val jwks = jwksResult.value
// Serve as JSON at /.well-known/jwks.json
} else {
// Handle error
}

Pushed Authorization Requests (RFC 9126)

In a standard authorization request, all parameters (client ID, scopes, redirect URI, PKCE challenge) go into the browser's URL. This creates two problems: URLs have length limits, and the parameters are visible to (and modifiable by) anything in the browser's network path. Pushed Authorization Requests (PAR) solve both. The client sends the authorization request parameters directly to the server via a back-channel POST and gets back an opaque request_uri. The browser redirect then carries only this request_uri, keeping the actual parameters server-side.

PAR allows clients to push the authorization request payload directly to the authorization server and receive a request_uri in return. The client then redirects the user-agent with only the request_uri, keeping sensitive parameters off the front-channel.

val authServer = session.graph.authorizationServerService

// 1. Client pushes the authorization request to the PAR endpoint
val parseResult = authServer.parsePushedAuthorizationRequest(rawHttpRequest)
val requestData = parseResult.value

// 2. Verify the pushed request (client authentication, parameters)
val verifyResult = authServer.verifyPushedAuthorizationRequest(requestData)
val verifiedRequest = verifyResult.value

// 3. Create a request URI that references the stored request
val requestUriResult = authServer.createRequestUri(verifiedRequest)
val requestUriData = requestUriResult.value

// 4. Build the PAR response (returns the request_uri and expires_in)
val parResponse = authServer.createPushedAuthorizationResponse(requestUriData).value
// parResponse.requestUri -- e.g. "urn:ietf:params:oauth:request_uri:abc123"
// parResponse.expiresIn -- seconds until the request_uri expires
tip

Configure requirePushedAuthorizationRequests = true on the ClientRegistration to enforce PAR for specific clients. This is recommended for high-security scenarios where authorization parameters must not be exposed in the browser URL bar.

Introspection and Revocation

Token Introspection (RFC 7662)

Introspection lets a resource server check whether a token is still valid without having to decode or verify it locally. The resource server sends the token to your authorization server's introspection endpoint and receives back a JSON object with the token's active/inactive status and its associated metadata (scopes, subject, client, expiration). This is particularly useful for opaque tokens that the resource server cannot decode on its own, or when you need a central point of revocation checking.

Token introspection lets resource servers check whether an access token is active and retrieve its associated metadata:

val authServer = session.graph.authorizationServerService

// Parse the introspection request (includes client authentication)
val parseResult = authServer.parseIntrospectionRequest(rawHttpRequest)
val introspectionRequest = parseResult.value

// Introspect the token
val result = authServer.introspectToken(introspectionRequest)
if (result.isOk) {
val response = result.value
// response.active -- whether the token is currently valid
// response.scope -- the scopes associated with the token
// response.clientId -- the client that requested the token
// response.sub -- the subject (user) of the token
// response.exp -- expiration timestamp
} else {
// Introspection request itself was invalid
}

Token Revocation (RFC 7009)

Revocation lets clients proactively invalidate tokens they no longer need, for example when a user logs out or when a refresh token should be discarded. Per RFC 7009, the revocation endpoint always returns HTTP 200 OK regardless of whether the token existed or was already invalid. This is intentional: returning different status codes for valid vs. invalid tokens would let an attacker probe for token existence.

Allow clients to revoke tokens they no longer need:

val authServer = session.graph.authorizationServerService

val parseResult = authServer.parseRevocationRequest(rawHttpRequest)
val revocationRequest = parseResult.value

val result = authServer.revokeToken(revocationRequest)
if (result.isOk) {
// Token revoked successfully. Per RFC 7009, always return 200 OK
// even if the token was already invalid.
} else {
// Client authentication failed
}

Client Management

Before a client can use your authorization server, it needs to be registered. Registration defines what the client is allowed to do: which grant types it can use, which scopes it can request, where it can redirect users, and how it authenticates. The IDK supports two approaches. You can define clients declaratively in YAML or environment variables (good for known, fixed clients like your own web app), or you can register them programmatically at runtime through the ClientRegistry API (good for dynamic registration or multi-tenant scenarios).

Clients are represented by the ClientRegistration model, which captures all OAuth 2.0 and OpenID Connect client metadata.

ClientRegistration Model

Key fields on ClientRegistration:

FieldDescription
clientIdUnique client identifier
clientSecretClient secret (confidential clients only)
clientNameHuman-readable client name
clientTypeCONFIDENTIAL or PUBLIC
grantTypesAllowed grant types (e.g., AUTHORIZATION_CODE, CLIENT_CREDENTIALS)
responseTypesAllowed response types (e.g., code)
redirectUrisRegistered redirect URIs
allowedScopesScopes the client is permitted to request
tokenEndpointAuthMethodHow the client authenticates at the token endpoint
jwks / jwksUriClient public keys for private_key_jwt or verification
requirePkceWhether PKCE is mandatory for this client
requirePushedAuthorizationRequestsWhether PAR is mandatory
dpopBoundAccessTokensWhether access tokens must be DPoP-bound
accessTokenLifetimeOverride for access token TTL
refreshTokenLifetimeOverride for refresh token TTL
authorizationCodeLifetimeOverride for authorization code TTL

Client Authentication Methods

The service supports the following token endpoint authentication methods:

MethodDescription
CLIENT_SECRET_BASICClient ID and secret via HTTP Basic authentication
CLIENT_SECRET_POSTClient ID and secret in the request body
CLIENT_SECRET_JWTClient assertion signed with the client secret (HMAC)
PRIVATE_KEY_JWTClient assertion signed with the client's private key
ATTESTATION_JWTAttestation-based client authentication (see below)
NONENo client authentication (public clients)

Attestation-Based Client Authentication

For mobile and native applications, attestation-based authentication uses platform attestations (e.g., Android Key Attestation, Apple App Attest) instead of shared secrets. Configure trusted attesters on the client registration:

FieldDescription
trustedAttesterIssuersAccepted issuers of attestation JWTs
trustedAttesterJwksUrisJWKS URIs for verifying attestation signatures

ClientRegistry Interface

The ClientRegistry is the storage interface for managing client registrations:

interface ClientRegistry {
suspend fun getClient(clientId: String): ClientRegistration?
suspend fun registerClient(registration: ClientRegistration): ClientRegistration
suspend fun updateClient(registration: ClientRegistration): ClientRegistration
suspend fun deleteClient(clientId: String)
suspend fun verifyClientCredentials(clientId: String, clientSecret: String): Boolean
}

You can provide your own implementation backed by a database or any other source. However, for most deployments the IDK's built-in ConfigAwareClientRegistry is sufficient. It combines configuration-driven clients with programmatic runtime registration.

Configuration-Driven Client Registration

The IDK ships with a ConfigAwareClientRegistry that reads client definitions from the IDK's configuration system (YAML, environment variables, or settings store). This lets you define OAuth 2.0 clients declaratively without writing registration code.

Configuration-driven clients are immutable at runtime: they cannot be updated or deleted through the ClientRegistry API. This prevents accidental modification of critical client configurations. Programmatically registered clients can still be added alongside them and are fully mutable.

Configuration Namespace

Client definitions live under the oauth2.clients namespace. Each client is defined under a user-chosen key:

oauth2.clients.{key}.{property}

The key (e.g., portal, mobile-app) is for organizational purposes only; the actual client identifier comes from the client-id property.

Client Properties

PropertyTypeDefaultDescription
client-idStringrequiredUnique OAuth 2.0 client identifier
client-secretString?nullClient secret (omit for public clients)
client-nameString?nullHuman-readable display name
client-typeStringCONFIDENTIALCONFIDENTIAL or PUBLIC
grant-typesListrequiredAllowed grant types
response-typesListauto-inferredResponse types (defaults to code for authorization code grants)
redirect-urisList[]Authorized redirect URIs
allowed-scopesList?nullPermitted scopes (null = all scopes allowed)
token-endpoint-auth-methodStringper client typeCLIENT_SECRET_BASIC, CLIENT_SECRET_POST, CLIENT_SECRET_JWT, PRIVATE_KEY_JWT, NONE
require-pkceBooleantrue for PUBLICWhether PKCE is mandatory
require-pushed-authorization-requestsBooleanfalseEnforce PAR (RFC 9126)
dpop-bound-access-tokensBooleanfalseRequire DPoP-bound tokens (RFC 9449)
access-token-lifetimeInt3600Access token lifetime in seconds
refresh-token-lifetimeInt?nullRefresh token lifetime (null = no expiry)
authorization-code-lifetimeInt600Authorization code lifetime in seconds
trusted-attester-issuersList?nullTrusted attestation JWT issuers
trusted-attester-jwks-urisMap?nullJWKS URIs for attestation verification, keyed by issuer
enabledBooleantrueWhether the client is active

Sensible defaults are applied automatically: public clients default to NONE authentication and require PKCE, confidential clients default to CLIENT_SECRET_BASIC, and response types are inferred from the grant types.

Example: Web Application

oauth2:
clients:
"[portal]":
client-id: portal-web
client-secret: ${PORTAL_CLIENT_SECRET}
client-name: Portal Web Application
client-type: CONFIDENTIAL
grant-types:
- authorization_code
- refresh_token
redirect-uris:
- https://portal.example.com/callback
- https://portal.example.com/auth/callback
allowed-scopes:
- openid
- profile
- email
require-pkce: true
access-token-lifetime: 3600
refresh-token-lifetime: 86400

Example: Machine-to-Machine Service

oauth2:
clients:
"[backend-service]":
client-id: api-service
client-secret: ${API_SERVICE_CLIENT_SECRET}
client-name: Backend API Service
client-type: CONFIDENTIAL
grant-types:
- client_credentials
allowed-scopes:
- read
- write
- admin
token-endpoint-auth-method: CLIENT_SECRET_POST
access-token-lifetime: 1800

Example: Mobile Application (Public Client)

oauth2:
clients:
"[mobile-app]":
client-id: com.example.mobile
client-name: Example Mobile App
client-type: PUBLIC
grant-types:
- authorization_code
- refresh_token
redirect-uris:
- com.example.mobile://oauth2/callback
allowed-scopes:
- openid
- profile
token-endpoint-auth-method: NONE
require-pkce: true

Example: OID4VCI Credential Issuance Client

For wallets that obtain credentials via OID4VCI, register a client with the pre_authorized_code grant:

oauth2:
clients:
"[wallet]":
client-id: wallet-app
client-name: Credential Wallet
client-type: PUBLIC
grant-types:
- pre_authorized_code
- authorization_code
redirect-uris:
- walletapp://callback
token-endpoint-auth-method: NONE
require-pkce: true

Using Environment Variables

The same configuration can be provided via environment variables. Property names are uppercased, dots become underscores, and hyphens become underscores:

OAUTH2_CLIENTS_PORTAL_CLIENT_ID=portal-web
OAUTH2_CLIENTS_PORTAL_CLIENT_SECRET=<secret> # Use a secrets manager in production
OAUTH2_CLIENTS_PORTAL_GRANT_TYPES=authorization_code,refresh_token
OAUTH2_CLIENTS_PORTAL_REDIRECT_URIS=https://portal.example.com/callback
OAUTH2_CLIENTS_PORTAL_ALLOWED_SCOPES=openid,profile,email

List values can be provided as comma-separated strings or with indexed keys (REDIRECT_URIS_0, REDIRECT_URIS_1).

How It Works

The ConfigAwareClientRegistry merges two sources at runtime:

  1. Configuration-driven clients: loaded from YAML/environment variables via OAuth2ClientsConfigBinder at session startup. These are immutable.
  2. Programmatic clients: registered at runtime through registerClient(). These are fully mutable.

When looking up a client, the registry searches both sources. Configuration-driven clients take precedence: attempting to register a client with the same ID programmatically will fail, so declarative clients cannot be accidentally overridden.

Client credential verification uses constant-time comparison to prevent timing attacks.

Storage Interfaces

The authorization server is stateful. It needs to persist authorization codes, access tokens, refresh tokens, sessions, and nonces across requests. The IDK defines the storage interfaces; you provide the implementations backed by whatever data store fits your architecture (PostgreSQL, Redis, DynamoDB, in-memory for testing, etc.). Pay close attention to the atomicity requirements on certain operations. For example, consuming an authorization code must be atomic to prevent replay attacks. These constraints are called out in the interface documentation below.

The authorization server requires several storage backends. You implement these interfaces to connect to your chosen data store.

AuthorizationCodeStorage

Stores and retrieves authorization codes. The consumeAuthorizationCode operation must be atomic: if two requests try to consume the same code concurrently, only one must succeed. This prevents authorization code replay attacks (RFC 6749 Section 10.5).

interface AuthorizationCodeStorage {
suspend fun storeAuthorizationCode(code: String, data: AuthorizationCodeData)
suspend fun consumeAuthorizationCode(code: String): AuthorizationCodeData? // Atomic get-and-delete
suspend fun isCodeUsed(code: String): Boolean
}
warning

The atomicity of consumeAuthorizationCode is a security requirement. If your storage backend does not support atomic operations natively, use a distributed lock or compare-and-swap mechanism.

TokenStorage

Manages the lifecycle of access tokens and refresh tokens:

interface TokenStorage {
// Access tokens
suspend fun storeAccessToken(token: String, data: AccessTokenData)
suspend fun getAccessToken(token: String): AccessTokenData?
suspend fun revokeAccessToken(token: String)

// Refresh tokens
suspend fun storeRefreshToken(token: String, data: RefreshTokenData)
suspend fun getRefreshToken(token: String): RefreshTokenData?
suspend fun revokeRefreshToken(token: String)

// Batch revocation (e.g., revoke all tokens for a user or client)
suspend fun revokeBySubject(subject: String)
suspend fun revokeByClientId(clientId: String)
}

SessionStorage

Tracks authorization sessions across the authorization code flow:

interface SessionStorage {
suspend fun createSession(session: AuthorizationSession): AuthorizationSession
suspend fun getSession(sessionId: String): AuthorizationSession?
suspend fun updateSession(session: AuthorizationSession): AuthorizationSession
suspend fun deleteSession(sessionId: String)
}

Additional Storage

  • NonceStorage: Stores and validates nonces for replay protection.
  • AttestationChallengeStorage: Stores challenges for attestation-based client authentication flows.

The authorization server intentionally does not handle authentication or consent UI itself. How you authenticate users (passwords, passkeys, federated login, biometrics) and how you collect consent (a full-page form, a modal, an always-approve policy for first-party apps) are application decisions that vary widely. The IDK delegates both concerns to your application through two provider interfaces. This separation keeps the OAuth 2.0 protocol logic clean and lets you plug in whatever identity provider or consent mechanism your application needs.

The authorization server delegates user authentication and consent to your application through two provider interfaces.

UserAuthenticationProvider

Implement this to connect to your identity provider or user store:

interface UserAuthenticationProvider {
suspend fun getAuthenticatedUser(context: AuthenticationContext): AuthenticatedUser?
suspend fun initiateAuthentication(context: AuthenticationContext): AuthenticationPrompt
suspend fun authenticateWithCredentials(credentials: UserCredentials): AuthenticatedUser?
suspend fun getUserInfo(subject: String, scopes: Set<String>): UserInfoResponse
}

The authorization server calls getAuthenticatedUser to check if the user is already authenticated (e.g., via an existing session cookie). If not, it calls initiateAuthentication to get a login prompt that your application renders. The getUserInfo method provides claims for the UserInfo endpoint and ID tokens.

ConsentProvider

Implement this to manage user consent for scope grants:

interface ConsentProvider {
suspend fun getExistingConsent(subject: String, clientId: String): ConsentRecord?
suspend fun isConsentRequired(subject: String, clientId: String, scopes: Set<String>): Boolean
suspend fun createConsentPrompt(subject: String, clientId: String, scopes: Set<String>): ConsentPrompt
suspend fun storeConsent(subject: String, clientId: String, approvedScopes: Set<String>)
}

If getExistingConsent returns a record covering all requested scopes, the authorization flow can skip the consent screen. Otherwise, createConsentPrompt generates the data your UI needs to display a consent dialog, and storeConsent persists the user's decision.

Discovery

OAuth 2.0 clients and resource servers need to know your authorization server's endpoints, supported grant types, signing algorithms, and other capabilities. Rather than hardcoding these details into every client, RFC 8414 defines a metadata document that clients can fetch from a well-known URL. The IDK generates this document automatically from your server's configuration, so it always reflects the actual capabilities you have enabled.

Authorization Server Metadata (RFC 8414)

The buildServerMetadata command generates the metadata document for /.well-known/oauth-authorization-server:

val authServer = session.graph.authorizationServerService

val metadataResult = authServer.buildServerMetadata()
if (metadataResult.isOk) {
val metadata = metadataResult.value
// Serve as JSON at /.well-known/oauth-authorization-server
} else {
// Configuration error
}

Example metadata response:

{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"introspection_endpoint": "https://auth.example.com/introspect",
"revocation_endpoint": "https://auth.example.com/revoke",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
"pushed_authorization_request_endpoint": "https://auth.example.com/par",
"userinfo_endpoint": "https://auth.example.com/userinfo",
"scopes_supported": ["openid", "profile", "email", "offline_access"],
"response_types_supported": ["code"],
"grant_types_supported": [
"authorization_code",
"refresh_token",
"client_credentials",
"urn:ietf:params:oauth:grant-type:token-exchange",
"urn:ietf:params:oauth:grant-type:pre-authorized_code"
],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"none"
],
"code_challenge_methods_supported": ["S256"],
"dpop_signing_alg_values_supported": ["ES256", "ES384"]
}

Module Dependencies

Add the authorization server modules to your build:

build.gradle.kts
dependencies {
implementation("com.sphereon.idk:lib-oauth2-authorization-server-api:0.25.0")
implementation("com.sphereon.idk:lib-oauth2-authorization-server-impl:0.25.0")
}

If you are using the lib-all dependency, these modules are already included.