OID4VP Holder
The OID4VP holder component enables wallet applications to receive and respond to credential presentation requests. This guide covers parsing authorization requests, resolving verifier identity, creating presentations, and submitting responses.
Obtaining the Holder
The holder service is the wallet-side counterpart to the verifier. It handles incoming authorization requests, helps you figure out which credentials to present, and submits the response back to the verifier. You get a single Oid4vpHolder instance from the session graph.
- Android/Kotlin
- iOS/Swift
val holder: Oid4vpHolder = session.graph.oid4vpHolder
let holder: Oid4vpHolder = session.graph.oid4vpHolder
Processing Authorization Requests
When a user scans a QR code or follows a deep link, the wallet receives an authorization request URI. Processing involves parsing, resolving, and validating the request.
Parsing the Request
The authorization request arrives as a URI, either from a scanned QR code or a deep link. Parsing extracts the query parameters into a structured object: the verifier's clientId, the nonce for replay protection, the state for session correlation, and the responseUri where you will eventually POST the presentation. If the request uses encryption (JWE), provide a decryptionKey in the wallet config.
- Android/Kotlin
- iOS/Swift
// Parse the incoming authorization request
val parseResult = holder.parseAuthorizationRequest(
requestUri = "openid4vp://authorize?client_id=https://verifier.example.com&...",
walletConfig = WalletConfig(
audience = "https://wallet.example.com",
decryptionKey = null // For encrypted request objects (JWE)
)
)
if (parseResult.isOk) {
val authRequest = parseResult.value
// Access request details
val clientId = authRequest.clientId
val nonce = authRequest.nonce
val state = authRequest.state
val responseUri = authRequest.responseUri
}
// Parse the incoming authorization request
let parseResult = try await holder.parseAuthorizationRequest(
requestUri: "openid4vp://authorize?client_id=https://verifier.example.com&...",
walletConfig: WalletConfig(
audience: "https://wallet.example.com",
decryptionKey: nil // For encrypted request objects (JWE)
)
)
if parseResult.isOk {
let authRequest = parseResult.value
// Access request details
let clientId = authRequest.clientId
let nonce = authRequest.nonce
let state = authRequest.state
let responseUri = authRequest.responseUri
}
Resolving the Request
Resolution goes beyond parsing by reaching out over the network to fetch the verifier's metadata and validate its client ID. Depending on the clientIdScheme, this may involve fetching an X.509 certificate chain, resolving a DID document, or checking a redirect URI. The result includes a verifierInfo object you can display to the user so they know who is asking for their data, along with any validation warnings.
- Android/Kotlin
- iOS/Swift
val resolveResult = holder.resolveAuthorizationRequest(authRequest)
if (resolveResult.isOk) {
val resolved = resolveResult.value
// Access the DCQL query
val dcqlQuery = resolved.dcqlQuery
// Get verifier information for UI display
val verifierInfo = resolved.verifierInfo
println("Verifier: ${verifierInfo.displayName}")
println("Client ID: ${verifierInfo.clientId}")
println("Scheme: ${verifierInfo.clientIdScheme}")
println("Valid: ${verifierInfo.clientIdValid}")
// Check for validation errors
if (verifierInfo.clientIdValidationErrors.isNotEmpty()) {
println("Warnings: ${verifierInfo.clientIdValidationErrors}")
}
}
let resolveResult = try await holder.resolveAuthorizationRequest(
request: authRequest
)
if resolveResult.isOk {
let resolved = resolveResult.value
// Access the DCQL query
let dcqlQuery = resolved.dcqlQuery
// Get verifier information for UI display
let verifierInfo = resolved.verifierInfo
print("Verifier: \(verifierInfo.displayName ?? "")")
print("Client ID: \(verifierInfo.clientId)")
print("Scheme: \(verifierInfo.clientIdScheme)")
print("Valid: \(verifierInfo.clientIdValid)")
// Check for validation errors
if !verifierInfo.clientIdValidationErrors.isEmpty {
print("Warnings: \(verifierInfo.clientIdValidationErrors)")
}
}
Building the Consent UI
Before presenting any credentials, you need the user's explicit consent. Use the resolved request to show exactly what the verifier is asking for: the verifier's identity, which credential types are requested, and which specific claims will be disclosed. The DCQL query's credentials list tells you the required formats and claim paths.
- Android/Kotlin
- iOS/Swift
// Display verifier information
val verifierInfo = resolved.verifierInfo
displayVerifierCard(
name = verifierInfo.displayName ?: verifierInfo.clientId,
logo = verifierInfo.logoUri,
trusted = verifierInfo.clientIdValid
)
// Display requested credentials from DCQL query
resolved.dcqlQuery?.credentials?.forEach { credentialQuery ->
println("Credential: ${credentialQuery.id}")
println("Format: ${credentialQuery.format}")
// Show requested claims
credentialQuery.claims?.forEach { claim ->
println(" Claim: ${claim.path.joinToString(".")}")
}
}
// Display verifier information
let verifierInfo = resolved.verifierInfo
displayVerifierCard(
name: verifierInfo.displayName ?? verifierInfo.clientId,
logo: verifierInfo.logoUri,
trusted: verifierInfo.clientIdValid
)
// Display requested credentials from DCQL query
for credentialQuery in resolved.dcqlQuery?.credentials ?? [] {
print("Credential: \(credentialQuery.id)")
print("Format: \(credentialQuery.format ?? "")")
// Show requested claims
for claim in credentialQuery.claims ?? [] {
print(" Claim: \(claim.path.joined(separator: "."))")
}
}
Creating the Response
Once the user approves, you build the authorization response by mapping each DCQL credential query to a credential from the wallet. Each SelectedCredential ties a credentialQueryId (matching the DCQL query) to a serialized presentation and the set of claims being disclosed. For SD-JWT credentials, this is where selective disclosure happens: you include only the claims the verifier asked for.
- Android/Kotlin
- iOS/Swift
// Build list of selected credentials
val selectedCredentials = listOf(
SelectedCredential(
credentialQueryId = "identity_credential", // Matches DCQL query ID
credentialId = "wallet-cred-001", // Local wallet ID
presentation = sdJwtPresentation, // Serialized presentation
format = "dc+sd-jwt",
disclosedClaims = mapOf(
"given_name" to "John",
"family_name" to "Doe"
)
)
)
// Create the authorization response
val responseResult = holder.createAuthorizationResponse(
request = resolved,
selectedCredentials = selectedCredentials
)
if (responseResult.isOk) {
val response = responseResult.value
// response.vpToken contains the VP token for submission
}
// Build list of selected credentials
let selectedCredentials = [
SelectedCredential(
credentialQueryId: "identity_credential", // Matches DCQL query ID
credentialId: "wallet-cred-001", // Local wallet ID
presentation: sdJwtPresentation, // Serialized presentation
format: "dc+sd-jwt",
disclosedClaims: [
"given_name": "John",
"family_name": "Doe"
]
)
]
// Create the authorization response
let responseResult = try await holder.createAuthorizationResponse(
request: resolved,
selectedCredentials: selectedCredentials
)
if responseResult.isOk {
let response = responseResult.value
// response.vpToken contains the VP token for submission
}
Submitting the Response
The final step sends the VP token to the verifier at the responseUri from the original request. With direct_post mode, the wallet makes an HTTP POST directly to the verifier's backend. The result tells you whether the verifier accepted the presentation, rejected it with an error, or returned a redirect URI for further navigation (for example, back to a web page that initiated the flow).
- Android/Kotlin
- iOS/Swift
val submitResult = holder.submitAuthorizationResponse(
resolvedRequest = resolved,
response = response,
responseMode = ResponseMode.DIRECT_POST // or null to use request default
)
if (submitResult.isOk) {
when (val result = submitResult.value) {
is SubmissionResult.Success -> {
// Presentation accepted
result.redirectUri?.let { uri ->
// Redirect user if verifier provided a redirect
openUrl(uri)
}
}
is SubmissionResult.Error -> {
// Verifier returned an error
showError("Error: ${result.error} - ${result.errorDescription}")
}
is SubmissionResult.Redirect -> {
// For fragment/query response modes
openUrl(result.redirectUri)
}
}
}
let submitResult = try await holder.submitAuthorizationResponse(
resolvedRequest: resolved,
response: response,
responseMode: .directPost // or nil to use request default
)
if submitResult.isOk {
switch submitResult.value {
case let success as SubmissionResult.Success:
// Presentation accepted
if let redirectUri = success.redirectUri {
openUrl(url: redirectUri)
}
case let error as SubmissionResult.Error:
// Verifier returned an error
showError(message: "\(error.error): \(error.errorDescription ?? "")")
case let redirect as SubmissionResult.Redirect:
// For fragment/query response modes
openUrl(url: redirect.redirectUri)
default:
break
}
}
Complete Flow Example
The following example walks through the entire holder flow from receiving a request URI to submitting the response. It includes credential matching (comparing what the verifier asks for against what the wallet holds), consent handling, and format-specific presentation creation for both SD-JWT and mDoc credentials.
- Android/Kotlin
- iOS/Swift
class WalletPresentationHandler(
private val holder: Oid4vpHolder,
private val credentialStore: CredentialStore,
private val sdJwtService: SdJwtService
) {
suspend fun handlePresentationRequest(requestUri: String): PresentationResult {
// 1. Parse the authorization request
val parseResult = holder.parseAuthorizationRequest(
requestUri = requestUri,
walletConfig = WalletConfig(audience = "https://wallet.example.com")
)
if (!parseResult.isOk) {
return PresentationResult.Error("Failed to parse request")
}
// 2. Resolve request (fetch metadata, validate client)
val resolveResult = holder.resolveAuthorizationRequest(parseResult.value)
if (!resolveResult.isOk) {
return PresentationResult.Error("Failed to resolve request")
}
val resolved = resolveResult.value
// 3. Check if we have matching credentials
val matchingCredentials = findMatchingCredentials(
resolved.dcqlQuery,
credentialStore.getAllCredentials()
)
if (matchingCredentials.isEmpty()) {
return PresentationResult.NoMatchingCredentials
}
// 4. Show consent UI and wait for user decision
val userConsent = showConsentUI(
verifier = resolved.verifierInfo,
requestedCredentials = resolved.dcqlQuery,
availableCredentials = matchingCredentials
)
if (!userConsent.approved) {
return PresentationResult.UserDeclined
}
// 5. Create presentations for selected credentials
val selectedCredentials = userConsent.selectedCredentials.map { selection ->
val presentation = when (selection.format) {
"dc+sd-jwt" -> createSdJwtPresentation(
credential = selection.credential,
claims = selection.disclosedClaims,
audience = resolved.verifierInfo.clientId,
nonce = resolved.request.nonce
)
"mso_mdoc" -> createMdocPresentation(
credential = selection.credential,
claims = selection.disclosedClaims
)
else -> throw UnsupportedOperationException("Unknown format: ${selection.format}")
}
SelectedCredential(
credentialQueryId = selection.queryId,
credentialId = selection.credential.id,
presentation = presentation,
format = selection.format,
disclosedClaims = selection.disclosedClaims
)
}
// 6. Create and submit response
val responseResult = holder.createAuthorizationResponse(
request = resolved,
selectedCredentials = selectedCredentials
)
if (!responseResult.isOk) {
return PresentationResult.Error("Failed to create response")
}
val submitResult = holder.submitAuthorizationResponse(
resolvedRequest = resolved,
response = responseResult.value
)
return when {
!submitResult.isOk -> PresentationResult.Error("Failed to submit")
submitResult.value is SubmissionResult.Success -> {
PresentationResult.Success(
(submitResult.value as SubmissionResult.Success).redirectUri
)
}
else -> PresentationResult.Error("Submission rejected")
}
}
private suspend fun createSdJwtPresentation(
credential: StoredCredential,
claims: Map<String, String>,
audience: String,
nonce: String?
): String {
val result = sdJwtService.presentSdJwt(
PresentSdJwtArgs(
sdJwt = credential.serialized,
disclosureSelection = SdMap(
fields = claims.keys.associateWith { SdField(sd = true) }
),
audience = audience,
nonce = nonce,
holderKey = credential.holderKeyInfo
)
)
return result.value.presentation
}
}
sealed class PresentationResult {
data class Success(val redirectUri: String?) : PresentationResult()
data class Error(val message: String) : PresentationResult()
object NoMatchingCredentials : PresentationResult()
object UserDeclined : PresentationResult()
}
class WalletPresentationHandler {
private let holder: Oid4vpHolder
private let credentialStore: CredentialStore
private let sdJwtService: SdJwtService
init(holder: Oid4vpHolder, credentialStore: CredentialStore, sdJwtService: SdJwtService) {
self.holder = holder
self.credentialStore = credentialStore
self.sdJwtService = sdJwtService
}
func handlePresentationRequest(requestUri: String) async throws -> PresentationResult {
// 1. Parse the authorization request
let parseResult = try await holder.parseAuthorizationRequest(
requestUri: requestUri,
walletConfig: WalletConfig(audience: "https://wallet.example.com")
)
guard parseResult.isOk else {
return .error("Failed to parse request")
}
// 2. Resolve request (fetch metadata, validate client)
let resolveResult = try await holder.resolveAuthorizationRequest(
request: parseResult.value
)
guard resolveResult.isOk else {
return .error("Failed to resolve request")
}
let resolved = resolveResult.value
// 3. Check if we have matching credentials
let matchingCredentials = findMatchingCredentials(
query: resolved.dcqlQuery,
credentials: credentialStore.getAllCredentials()
)
guard !matchingCredentials.isEmpty else {
return .noMatchingCredentials
}
// 4. Show consent UI and wait for user decision
let userConsent = await showConsentUI(
verifier: resolved.verifierInfo,
requestedCredentials: resolved.dcqlQuery,
availableCredentials: matchingCredentials
)
guard userConsent.approved else {
return .userDeclined
}
// 5. Create presentations for selected credentials
var selectedCredentials: [SelectedCredential] = []
for selection in userConsent.selectedCredentials {
let presentation: String
switch selection.format {
case "dc+sd-jwt":
presentation = try await createSdJwtPresentation(
credential: selection.credential,
claims: selection.disclosedClaims,
audience: resolved.verifierInfo.clientId,
nonce: resolved.request.nonce
)
case "mso_mdoc":
presentation = try await createMdocPresentation(
credential: selection.credential,
claims: selection.disclosedClaims
)
default:
throw NSError(domain: "Unsupported format", code: -1)
}
selectedCredentials.append(SelectedCredential(
credentialQueryId: selection.queryId,
credentialId: selection.credential.id,
presentation: presentation,
format: selection.format,
disclosedClaims: selection.disclosedClaims
))
}
// 6. Create and submit response
let responseResult = try await holder.createAuthorizationResponse(
request: resolved,
selectedCredentials: selectedCredentials
)
guard responseResult.isOk else {
return .error("Failed to create response")
}
let submitResult = try await holder.submitAuthorizationResponse(
resolvedRequest: resolved,
response: responseResult.value
)
guard submitResult.isOk else {
return .error("Failed to submit")
}
if let success = submitResult.value as? SubmissionResult.Success {
return .success(redirectUri: success.redirectUri)
} else {
return .error("Submission rejected")
}
}
}
enum PresentationResult {
case success(redirectUri: String?)
case error(String)
case noMatchingCredentials
case userDeclined
}
Data Types
The types below are the main structures used on the holder side for building and submitting presentations.
SelectedCredential
data class SelectedCredential(
val credentialQueryId: String, // Must match DCQL credential query ID
val credentialId: String, // Local wallet credential identifier
val presentation: String, // Serialized credential presentation
val format: String, // e.g., "dc+sd-jwt", "mso_mdoc"
val disclosedClaims: Map<String, String>? = null
)
VerifierInfo
data class VerifierInfo(
val clientId: String,
val clientIdScheme: ClientIdScheme,
val clientIdValid: Boolean = true,
val clientIdValidationErrors: List<ClientIdValidationError> = emptyList(),
val displayName: String? = null,
val logoUri: String? = null,
val trustRoot: String? = null
)
SubmissionResult
sealed interface SubmissionResult {
data class Success(
val redirectUri: String? = null,
val responseBody: JsonObject? = null
) : SubmissionResult
data class Error(
val error: String,
val errorDescription: String? = null
) : SubmissionResult
data class Redirect(
val redirectUri: String
) : SubmissionResult
}