OIDC4VCI Plugin
OpenID Connect for Verifiable Credential Issuance (OIDC4VCI) implementation for TrustWeave.
Overview
OIDC4VCI is a protocol that enables credential issuance using OpenID Connect flows. It provides a standardized way for issuers to offer credentials and for holders to request and receive them.
Features
- Credential offer creation
- Credential request handling
- Credential issuance
- Credential issuer metadata discovery
- Integration with protocol abstraction layer
Architecture
1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────┐
│ Oidc4VciExchangeProtocol │
│ (Implements CredentialExchangeProtocol) │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Oidc4VciService │
│ - createCredentialOffer() │
│ - createCredentialRequest() │
│ - issueCredential() │
└─────────────────────────────────────┘
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
28
29
30
import org.trustweave.credential.oidc4vci.Oidc4VciService
import org.trustweave.credential.oidc4vci.exchange.Oidc4VciExchangeProtocol
import org.trustweave.credential.exchange.*
import okhttp3.OkHttpClient
import org.trustweave.credential.exchange.registry.ExchangeProtocolRegistries
import org.trustweave.credential.exchange.ExchangeServices
val kms = // Your KMS instance
val httpClient = OkHttpClient()
val oidc4vciService = Oidc4VciService(
credentialIssuerUrl = "https://issuer.example.com",
kms = kms,
httpClient = httpClient,
// Required to verify the credential the issuer returns. Without a resolver,
// issuance fails closed rather than trusting an unverified credential.
didResolver = didResolver
)
val protocol = Oidc4VciExchangeProtocol(oidc4vciService)
val registry = ExchangeProtocolRegistries.default()
registry.register(protocol)
val exchangeService = ExchangeServices.createExchangeService(
protocolRegistry = registry,
credentialService = credentialService,
didResolver = didResolver
)
Credential Offer
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
import org.trustweave.credential.exchange.*
import org.trustweave.credential.exchange.request.ExchangeRequest
import org.trustweave.credential.exchange.result.ExchangeResult
import org.trustweave.credential.exchange.options.ExchangeOptions
import org.trustweave.credential.identifiers.*
import org.trustweave.did.identifiers.Did
import kotlinx.serialization.json.JsonPrimitive
val offerResult = exchangeService.offer(
ExchangeRequest.Offer(
protocolName = "oidc4vci".requireExchangeProtocolName(),
issuerDid = Did("did:key:issuer"),
holderDid = Did("did:key:holder"),
credentialPreview = CredentialPreview(
attributes = listOf(
CredentialAttribute("name", "Alice"),
CredentialAttribute("email", "alice@example.com")
)
),
options = ExchangeOptions.builder()
.addMetadata("credentialIssuer", "https://issuer.example.com")
.addMetadata("credentialTypes", JsonPrimitive("VerifiableCredential,PersonCredential"))
.addMetadata("grants", JsonPrimitive("authorization_code"))
.addMetadata("issuer_state", JsonPrimitive("state123"))
.build()
)
)
val offer = when (offerResult) {
is ExchangeResult.Success -> offerResult.value
else -> throw IllegalStateException("Offer failed: $offerResult")
}
// The offer's protocol-agnostic envelope holds the OIDC4VCI offer JSON
val messageData = offer.messageEnvelope.messageData // JsonElement
// The plugin serializes Oidc4VciOffer here; parse out e.g. offerUri:
// Format: openid-credential-offer://?credential_issuer=...
Credential Request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
val requestResult = exchangeService.request(
ExchangeRequest.Request(
protocolName = "oidc4vci".requireExchangeProtocolName(),
holderDid = Did("did:key:holder"),
issuerDid = Did("did:key:issuer"),
offerId = offer.offerId,
options = ExchangeOptions.builder()
.addMetadata("redirectUri", "https://holder.example.com/callback")
.build()
)
)
val credentialRequest = when (requestResult) {
is ExchangeResult.Success -> requestResult.value
else -> throw IllegalStateException("Request failed: $requestResult")
}
Credential Issuance
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
import org.trustweave.credential.model.vc.VerifiableCredential
import org.trustweave.credential.model.vc.CredentialSubject
import org.trustweave.credential.model.vc.Issuer
import org.trustweave.credential.model.CredentialType
import org.trustweave.core.identifiers.Iri
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.datetime.Clock
val credential = VerifiableCredential(
type = listOf(CredentialType.fromString("VerifiableCredential"), CredentialType.fromString("PersonCredential")),
issuer = Issuer.IriIssuer(Iri("did:key:issuer")),
issuanceDate = Clock.System.now(),
credentialSubject = CredentialSubject(
id = Iri("did:key:holder"), // CredentialSubject.id is Iri, not Did
claims = mapOf(
"name" to JsonPrimitive("Alice"),
"email" to JsonPrimitive("alice@example.com")
)
)
)
val issueResult = exchangeService.issue(
ExchangeRequest.Issue(
protocolName = "oidc4vci".requireExchangeProtocolName(),
issuerDid = Did("did:key:issuer"),
holderDid = Did("did:key:holder"),
credential = credential,
requestId = credentialRequest.requestId,
options = ExchangeOptions.builder().build()
)
)
val issue = when (issueResult) {
is ExchangeResult.Success -> issueResult.value
else -> throw IllegalStateException("Issue failed: $issueResult")
}
Issued-Credential Verification
Oidc4VciService parses and cryptographically verifies the credential the issuer
returns, before handing it back:
- the proof is verified against the issuer DID resolved through
didResolver; credential.issuermust equal the issuer the offer was pinned to;credentialSubject.idmust equal the holder DID.
Any failure — including no didResolver being configured — raises
Oidc4VciException.CredentialVerificationFailed. The service never returns a credential
it could not verify.
Note that the credential field on ExchangeRequest.Issue is not what comes back. The
returned credential is the issuer’s own, freshly verified; the request field is carried by
the generic exchange API and is unused by this protocol.
Only VC-LD (JSON-LD) credentials are verified today. Compact formats (JWT, SD-JWT-VC) are
rejected rather than trusted, because credential-api has no compact-to-model parser yet.
Deferred issuance (pollDeferredCredential) runs a returned credential through the same
checks. Supply issuerDid and holderDid on DeferredCredentialRequest so it has
something to verify against; without them, a returned credential is refused.
OIDC4VCI Flow
- Credential Offer: Issuer creates an offer URI or object
- Authorization: Holder authorizes the request (if using authorization code flow)
- Token Exchange: Holder exchanges authorization code for access token
- Credential Request: Holder requests credential with proof of possession
- Credential Issuance: Issuer issues credential via credential endpoint
Protocol-Specific Options
Credential Offer Options
credentialIssuer(required): URL of the credential issuercredentialTypes: List of credential types to offergrants: Grant types (e.g.,authorization_code)
Credential Request Options
redirectUri: Redirect URI for authorization code flow
Credential Issuer Metadata
The service can fetch credential issuer metadata from /.well-known/openid-credential-issuer:
1
2
3
4
5
6
7
val metadata = oidc4vciService.fetchCredentialIssuerMetadata(
credentialIssuerUrl = "https://issuer.example.com"
)
println("Credential endpoint: ${metadata.credentialEndpoint}")
println("Token endpoint: ${metadata.tokenEndpoint}")
println("Supported formats: ${metadata.credentialConfigurationsSupported.keys}")
Integration with walt.id Library
This implementation is designed to work with walt.id’s waltid-openid4vc library. To enable full integration:
- Uncomment the dependency in
build.gradle.kts:1
implementation("id.walt:waltid-openid4vc:1.0.0")
- Update
Oidc4VciServiceto use walt.id’s classes directly
Limitations
- Proof requests and presentations are not supported (use DIDComm or OIDC4VP)
- Only VC-LD issued credentials are verified; compact JWT / SD-JWT-VC responses are
rejected until
credential-apigains a compact-to-model parser - Issuance fails closed without a
didResolver, so the service cannot be used to fetch credentials you intend to verify yourself later