OAuth 2.0 Client
The IDK provides a full OAuth 2.0 client implementation supporting authorization code flow, OpenID Connect, token management, and modern security extensions like PKCE, DPoP, and Resource Indicators.
Overview
The OAuth2Client interface on the session graph handles:
- Authorization server metadata discovery (RFC 8414)
- Authorization code flow with PKCE (RFC 7636)
- Token exchange and refresh
- Pre-authorized code exchange (OpenID4VCI)
- DPoP token binding (RFC 9449)
- Token introspection (RFC 7662)
- ID token validation (OpenID Connect Core)
- UserInfo endpoint (OpenID Connect Core)
- Resource indicators (RFC 8707)
- JWT Secured Authorization Requests (JAR) (RFC 9101)
All methods return IdkResult<T>, giving you explicit success/failure handling without exceptions.
Installation
dependencies {
implementation("com.sphereon.idk:lib-oauth2-client-api:0.25.0")
implementation("com.sphereon.idk:lib-oauth2-client-impl:0.25.0")
}
Obtaining the Client
The OAuth2Client is available on the session graph:
- Android/Kotlin
- iOS/Swift
val oauth2Client = session.graph.oauth2Client
let oauth2Client = session.graph.oauth2Client
Authorization Server Discovery
Before you can start any OAuth 2.0 flow, you need to know the authorization server's endpoints (token URL, authorization URL, JWKS URI, etc.). Discovery fetches this metadata from the server's /.well-known/openid-configuration document so you don't have to hard-code these URLs. The returned metadata also tells you which features the server supports, like PKCE challenge methods and DPoP.
Fetch authorization server metadata from the well-known endpoint using fetchAuthorizationServerMetadata:
- Android/Kotlin
- iOS/Swift
// Discover server metadata from well-known endpoint
val metadataResult = oauth2Client.fetchAuthorizationServerMetadata(
issuer = "https://auth.example.com"
)
metadataResult.onSuccess { metadata ->
// Access discovered endpoints
val authorizationEndpoint = metadata.authorizationEndpoint
val tokenEndpoint = metadata.tokenEndpoint
val jwksUri = metadata.jwksUri
// Check supported features
val supportsPkce = metadata.codeChallengeMethodsSupported.contains("S256")
val supportsDpop = oauth2Client.isDpopSupported(metadata)
}
// Discover server metadata from well-known endpoint
let metadataResult = try await oauth2Client.fetchAuthorizationServerMetadata(
issuer: "https://auth.example.com"
)
if case .success(let metadata) = metadataResult {
// Access discovered endpoints
let authorizationEndpoint = metadata.authorizationEndpoint
let tokenEndpoint = metadata.tokenEndpoint
let jwksUri = metadata.jwksUri
// Check supported features
let supportsPkce = metadata.codeChallengeMethodsSupported.contains("S256")
let supportsDpop = oauth2Client.isDpopSupported(metadata: metadata)
}
Authorization Code Flow
Initiating Authorization
Use initiateAuthorization to build an authorization request. PKCE is handled automatically. You can optionally pass resource indicators (RFC 8707), DPoP context, and additional parameters for JAR or other extensions.
- Android/Kotlin
- iOS/Swift
val authResult = oauth2Client.initiateAuthorization(
metadata = metadata,
clientId = "my-client-id",
redirectUri = "myapp://callback",
scope = "openid profile email",
state = null, // auto-generated if omitted
resource = listOf("https://api.example.com"), // RFC 8707 resource indicator
clientAuthentication = null, // for public clients
dpopContext = null, // set for DPoP-bound requests
additionalParameters = mapOf(
"prompt" to "consent",
"login_hint" to "user@example.com"
)
)
authResult.onSuccess { authorization ->
// Get the authorization URL to open in a browser
val authUrl = authorization.authorizationUrl
// Open in browser or system browser / in-app tab
openBrowser(authUrl)
// Store PKCE data for the token exchange step
val pkceData = authorization.pkceData
}
let authResult = try await oauth2Client.initiateAuthorization(
metadata: metadata,
clientId: "my-client-id",
redirectUri: "myapp://callback",
scope: "openid profile email",
state: nil, // auto-generated if omitted
resource: ["https://api.example.com"], // RFC 8707 resource indicator
clientAuthentication: nil, // for public clients
dpopContext: nil, // set for DPoP-bound requests
additionalParameters: [
"prompt": "consent",
"login_hint": "user@example.com"
]
)
if case .success(let authorization) = authResult {
// Get the authorization URL to open in a browser
let authUrl = authorization.authorizationUrl
// Open in browser
openBrowser(url: authUrl)
// Store PKCE data for the token exchange step
let pkceData = authorization.pkceData
}
Handling the Callback
After the user authenticates, the authorization server redirects back to your app with an authorization code in the URL. You need to parse that redirect, verify the state parameter matches what you originally sent (to prevent CSRF), and then exchange the short-lived authorization code for actual tokens at the token endpoint.
Parse the redirect URL with parseAuthorizationResponse, then exchange the authorization code for tokens:
- Android/Kotlin
- iOS/Swift
// Parse the callback URL
val callbackUrl = "myapp://callback?code=abc123&state=xyz789"
val parseResult = oauth2Client.parseAuthorizationResponse(
redirectUrl = callbackUrl
)
parseResult.onSuccess { authResponse ->
// Validate state matches what you sent
if (authResponse.state != expectedState) {
throw SecurityException("State mismatch - possible CSRF attack")
}
// Exchange the authorization code for tokens
val tokenResult = oauth2Client.exchangeAuthorizationCode(
metadata = metadata,
clientAuthentication = clientAuth,
authorizationCode = authResponse.code,
redirectUri = "myapp://callback",
pkceData = pkceData, // from initiateAuthorization
resource = listOf("https://api.example.com"), // RFC 8707
dpopContext = null
)
tokenResult.onSuccess { tokenResponse ->
// Access tokens
val accessToken = tokenResponse.accessToken
val tokenType = tokenResponse.tokenType
val expiresIn = tokenResponse.expiresIn
val refreshToken = tokenResponse.refreshToken
val scope = tokenResponse.scope
// OIDC: ID token (when openid scope was requested)
val idToken = tokenResponse.idToken
// DPoP nonce for subsequent requests (RFC 9449)
val dpopNonce = tokenResponse.dpopNonce
}
}
// Parse the callback URL
let callbackUrl = "myapp://callback?code=abc123&state=xyz789"
let parseResult = try await oauth2Client.parseAuthorizationResponse(
redirectUrl: callbackUrl
)
if case .success(let authResponse) = parseResult {
// Validate state matches what you sent
guard authResponse.state == expectedState else {
throw SecurityError.stateMismatch
}
// Exchange the authorization code for tokens
let tokenResult = try await oauth2Client.exchangeAuthorizationCode(
metadata: metadata,
clientAuthentication: clientAuth,
authorizationCode: authResponse.code,
redirectUri: "myapp://callback",
pkceData: pkceData, // from initiateAuthorization
resource: ["https://api.example.com"], // RFC 8707
dpopContext: nil
)
if case .success(let tokenResponse) = tokenResult {
// Access tokens
let accessToken = tokenResponse.accessToken
let tokenType = tokenResponse.tokenType
let expiresIn = tokenResponse.expiresIn
let refreshToken = tokenResponse.refreshToken
let scope = tokenResponse.scope
// OIDC: ID token (when openid scope was requested)
let idToken = tokenResponse.idToken
// DPoP nonce for subsequent requests (RFC 9449)
let dpopNonce = tokenResponse.dpopNonce
}
}
TokenResponse Fields
The TokenResponse returned by all token exchange methods includes:
| Field | Description |
|---|---|
accessToken | The access token issued by the authorization server |
tokenType | Token type, typically Bearer or DPoP |
expiresIn | Token lifetime in seconds |
refreshToken | Refresh token for obtaining new access tokens |
scope | Granted scope (may differ from requested scope) |
idToken | OpenID Connect ID token (when openid scope is used) |
cNonce | Credential nonce for OpenID4VCI flows |
cNonceExpiresIn | Lifetime of the cNonce in seconds |
dpopNonce | DPoP nonce for subsequent requests (RFC 9449) |
issuedTokenType | Issued token type for token exchange (RFC 8693) |
Pre-Authorized Code Exchange (OpenID4VCI)
Unlike the standard authorization code flow where the user authenticates through a browser redirect, the pre-authorized code flow skips that step entirely. The issuer generates a credential offer containing a pre-authorized_code that can be exchanged directly for tokens. This is typical in credential issuance scenarios where the issuer already knows who the holder is.
Use exchangePreAuthorizedCode to exchange it for tokens:
- Android/Kotlin
- iOS/Swift
val tokenResult = oauth2Client.exchangePreAuthorizedCode(
metadata = metadata,
clientAuthentication = clientAuth,
preAuthorizedCode = "pre-auth-code-from-offer",
txCode = "123456", // user-provided transaction code, if required
resource = null,
dpopContext = null
)
tokenResult.onSuccess { tokenResponse ->
val accessToken = tokenResponse.accessToken
// OpenID4VCI-specific fields
val cNonce = tokenResponse.cNonce
val cNonceExpiresIn = tokenResponse.cNonceExpiresIn
}
let tokenResult = try await oauth2Client.exchangePreAuthorizedCode(
metadata: metadata,
clientAuthentication: clientAuth,
preAuthorizedCode: "pre-auth-code-from-offer",
txCode: "123456", // user-provided transaction code, if required
resource: nil,
dpopContext: nil
)
if case .success(let tokenResponse) = tokenResult {
let accessToken = tokenResponse.accessToken
// OpenID4VCI-specific fields
let cNonce = tokenResponse.cNonce
let cNonceExpiresIn = tokenResponse.cNonceExpiresIn
}
The pre-authorized code flow is common in credential issuance scenarios where the issuer generates an offer containing a pre-authorized_code. The optional txCode parameter is a user-entered PIN or transaction code that the authorization server may require for additional security.
Token Refresh
Access tokens are intentionally short-lived (often minutes to an hour). When one expires, you use the refresh token to get a new access token without making the user log in again. The refresh token itself is longer-lived and single-use; the server typically issues a new refresh token alongside the new access token.
Use refreshAccessToken to obtain new tokens before the current access token expires:
- Android/Kotlin
- iOS/Swift
val refreshResult = oauth2Client.refreshAccessToken(
metadata = metadata,
clientAuthentication = clientAuth,
refreshToken = currentRefreshToken,
scope = null, // request a narrower scope, or null to keep current
resource = null, // RFC 8707 resource indicator
dpopContext = null
)
refreshResult.onSuccess { tokenResponse ->
// Update stored tokens
saveTokens(tokenResponse)
}
let refreshResult = try await oauth2Client.refreshAccessToken(
metadata: metadata,
clientAuthentication: clientAuth,
refreshToken: currentRefreshToken,
scope: nil, // request a narrower scope, or nil to keep current
resource: nil, // RFC 8707 resource indicator
dpopContext: nil
)
if case .success(let tokenResponse) = refreshResult {
// Update stored tokens
saveTokens(tokens: tokenResponse)
}
ID Token Validation
The ID token is a signed JWT that proves who the user is. You should always validate it before trusting any of its claims. Validation checks the cryptographic signature against the authorization server's published keys (JWKS), confirms the token was issued by the expected issuer, is intended for your client (audience), hasn't expired, and that the nonce matches what you sent in the authorization request.
When using OpenID Connect, validate the ID token returned in the token response with validateIdToken:
- Android/Kotlin
- iOS/Swift
val validationResult = oauth2Client.validateIdToken(
idToken = tokenResponse.idToken!!,
options = IdTokenValidationOptions(
issuer = "https://auth.example.com",
audience = "my-client-id",
nonce = savedNonce // if a nonce was sent in the authorization request
)
)
validationResult.onSuccess { validatedIdToken ->
// Access standard OIDC claims
val subject = validatedIdToken.subject
val issuer = validatedIdToken.issuer
val audience = validatedIdToken.audience
val issuedAt = validatedIdToken.issuedAt
val expiration = validatedIdToken.expiration
// Access additional claims
val email = validatedIdToken.claims["email"] as? String
val emailVerified = validatedIdToken.claims["email_verified"] as? Boolean
val name = validatedIdToken.claims["name"] as? String
}
let validationResult = try await oauth2Client.validateIdToken(
idToken: tokenResponse.idToken!,
options: IdTokenValidationOptions(
issuer: "https://auth.example.com",
audience: "my-client-id",
nonce: savedNonce // if a nonce was sent in the authorization request
)
)
if case .success(let validatedIdToken) = validationResult {
// Access standard OIDC claims
let subject = validatedIdToken.subject
let issuer = validatedIdToken.issuer
let audience = validatedIdToken.audience
let issuedAt = validatedIdToken.issuedAt
let expiration = validatedIdToken.expiration
// Access additional claims
let email = validatedIdToken.claims["email"] as? String
let emailVerified = validatedIdToken.claims["email_verified"] as? Bool
let name = validatedIdToken.claims["name"] as? String
}
For standalone JWT access token validation (outside of the OAuth 2.0 client flow), see the JWT Validation guide.
UserInfo Endpoint
The ID token only contains a minimal set of identity claims. If you need the full user profile (email, display name, profile picture, or provider-specific attributes), the UserInfo endpoint returns those additional claims. The exact set of claims you get back depends on the scopes you requested during authorization (e.g., profile, email).
Fetch the authenticated user's profile claims from the authorization server's UserInfo endpoint with fetchUserInfo. This requires a valid access token with the openid scope:
- Android/Kotlin
- iOS/Swift
val userInfoResult = oauth2Client.fetchUserInfo(
accessToken = tokenResponse.accessToken,
metadata = metadata
)
userInfoResult.onSuccess { userInfo ->
val subject = userInfo.sub
val email = userInfo.claims["email"]?.jsonPrimitive?.content
val name = userInfo.claims["name"]?.jsonPrimitive?.content
val picture = userInfo.claims["picture"]?.jsonPrimitive?.content
// Access any additional claims returned by the provider
val customClaim = userInfo.claims["custom_claim"]
}
let userInfoResult = try await oauth2Client.fetchUserInfo(
accessToken: tokenResponse.accessToken,
metadata: metadata
)
if case .success(let userInfo) = userInfoResult {
let subject = userInfo.sub
let email = (userInfo.claims["email"] as? JsonPrimitive)?.content
let name = (userInfo.claims["name"] as? JsonPrimitive)?.content
let picture = (userInfo.claims["picture"] as? JsonPrimitive)?.content
// Access any additional claims returned by the provider
let customClaim = userInfo.claims["custom_claim"]
}
Token Introspection
Token introspection lets you ask the authorization server whether a token is still valid. This is useful for opaque tokens that can't be validated locally, or when you need to check if a JWT has been revoked server-side. The response tells you if the token is active and includes metadata like its scopes, subject, and expiration.
Check whether a token is still active using introspectToken (RFC 7662):
- Android/Kotlin
- iOS/Swift
val introspectionResult = oauth2Client.introspectToken(
metadata = metadata,
clientAuthentication = clientAuth,
token = accessToken,
tokenTypeHint = "access_token" // optional hint
)
introspectionResult.onSuccess { introspection ->
if (introspection.active) {
val expiresAt = introspection.exp
val scopes = introspection.scope
val subject = introspection.sub
} else {
// Token is no longer valid
handleInvalidToken()
}
}
let introspectionResult = try await oauth2Client.introspectToken(
metadata: metadata,
clientAuthentication: clientAuth,
token: accessToken,
tokenTypeHint: "access_token" // optional hint
)
if case .success(let introspection) = introspectionResult {
if introspection.active {
let expiresAt = introspection.exp
let scopes = introspection.scope
let subject = introspection.sub
} else {
// Token is no longer valid
handleInvalidToken()
}
}
Resource Indicators (RFC 8707)
Resource indicators let you specify the target API when requesting tokens. This is useful when an authorization server issues audience-restricted tokens for different resource servers. By passing a resource URI, you tell the authorization server to scope the access token to that particular API, so the token can't be used against other APIs protected by the same server.
Pass the resource parameter to initiateAuthorization, exchangeAuthorizationCode, exchangePreAuthorizedCode, or refreshAccessToken:
- Android/Kotlin
- iOS/Swift
// Request authorization scoped to a specific resource
val authResult = oauth2Client.initiateAuthorization(
metadata = metadata,
clientId = "my-client-id",
redirectUri = "myapp://callback",
scope = "openid read write",
resource = listOf("https://api.example.com"),
additionalParameters = emptyMap()
)
// Exchange code with the same resource indicator
val tokenResult = oauth2Client.exchangeAuthorizationCode(
metadata = metadata,
clientAuthentication = clientAuth,
authorizationCode = code,
redirectUri = "myapp://callback",
pkceData = pkceData,
resource = listOf("https://api.example.com")
)
// Request authorization scoped to a specific resource
let authResult = try await oauth2Client.initiateAuthorization(
metadata: metadata,
clientId: "my-client-id",
redirectUri: "myapp://callback",
scope: "openid read write",
resource: ["https://api.example.com"],
additionalParameters: [:]
)
// Exchange code with the same resource indicator
let tokenResult = try await oauth2Client.exchangeAuthorizationCode(
metadata: metadata,
clientAuthentication: clientAuth,
authorizationCode: code,
redirectUri: "myapp://callback",
pkceData: pkceData,
resource: ["https://api.example.com"]
)
DPoP Support
DPoP (Demonstrating Proof-of-Possession, RFC 9449) binds tokens to a specific client key pair, preventing stolen tokens from being used by attackers. The OAuth2Client accepts a dpopContext parameter on all token operations.
Use isDpopSupported to check whether the authorization server advertises DPoP support:
if (oauth2Client.isDpopSupported(metadata)) {
// Create a DPoP context and pass it to token operations
}
For detailed DPoP usage including key generation and proof creation, see the DPoP & PKCE guide.
JAR (JWT Secured Authorization Requests)
JAR (RFC 9101) allows the authorization request parameters to be sent as a signed JWT, protecting them from tampering. Pass JAR-related parameters via additionalParameters in initiateAuthorization:
val authResult = oauth2Client.initiateAuthorization(
metadata = metadata,
clientId = "my-client-id",
redirectUri = "myapp://callback",
scope = "openid profile",
additionalParameters = mapOf(
"request" to signedRequestJwt // pre-built signed JAR
)
)
When using Pushed Authorization Requests (PAR, RFC 9126), the IDK handles the request_uri flow transparently if the authorization server metadata indicates PAR support.
Configuration
As an alternative to passing values programmatically, you can configure the OAuth 2.0 client via properties. These are picked up automatically at session initialization:
# Authorization server
oauth2.issuer=https://auth.example.com
oauth2.client-id=my-client-id
oauth2.client-secret=my-client-secret
# Redirect URI
oauth2.redirect-uri=myapp://callback
# Token settings
oauth2.token.refresh.margin.seconds=60
# Security features
oauth2.pkce.enabled=true
oauth2.dpop.enabled=false
Error Handling
All OAuth2Client methods return IdkResult<T>, which is either a success or a failure. Use onSuccess / onFailure for declarative handling, or pattern-match on the result:
- Android/Kotlin
- iOS/Swift
val tokenResult = oauth2Client.exchangeAuthorizationCode(
metadata = metadata,
clientAuthentication = clientAuth,
authorizationCode = code,
redirectUri = "myapp://callback",
pkceData = pkceData
)
tokenResult.onFailure { error ->
when {
"invalid_grant" in error.message.orEmpty() ->
showError("Authorization expired. Please try again.")
"invalid_client" in error.message.orEmpty() ->
showError("Client authentication failed.")
else ->
showError("OAuth error: ${error.message}")
}
}
let tokenResult = try await oauth2Client.exchangeAuthorizationCode(
metadata: metadata,
clientAuthentication: clientAuth,
authorizationCode: code,
redirectUri: "myapp://callback",
pkceData: pkceData
)
if case .failure(let error) = tokenResult {
if error.localizedDescription.contains("invalid_grant") {
showError(message: "Authorization expired. Please try again.")
} else if error.localizedDescription.contains("invalid_client") {
showError(message: "Client authentication failed.")
} else {
showError(message: "OAuth error: \(error.localizedDescription)")
}
}
Complete OIDC Flow Example
The following example demonstrates a full OpenID Connect authorization code flow with PKCE, ID token validation, and UserInfo fetching:
- Android/Kotlin
- iOS/Swift
val oauth2Client = session.graph.oauth2Client
// 1. Discover authorization server
val metadata = oauth2Client.fetchAuthorizationServerMetadata(
issuer = "https://auth.example.com"
).getOrThrow()
// 2. Start authorization with PKCE (automatic)
val authorization = oauth2Client.initiateAuthorization(
metadata = metadata,
clientId = "my-client-id",
redirectUri = "myapp://callback",
scope = "openid profile email",
additionalParameters = emptyMap()
).getOrThrow()
openBrowser(authorization.authorizationUrl)
// 3. Handle callback and exchange code
val authResponse = oauth2Client.parseAuthorizationResponse(
redirectUrl = callbackUrl
).getOrThrow()
val tokenResponse = oauth2Client.exchangeAuthorizationCode(
metadata = metadata,
clientAuthentication = clientAuth,
authorizationCode = authResponse.code,
redirectUri = "myapp://callback",
pkceData = authorization.pkceData
).getOrThrow()
// 4. Validate the ID token
val validatedIdToken = oauth2Client.validateIdToken(
idToken = tokenResponse.idToken!!,
options = IdTokenValidationOptions(
issuer = "https://auth.example.com",
audience = "my-client-id"
)
).getOrThrow()
println("Authenticated user: ${validatedIdToken.subject}")
// 5. Fetch full user profile from UserInfo endpoint
val userInfo = oauth2Client.fetchUserInfo(
accessToken = tokenResponse.accessToken,
metadata = metadata
).getOrThrow()
val name = userInfo.claims["name"]?.jsonPrimitive?.content
val email = userInfo.claims["email"]?.jsonPrimitive?.content
println("Welcome, $name ($email)")
// 6. Later: refresh the access token
val refreshedTokens = oauth2Client.refreshAccessToken(
metadata = metadata,
clientAuthentication = clientAuth,
refreshToken = tokenResponse.refreshToken!!
).getOrThrow()
let oauth2Client = session.graph.oauth2Client
// 1. Discover authorization server
let metadata = try await oauth2Client.fetchAuthorizationServerMetadata(
issuer: "https://auth.example.com"
).get()
// 2. Start authorization with PKCE (automatic)
let authorization = try await oauth2Client.initiateAuthorization(
metadata: metadata,
clientId: "my-client-id",
redirectUri: "myapp://callback",
scope: "openid profile email",
additionalParameters: [:]
).get()
openBrowser(url: authorization.authorizationUrl)
// 3. Handle callback and exchange code
let authResponse = try await oauth2Client.parseAuthorizationResponse(
redirectUrl: callbackUrl
).get()
let tokenResponse = try await oauth2Client.exchangeAuthorizationCode(
metadata: metadata,
clientAuthentication: clientAuth,
authorizationCode: authResponse.code,
redirectUri: "myapp://callback",
pkceData: authorization.pkceData
).get()
// 4. Validate the ID token
let validatedIdToken = try await oauth2Client.validateIdToken(
idToken: tokenResponse.idToken!,
options: IdTokenValidationOptions(
issuer: "https://auth.example.com",
audience: "my-client-id"
)
).get()
print("Authenticated user: \(validatedIdToken.subject)")
// 5. Fetch full user profile from UserInfo endpoint
let userInfo = try await oauth2Client.fetchUserInfo(
accessToken: tokenResponse.accessToken,
metadata: metadata
).get()
let name = (userInfo.claims["name"] as? JsonPrimitive)?.content ?? ""
let email = (userInfo.claims["email"] as? JsonPrimitive)?.content ?? ""
print("Welcome, \(name) (\(email))")
// 6. Later: refresh the access token
let refreshedTokens = try await oauth2Client.refreshAccessToken(
metadata: metadata,
clientAuthentication: clientAuth,
refreshToken: tokenResponse.refreshToken!
).get()