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

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

build.gradle.kts
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:

val 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:

// 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)
}

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.

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
}

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:

// 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
}
}

TokenResponse Fields

The TokenResponse returned by all token exchange methods includes:

FieldDescription
accessTokenThe access token issued by the authorization server
tokenTypeToken type, typically Bearer or DPoP
expiresInToken lifetime in seconds
refreshTokenRefresh token for obtaining new access tokens
scopeGranted scope (may differ from requested scope)
idTokenOpenID Connect ID token (when openid scope is used)
cNonceCredential nonce for OpenID4VCI flows
cNonceExpiresInLifetime of the cNonce in seconds
dpopNonceDPoP nonce for subsequent requests (RFC 9449)
issuedTokenTypeIssued 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:

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
}
tip

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:

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)
}

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:

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
}
note

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:

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"]
}

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):

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()
}
}

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:

// 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")
)

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:

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}")
}
}

Complete OIDC Flow Example

The following example demonstrates a full OpenID Connect authorization code flow with PKCE, ID token validation, and UserInfo fetching:

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()