OIDC4VP Plugin
OpenID for Verifiable Presentations (OIDC4VP) implementation for TrustWeave.
Overview
OIDC4VP (OpenID for Verifiable Presentations, OID4VP) is an OAuth 2.0 / OpenID
Connect extension that lets a verifier request one or more Verifiable Presentations
from a wallet. The verifier sends an authorization request (typically as an
openid4vp:// URL or QR code) and the wallet responds with a signed vp_token,
optionally accompanied by a DIF Presentation Exchange presentation_submission.
When paired with SIOPv2, the same authorization request can yield
both an id_token (self-issued identity proof) and a vp_token (credential
presentation) in a single round-trip.
Features
- Parse
openid4vp://authorization URLs (direct params andrequest_urifetch) - Build and submit a
vp_tokenviadirect_postanddirect_post.jwt - DIF Presentation Exchange v2 integration (
presentation_definition) - DCQL query support (
dcql_queryparameter pass-through) - High Assurance Interoperability Profile (HAIP) validation via
HaipProfileValidator - Multiple
client_id_schemevalues:pre-registered,redirect_uri,entity_id,did,x509_san_dns,x509_san_uri,verifier_attestation - Verifier metadata discovery at
/.well-known/openid-credential-verifier - Integration with the protocol-agnostic
ExchangeServicevia theCredentialExchangeProtocolSPI (auto-registered)
Architecture
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
+-------------------------------------------+
| Oidc4VpExchangeProtocol |
| (Implements CredentialExchangeProtocol) |
| - requestProof() |
| - presentProof() |
+-------------------------------------------+
|
v
+-------------------------------------------+
| Oidc4VpService |
| - parseAuthorizationUrl() |
| - createPermissionResponse() |
| - submitPermissionResponse() |
| - fetchVerifierMetadata() |
+-------------------------------------------+
|
+----------+----------+
v v
+--------------+ +--------------------+
| HAIP | | Presentation |
| Profile | | Exchange plugin |
| Validator | | (definition match) |
+--------------+ +--------------------+
Usage
Basic Setup
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import org.trustweave.credential.oidc4vp.Oidc4VpService
import org.trustweave.credential.oidc4vp.exchange.Oidc4VpExchangeProtocol
import org.trustweave.credential.exchange.ExchangeServices
import org.trustweave.credential.exchange.registry.ExchangeProtocolRegistries
import okhttp3.OkHttpClient
val kms = // your KeyManagementService
val credentialService = // your CredentialService
val didResolver = // your DidResolver
val httpClient = OkHttpClient()
val oidc4vpService = Oidc4VpService(
kms = kms,
httpClient = httpClient,
haipMode = false, // set true to enforce HAIP on every parsed request
)
val protocol = Oidc4VpExchangeProtocol(oidc4vpService)
val registry = ExchangeProtocolRegistries.default()
registry.register(protocol)
val exchangeService = ExchangeServices.createExchangeService(
protocolRegistry = registry,
credentialService = credentialService,
didResolver = didResolver,
)
The plugin is also discovered automatically via the Java ServiceLoader file
META-INF/services/org.trustweave.credential.exchange.spi.CredentialExchangeProtocolProvider,
which points at org.trustweave.credential.oidc4vp.exchange.spi.Oidc4VpExchangeProtocolProvider.
Auto-discovery is opt-in through:
1
2
3
4
5
val exchangeService = ExchangeServices.createExchangeServiceWithAutoDiscovery(
credentialService = credentialService,
didResolver = didResolver,
options = mapOf("kms" to kms, "httpClient" to httpClient),
)
Verifier Side: Building an Authorization Request
The plugin is wallet/holder-centric, so the verifier publishes the authorization
request as a JSON document served from request_uri plus an openid4vp:// deep
link pointing at it. The payload embeds a DIF Presentation Exchange
presentation_definition:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import org.trustweave.credential.pex.PresentationDefinition
import org.trustweave.credential.pex.InputDescriptor
import org.trustweave.credential.pex.Constraints
import org.trustweave.credential.pex.Field
import kotlinx.serialization.json.Json
import org.trustweave.core.json.jsonData
val definition = PresentationDefinition(
id = "employee-verification",
inputDescriptors = listOf(
InputDescriptor(
id = "employee_credential",
constraints = Constraints(
fields = listOf(
Field(path = listOf("$.type")),
Field(path = listOf("$.credentialSubject.employeeId")),
),
),
),
),
)
val authorizationRequest = jsonData {
"client_id" to "https://verifier.example.com"
"client_id_scheme" to "x509_san_dns"
"response_mode" to "direct_post"
"response_uri" to "https://verifier.example.com/oidc4vp/response"
"nonce" to java.util.UUID.randomUUID().toString()
"state" to java.util.UUID.randomUUID().toString()
"presentation_definition" to Json.encodeToJsonElement(
PresentationDefinition.serializer(), definition,
)
}
// Serve from your request_uri endpoint and share:
// openid4vp://authorize?client_id=...&request_uri=https://verifier.example.com/req/abc
See the Presentation Exchange plugin README
for the full PresentationDefinition API.
Holder Side: Parse, Respond, Submit
The wallet drives the flow through the unified ExchangeService API or directly
against Oidc4VpService for finer control. The high-level path uses
ProofExchangeRequest.Request / ProofExchangeRequest.Presentation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import org.trustweave.credential.exchange.request.ProofExchangeRequest
import org.trustweave.credential.exchange.request.ProofRequest
import org.trustweave.credential.exchange.options.ExchangeOptions
import org.trustweave.credential.exchange.result.ExchangeResult
import org.trustweave.credential.identifiers.ExchangeProtocolName
import org.trustweave.credential.identifiers.RequestId
import org.trustweave.credential.model.CredentialType
import org.trustweave.credential.model.vc.VerifiablePresentation
import org.trustweave.core.identifiers.Iri
import org.trustweave.did.identifiers.Did
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import org.trustweave.core.json.jsonData
fun main() = runBlocking {
val verifierDid = Did("did:web:verifier.example.com")
val holderDid = Did("did:key:zHolder...")
// 1) Parse the verifier's authorization URL (e.g., from a scanned QR code).
val requestResult = exchangeService.requestProof(
ProofExchangeRequest.Request(
protocolName = ExchangeProtocolName.Oidc4Vp,
verifierDid = verifierDid,
proverDid = holderDid,
proofRequest = ProofRequest(
name = "oid4vp",
requestedAttributes = emptyMap(), // PE definition carries the details
),
options = ExchangeOptions(
metadata = mapOf(
"authorizationUrl" to JsonPrimitive(
"openid4vp://authorize?client_id=...&request_uri=https://verifier.example.com/req/abc",
),
),
),
),
)
// The OID4VP plugin tracks its own session by the `requestId` it minted in
// parseAuthorizationUrl(); it is round-tripped through messageEnvelope.metadata.
// (requestResult.value.requestId is a separate UUID minted by ExchangeService
// and is NOT the OID4VP session id.)
val response = when (requestResult) {
is ExchangeResult.Success -> requestResult.value
is ExchangeResult.Failure -> error("Proof request failed: ${requestResult.errors}")
}
val requestId = RequestId(
(response.messageEnvelope.metadata["requestId"] as JsonPrimitive).content,
)
// 2) Build a VP locally and submit it back through the same protocol.
val vp = VerifiablePresentation(
type = listOf(CredentialType.Custom("VerifiablePresentation")),
holder = Iri(holderDid.value),
verifiableCredential = listOf(employeeCredential),
)
val presentResult = exchangeService.presentProof(
ProofExchangeRequest.Presentation(
protocolName = ExchangeProtocolName.Oidc4Vp,
proverDid = holderDid,
verifierDid = verifierDid,
presentation = vp,
requestId = requestId,
options = ExchangeOptions(
metadata = mapOf(
"keyId" to JsonPrimitive("did:key:zHolder...#key-1"),
"selectedCredentials" to JsonArray(
listOf(JsonPrimitive("employee_credential")),
),
"selectedFields" to jsonData {
"employee_credential" to JsonArray(
listOf(JsonPrimitive("employeeId")),
)
},
),
),
),
)
when (presentResult) {
is ExchangeResult.Success -> println("vp_token submitted, response id = ${presentResult.value.presentationId}")
is ExchangeResult.Failure -> error("Presentation failed: ${presentResult.errors}")
}
}
Direct calls against Oidc4VpService give access to the intermediate
PermissionRequest / PermissionResponse (e.g., to prompt the user before
submitting):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import org.trustweave.credential.oidc4vp.models.PresentableCredential
import kotlinx.coroutines.runBlocking
runBlocking {
val permissionRequest = oidc4vpService.parseAuthorizationUrl(authorizationUrl)
val selected = listOf(
PresentableCredential(
credentialId = "employee_credential",
credential = employeeCredential,
credentialType = "EmployeeCredential",
),
)
val response = oidc4vpService.createPermissionResponse(
permissionRequest = permissionRequest,
selectedCredentials = selected,
selectedFields = listOf(listOf("employeeId")),
holderDid = "did:key:zHolder...",
keyId = "did:key:zHolder...#key-1",
)
oidc4vpService.submitPermissionResponse(response)
}
Presentation Exchange Integration
The presentation_definition carried in the OID4VP request is a plain
DIF Presentation Exchange v2 document. Match it against the holder’s wallet with
PresentationDefinitionMatcher from the presentation-exchange plugin:
1
2
3
4
5
6
7
8
9
10
11
12
import org.trustweave.credential.pex.PresentationDefinition
import org.trustweave.credential.pex.PresentationDefinitionMatcher
import kotlinx.serialization.json.Json
val definitionJson = permissionRequest.authorizationRequest.presentationDefinition
?: error("Verifier did not include a presentation_definition")
val definition = Json.decodeFromJsonElement(
PresentationDefinition.serializer(), definitionJson,
)
val matches = PresentationDefinitionMatcher.match(definition, walletCredentials)
val submission = PresentationDefinitionMatcher.buildSubmission(definition, matches)
The matcher emits a PresentationSubmission you can attach to the response
(PermissionResponse.presentationSubmission). See the
Presentation Exchange plugin README
for the complete PresentationDefinition schema and matcher options.
HAIP Profile Validation
The High Assurance Interoperability Profile (HAIP) tightens OID4VP to a high-assurance subset:
client_id_schemein {did,x509_san_dns,verifier_attestation}response_modein {direct_post,direct_post.jwt} with aresponse_uri- A non-empty
nonce - Either
presentation_definitionordcql_querypresent - Credential formats limited to
vc+sd-jwtandmso_mdoc
Enable enforcement on every parsed request by constructing the service with
haipMode = true, or run a one-off check:
1
2
3
4
5
6
7
8
9
10
11
12
import org.trustweave.credential.oidc4vp.haip.HaipProfileValidator
import org.trustweave.credential.oidc4vp.exception.Oidc4VpException
val violations = HaipProfileValidator.validateAuthorizationRequest(
permissionRequest.authorizationRequest,
)
if (violations.isNotEmpty()) {
throw Oidc4VpException.HaipViolationException(violations)
}
// Format-only check (e.g., when inspecting verifier metadata):
HaipProfileValidator.validateFormat("vc+sd-jwt") // returns null when compliant
When haipMode = true, Oidc4VpService.parseAuthorizationUrl() throws
Oidc4VpException.HaipViolationException before any further processing.
SIOPv2 + OIDC4VP Combined Flow
OID4VP composes with SIOPv2: a single authorization request can
specify response_type=vp_token id_token and the wallet returns both tokens in
the same direct_post. Register both protocols on the same ExchangeService
and dispatch by protocolName. See the SIOPv2 doc for the id_token issuance
and the combined response shape.
Error Handling
Oidc4VpService throws subclasses of Oidc4VpException (which extends
TrustWeaveException):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import org.trustweave.credential.oidc4vp.exception.Oidc4VpException
import kotlinx.coroutines.runBlocking
runBlocking {
try {
val request = oidc4vpService.parseAuthorizationUrl(authorizationUrl)
// ...
} catch (e: Oidc4VpException.UrlParseFailed) {
println("Bad URL '${e.url}': ${e.reason}")
} catch (e: Oidc4VpException.AuthorizationRequestFetchFailed) {
println("Could not fetch request_uri '${e.requestUri}': ${e.reason}")
} catch (e: Oidc4VpException.MetadataFetchFailed) {
println("Could not fetch verifier metadata from '${e.verifierUrl}': ${e.reason}")
} catch (e: Oidc4VpException.HttpRequestFailed) {
println("HTTP ${e.statusCode} from ${e.url}: ${e.reason}")
} catch (e: Oidc4VpException.PresentationSubmissionFailed) {
println("Submission to ${e.verifierUrl} failed: ${e.reason}")
} catch (e: Oidc4VpException.HaipViolationException) {
e.violations.forEach { println("HAIP violation on ${it.field}: ${it.message}") }
}
}
When going through the unified ExchangeService, results come back as
ExchangeResult<T> and you pattern-match on Failure variants:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.trustweave.credential.exchange.result.ExchangeResult
when (val result = exchangeService.requestProof(req)) {
is ExchangeResult.Success -> handle(result.value)
is ExchangeResult.Failure.ProtocolNotSupported ->
println("oidc4vp not registered; available: ${result.availableProtocols}")
is ExchangeResult.Failure.OperationNotSupported ->
println("${result.protocolName} does not support ${result.operation}")
is ExchangeResult.Failure.InvalidRequest ->
println("Invalid '${result.field}': ${result.reason}")
is ExchangeResult.Failure.MessageNotFound ->
println("Missing message id=${result.messageId}")
is ExchangeResult.Failure.NetworkError ->
println("Network error: ${result.reason}")
is ExchangeResult.Failure.Unknown ->
println("Unknown error: ${result.reason}")
}
Limitations
- Wallet/holder-centric: verifier-side authorization endpoint, request signing, and JAR (JWT-Secured Authorization Request) packaging must be implemented by the verifier service.
vp_tokengeneration uses a simplified embedded-JWT layout signed with the KMS-resolved holder key. For full W3C VC 1.1/2.0 VP serialization and SD-JWT selective disclosure, build theVerifiablePresentationyourself (e.g., via the SD-JWT plugin) and pass it intocreatePermissionResponse.dcql_queryis parsed and forwarded verbatim; no built-in DCQL evaluator is bundled.Oidc4VpExchangeProtocol.capabilities.supportedOperationsis{REQUEST_PROOF, PRESENT_PROOF}only —OFFER_CREDENTIAL,REQUEST_CREDENTIAL, andISSUE_CREDENTIALthrowInvalidOperation. Use the OID4VCI plugin for issuance.capabilities.supportsAsyncisfalse; long-running async response flows must be coordinated externally.