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
| Standard | Description |
|---|---|
| RFC 6749 | OAuth 2.0 Authorization Framework |
| RFC 7636 | Proof Key for Code Exchange (PKCE) |
| RFC 8693 | OAuth 2.0 Token Exchange |
| RFC 9449 | OAuth 2.0 Demonstrating Proof of Possession (DPoP) |
| RFC 9126 | OAuth 2.0 Pushed Authorization Requests (PAR) |
| RFC 7662 | OAuth 2.0 Token Introspection |
| RFC 7009 | OAuth 2.0 Token Revocation |
| RFC 8414 | OAuth 2.0 Authorization Server Metadata |
| OpenID Connect Core | ID Tokens, UserInfo, JWKS |
| draft-ietf-oauth-attestation-based-client-auth | Attestation-based client authentication |
Getting the Service
The AuthorizationServerService is available on the session graph:
- Android/Kotlin
- iOS/Swift
val authServer = session.graph.authorizationServerService
let 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 Type | RFC | Description |
|---|---|---|
AUTHORIZATION_CODE | RFC 6749 Section 4.1 | Standard authorization code flow with optional PKCE |
REFRESH_TOKEN | RFC 6749 Section 6 | Exchanging a refresh token for new access/refresh tokens |
CLIENT_CREDENTIALS | RFC 6749 Section 4.4 | Machine-to-machine authentication with no user context |
TOKEN_EXCHANGE | RFC 8693 | Exchanging one token for another with delegation or impersonation semantics |
PRE_AUTHORIZED_CODE | OpenID4VCI | Pre-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.
- Android/Kotlin
- iOS/Swift
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
let authServer = session.graph.authorizationServerService
// 1. Parse the incoming authorization request (takes query parameters as a dictionary)
let queryParameters: [String: String] = extractQueryParameters(rawHttpRequest)
let parseResult = try await authServer.parseAuthorizationRequest(queryParameters: queryParameters)
guard parseResult.isOk else {
return errorResponse(error: parseResult.error)
}
let requestData = parseResult.value
// 2. Verify the parsed request (validates client, redirect URI, scopes, PKCE)
let verifyResult = try await authServer.verifyAuthorizationRequest(request: requestData)
guard verifyResult.isOk else {
return errorResponse(error: verifyResult.error)
}
let verifiedRequest = verifyResult.value
// 3. Authenticate the user and collect consent (your application logic)
let user = authenticateUser()
let approvedScopes = collectConsent(user: user, request: verifiedRequest)
// 4. Create an authorization session to track state
let sessionResult = try await authServer.createAuthorizationSession(
request: verifiedRequest
)
let authSession = sessionResult.value
// 5. Generate the authorization code
let codeResult = try await authServer.createAuthorizationCode(
session: authSession,
userId: user.id,
consent: ConsentDecision(approvedScopes)
)
let code = codeResult.value
// 6. Build the redirect response
let responseResult = try await authServer.createAuthorizationResponse(
code: code,
state: requestData.state,
redirectUri: requestData.redirectUri
)
let 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:
- Android/Kotlin
- iOS/Swift
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")
}
let authServer = session.graph.authorizationServerService
// Parse the token request (takes form parameters as a dictionary)
let parameters: [String: String] = extractFormParameters(rawHttpRequest)
let parseResult = try await authServer.parseTokenRequest(parameters: parameters)
guard parseResult.isOk else {
return errorResponse(error: parseResult.error)
}
let tokenRequest = parseResult.value
// Branch on grant type
switch tokenRequest.grantType {
case .authorizationCode:
let verifyResult = try await authServer.verifyAuthorizationCodeGrant(
code: tokenRequest.code,
redirectUri: tokenRequest.redirectUri,
clientId: tokenRequest.clientId,
codeVerifier: tokenRequest.codeVerifier
)
guard verifyResult.isOk else {
return errorResponse(error: verifyResult.error)
}
let grant = verifyResult.value
let accessToken = try await authServer.createAccessToken(
subject: grant.subject, clientId: grant.clientId, scope: grant.scope
).value
let refreshToken = try await authServer.createRefreshToken(
subject: grant.subject, clientId: grant.clientId, scope: grant.scope
).value
let response = try await authServer.createTokenResponse(
accessToken: accessToken,
refreshToken: refreshToken,
scope: grant.scope
)
return jsonResponse(body: response.value)
case .clientCredentials:
let verifyResult = try await authServer.verifyClientCredentialsGrant(
clientId: tokenRequest.clientId,
requestedScope: tokenRequest.scope
)
guard verifyResult.isOk else {
return errorResponse(error: verifyResult.error)
}
let grant = verifyResult.value
let accessToken = try await authServer.createAccessToken(
subject: grant.subject, clientId: grant.clientId, scope: grant.scope
).value
let response = try await authServer.createTokenResponse(
accessToken: accessToken,
scope: grant.scope
)
return jsonResponse(body: response.value)
case .tokenExchange:
let verifyResult = try await authServer.verifyTokenExchangeGrant(
request: tokenRequest.toTokenExchangeRequest()
)
guard verifyResult.isOk else {
return errorResponse(error: verifyResult.error)
}
let grant = verifyResult.value
let accessToken = try await authServer.createAccessToken(
subject: grant.subject, clientId: grant.clientId, scope: grant.scope
).value
let response = try await authServer.createTokenResponse(
accessToken: accessToken,
scope: grant.scope
)
return jsonResponse(body: response.value)
default:
return errorResponse(error: "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:
- Android/Kotlin
- iOS/Swift
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
}
let authServer = session.graph.authorizationServerService
let parameters: [String: String] = extractFormParameters(rawHttpRequest)
let parseResult = try await authServer.parseTokenRequest(parameters: parameters)
let tokenRequest = parseResult.value
let verifyResult = try await authServer.verifyClientCredentialsGrant(
clientId: tokenRequest.clientId,
requestedScope: tokenRequest.scope
)
if verifyResult.isOk {
let grant = verifyResult.value
// grant.clientId -- the authenticated client
// grant.scope -- the granted scopes
let accessToken = try await authServer.createAccessToken(
subject: grant.subject, clientId: grant.clientId, scope: grant.scope
).value
let response = try await 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:
| Parameter | Description |
|---|---|
subjectToken | The token representing the subject of the exchange |
subjectTokenType | Type identifier for the subject token |
actorToken | Optional token representing the actor (for delegation) |
actorTokenType | Type identifier for the actor token |
resources | Target resources for the new token |
audiences | Target audiences for the new token |
scope | Requested scope for the new token |
requestedTokenType | Desired type of the issued token |
Token type identifiers are defined as constants on TokenTypeIdentifier:
| Constant | URI |
|---|---|
ACCESS_TOKEN | urn:ietf:params:oauth:token-type:access_token |
REFRESH_TOKEN | urn:ietf:params:oauth:token-type:refresh_token |
ID_TOKEN | urn:ietf:params:oauth:token-type:id_token |
SAML1 | urn:ietf:params:oauth:token-type:saml1 |
SAML2 | urn:ietf:params:oauth:token-type:saml2 |
JWT | urn:ietf:params:oauth:token-type:jwt |
Performing a Token Exchange
- Android/Kotlin
- iOS/Swift
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
}
let authServer = session.graph.authorizationServerService
// Parse the incoming token exchange request
let parameters: [String: String] = extractFormParameters(rawHttpRequest)
let parseResult = try await authServer.parseTokenRequest(parameters: parameters)
let tokenRequest = parseResult.value
// Verify the token exchange grant
let verifyResult = try await authServer.verifyTokenExchangeGrant(
request: tokenRequest.toTokenExchangeRequest()
)
if verifyResult.isOk {
let grant = verifyResult.value
// grant.subject -- the original subject identity
// grant.clientId -- the requesting client
// grant.isDelegation -- true for delegation, false for impersonation
// grant.actorClaim -- the nested actor claim chain
let accessToken = try await authServer.createAccessToken(
subject: grant.subject, clientId: grant.clientId, scope: grant.scope
).value
let response = try await 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 identifieract: an optional nestedActorClaimfor multi-hop delegation chainsadditionalClaims: 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:
- Android/Kotlin
- iOS/Swift
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
)
}
}
class MyTokenExchangePolicy: TokenExchangePolicy {
func evaluate(request: TokenExchangeRequest) async -> TokenExchangePolicyDecision {
// Example: only allow delegation for trusted clients
guard trustedClients.contains(request.clientId) else {
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:
| Field | Description |
|---|---|
allowed | Whether the exchange is permitted |
isDelegation | true for delegation (includes act claim), false for impersonation |
issuedTokenType | The type of token to issue |
grantedScope | The scopes to include in the issued token (may be narrower than requested) |
grantedAudience | The audiences to include in the issued token |
denyReason | Human-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:
- Android/Kotlin
- iOS/Swift
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
let authServer = session.graph.authorizationServerService
// After verifying the authorization code grant
let grant = try await authServer.verifyAuthorizationCodeGrant(
code: tokenRequest.code,
redirectUri: tokenRequest.redirectUri,
clientId: tokenRequest.clientId,
codeVerifier: tokenRequest.codeVerifier
).value
let accessToken = try await authServer.createAccessToken(
subject: grant.subject, clientId: grant.clientId, scope: grant.scope
).value
let refreshToken = try await authServer.createRefreshToken(
subject: grant.subject, clientId: grant.clientId, scope: grant.scope
).value
// Create an ID token if the openid scope was granted
var idToken: String? = nil
if grant.scopes.contains("openid") {
idToken = try await authServer.createIdToken(grant: grant).value
}
let response = try await 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:
- Android/Kotlin
- iOS/Swift
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
}
let authServer = session.graph.authorizationServerService
let userInfoResult = try await authServer.getUserInfo(accessToken: accessToken)
if userInfoResult.isOk {
let 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:
- Android/Kotlin
- iOS/Swift
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
}
let authServer = session.graph.authorizationServerService
let jwksResult = try await authServer.getJwks()
if jwksResult.isOk {
let 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.
- Android/Kotlin
- iOS/Swift
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
let authServer = session.graph.authorizationServerService
// 1. Client pushes the authorization request to the PAR endpoint
let parseResult = try await authServer.parsePushedAuthorizationRequest(request: rawHttpRequest)
let requestData = parseResult.value
// 2. Verify the pushed request (client authentication, parameters)
let verifyResult = try await authServer.verifyPushedAuthorizationRequest(request: requestData)
let verifiedRequest = verifyResult.value
// 3. Create a request URI that references the stored request
let requestUriResult = try await authServer.createRequestUri(request: verifiedRequest)
let requestUriData = requestUriResult.value
// 4. Build the PAR response (returns the request_uri and expires_in)
let parResponse = try await authServer.createPushedAuthorizationResponse(
requestUri: requestUriData
).value
// parResponse.requestUri -- e.g. "urn:ietf:params:oauth:request_uri:abc123"
// parResponse.expiresIn -- seconds until the request_uri expires
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:
- Android/Kotlin
- iOS/Swift
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
}
let authServer = session.graph.authorizationServerService
// Parse the introspection request (includes client authentication)
let parseResult = try await authServer.parseIntrospectionRequest(request: rawHttpRequest)
let introspectionRequest = parseResult.value
// Introspect the token
let result = try await authServer.introspectToken(request: introspectionRequest)
if result.isOk {
let 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:
- Android/Kotlin
- iOS/Swift
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
}
let authServer = session.graph.authorizationServerService
let parseResult = try await authServer.parseRevocationRequest(request: rawHttpRequest)
let revocationRequest = parseResult.value
let result = try await authServer.revokeToken(request: 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:
| Field | Description |
|---|---|
clientId | Unique client identifier |
clientSecret | Client secret (confidential clients only) |
clientName | Human-readable client name |
clientType | CONFIDENTIAL or PUBLIC |
grantTypes | Allowed grant types (e.g., AUTHORIZATION_CODE, CLIENT_CREDENTIALS) |
responseTypes | Allowed response types (e.g., code) |
redirectUris | Registered redirect URIs |
allowedScopes | Scopes the client is permitted to request |
tokenEndpointAuthMethod | How the client authenticates at the token endpoint |
jwks / jwksUri | Client public keys for private_key_jwt or verification |
requirePkce | Whether PKCE is mandatory for this client |
requirePushedAuthorizationRequests | Whether PAR is mandatory |
dpopBoundAccessTokens | Whether access tokens must be DPoP-bound |
accessTokenLifetime | Override for access token TTL |
refreshTokenLifetime | Override for refresh token TTL |
authorizationCodeLifetime | Override for authorization code TTL |
Client Authentication Methods
The service supports the following token endpoint authentication methods:
| Method | Description |
|---|---|
CLIENT_SECRET_BASIC | Client ID and secret via HTTP Basic authentication |
CLIENT_SECRET_POST | Client ID and secret in the request body |
CLIENT_SECRET_JWT | Client assertion signed with the client secret (HMAC) |
PRIVATE_KEY_JWT | Client assertion signed with the client's private key |
ATTESTATION_JWT | Attestation-based client authentication (see below) |
NONE | No 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:
| Field | Description |
|---|---|
trustedAttesterIssuers | Accepted issuers of attestation JWTs |
trustedAttesterJwksUris | JWKS 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
| Property | Type | Default | Description |
|---|---|---|---|
client-id | String | required | Unique OAuth 2.0 client identifier |
client-secret | String? | null | Client secret (omit for public clients) |
client-name | String? | null | Human-readable display name |
client-type | String | CONFIDENTIAL | CONFIDENTIAL or PUBLIC |
grant-types | List | required | Allowed grant types |
response-types | List | auto-inferred | Response types (defaults to code for authorization code grants) |
redirect-uris | List | [] | Authorized redirect URIs |
allowed-scopes | List? | null | Permitted scopes (null = all scopes allowed) |
token-endpoint-auth-method | String | per client type | CLIENT_SECRET_BASIC, CLIENT_SECRET_POST, CLIENT_SECRET_JWT, PRIVATE_KEY_JWT, NONE |
require-pkce | Boolean | true for PUBLIC | Whether PKCE is mandatory |
require-pushed-authorization-requests | Boolean | false | Enforce PAR (RFC 9126) |
dpop-bound-access-tokens | Boolean | false | Require DPoP-bound tokens (RFC 9449) |
access-token-lifetime | Int | 3600 | Access token lifetime in seconds |
refresh-token-lifetime | Int? | null | Refresh token lifetime (null = no expiry) |
authorization-code-lifetime | Int | 600 | Authorization code lifetime in seconds |
trusted-attester-issuers | List? | null | Trusted attestation JWT issuers |
trusted-attester-jwks-uris | Map? | null | JWKS URIs for attestation verification, keyed by issuer |
enabled | Boolean | true | Whether 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:
- Configuration-driven clients: loaded from YAML/environment variables via
OAuth2ClientsConfigBinderat session startup. These are immutable. - 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
}
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.
Authentication and Consent
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:
- Android/Kotlin
- iOS/Swift
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
}
let authServer = session.graph.authorizationServerService
let metadataResult = try await authServer.buildServerMetadata()
if metadataResult.isOk {
let 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:
- Android/Kotlin
- iOS/Swift
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")
}
// The iOS package includes all modules by default
import SphereonIDK
If you are using the lib-all dependency, these modules are already included.