OID4VP 1.0 FINAL holder-side flow. Your OEM app receives a presentation request (deep-link), resolves the verifier's DCQL query against locally held credentials, builds a vp_token (SD-JWT with selective disclosures + KB-JWT for key binding, or mDoc DeviceResponse), and posts it back. Two response modes: direct_post (plain form) and direct_post.jwt (JWE-encrypted, ECDH-ES + A128GCM). Optional HAIP §5.11 Wallet Attestation as OAuth-Client-Attestation header when the verifier requires it. Interop target: European Digital Identity Wallet ecosystem.
The C1 jar-proxy detour is browser-only. Native apps have no CORS and MUST fetch request_uri directly. Do not route via /oidc.ashx?action=jar-proxy.
1 Parse the OID4VP request
Deep-link scheme: openid4vp://, haip-vp://, or a bespoke URL scheme your app claims. Two shapes: inline (all params in query string) or by reference (request_uri). By-reference dominates in practice because request objects contain long JWKs.
GETrequest_uri with Accept: application/oauth-authz-req+jwt. Response is a signed JWT (JWS Compact). Verify the signature using the x5c chain in the header (Access Certificate per ARF 3.0) or the JWKS the verifier metadata advertises.
Header includes typ=oauth-authz-req+jwt per HAIP. Payload is the OID4VP request object: client_id, response_type=vp_token, response_mode, response_uri, nonce, state, dcql_query, and (for JWE mode) client_metadata.jwks.
iOS Swift
func fetchJar(uri: URL) async throws -> RequestObject {
var req = URLRequest(url: uri)
req.setValue("application/oauth-authz-req+jwt", forHTTPHeaderField: "Accept")
let (data, _) = try await URLSession.shared.data(for: req)
let jwt = String(data: data, encoding: .utf8)!
return try verifyAndParseJwt(jwt) // checks x5c chain against your trust anchors
}
Android Kotlin
suspend fun fetchJar(uri: String): RequestObject = withContext(Dispatchers.IO) {
val conn = (URL(uri).openConnection() as HttpURLConnection).apply {
setRequestProperty("Accept", "application/oauth-authz-req+jwt")
}
val jwt = conn.inputStream.bufferedReader().readText()
verifyAndParseJwt(jwt) // x5c chain -> your trust anchors
}
3 Resolve DCQL against local credentials
The request's dcql_query shape (OID4VP 1.0 FINAL sec 7.2):
Iterate credentials[]: for each entry, find local credentials whose vct (or doctype) matches meta.vct_values / meta.doctype_value. Confirm your credential contains all the requested claims[].path values. If multiple candidates exist, prompt the user to pick.
iOS Swift
func resolveDcql(_ query: DcqlQuery, store: CredentialStore) -> [DcqlMatch] {
query.credentials.compactMap { credSpec in
let candidates = store.all.filter { c in
switch credSpec.format {
case "dc+sd-jwt":
return credSpec.meta.vct_values?.contains(c.vctOrDoctype) ?? false
case "mso_mdoc":
return credSpec.meta.doctype_value == c.vctOrDoctype
default: return false
}
}
return candidates.first.map { DcqlMatch(spec: credSpec, credential: $0) }
}
}
4 Build the vp_token
SD-JWT VC branch
Emit the SD-JWT + only the disclosures corresponding to claims[].path (selective disclosure). Then append a KB-JWT (Key Binding JWT) signed by the wallet key that cnf.jwk in the SD-JWT payload identifies.
Build a CBOR DeviceResponse whose documents[].deviceSigned.deviceSignature is a COSE_Sign1 over DeviceAuthentication = ["DeviceAuthentication", SessionTranscript, docType, DeviceNameSpacesBytes]. SessionTranscript for OID4VP per OID4VP 1.0 FINAL §B.2.6.1:
Both hashes are computed over the CBOR encoding of a two-element array [value, mdoc_generated_nonce]. The third element of the handover is the raw mdoc_generated_nonce (NOT the request's nonce). Getting either wrong causes the verifier to reject the deviceSignature.
The mdoc_generated_nonce transport back to the verifier depends on response mode. direct_post: form field. direct_post.jwt: JWE apu header (base64url, max 256 chars). Per OID4VP 1.0 FINAL §8.1.
5 (When response_mode = direct_post.jwt) encrypt the response
Per HAIP §4, wallet responses to LOTL-attested verifiers MUST be encrypted. JWE algorithm: alg=ECDH-ES, enc=A128GCM. The verifier's ephemeral public key is in client_metadata.jwks in the JAR payload.
For mDoc: put mdoc_generated_nonce as base64url in the JWE apu header (max 256 chars).
URL: the response_uri from the JAR. Content-Type: application/x-www-form-urlencoded.
direct_post body:
vp_token=<sd-jwt~disclosures~kb-jwt OR base64url(mdoc)>
state=<from JAR>
mdoc_generated_nonce=<only for mDoc, only when direct_post>
direct_post.jwt body:
response=<JWE compact string>
mdoc_generated_nonce=<only for mDoc; alternative to JWE apu header>
Response from the verifier (200): { "redirect_uri": "https://verifier.example/return?state=..." }. Your app can follow that URL to complete the verifier's post-presentation flow.