Self-Issued OpenID Provider v2 (SIOPv2) implementation for TrustWeave.
Overview
SIOPv2 is the OpenID specification that lets a wallet act as its own OpenID Provider. Instead of a centralized IdP, the wallet self-issues an id_token whose iss and sub are the holder’s DID and which is signed by a key the wallet controls. Verifiers may also request a Verifiable Presentation (vp_token) alongside or instead of the id_token using OpenID for Verifiable Presentations (OID4VP) constructs such as presentation_definition and presentation_submission.
This plugin exposes SIOPv2 both as a standalone service (SiopV2Service) and through TrustWeave’s unified CredentialExchangeProtocol SPI so that a wallet or verifier can drive it via the protocol-agnostic ExchangeService.
Features
Authorization request creation (verifier side)
Authorization request parsing from URL or request_uri reference (wallet side)
Authorization response building with wallet-issued id_token and/or vp_token
Direct-post response submission to the verifier’s response_uri
In-memory session tracking by sessionId
Cross-device (QR / request_uri) and same-device (deep link) flows
SPI auto-discovery via ServiceLoader under the name siop-v2
Integration with TrustWeave’s ExchangeService for requestProof / presentProof
importokhttp3.OkHttpClientimportorg.trustweave.credential.exchange.ExchangeServicesimportorg.trustweave.credential.exchange.registry.ExchangeProtocolRegistriesimportorg.trustweave.credential.siop.SiopV2Configimportorg.trustweave.credential.siop.SiopV2Serviceimportorg.trustweave.credential.siop.exchange.SiopV2ExchangeProtocolvalkms=// Your KeyManagementService instancevalcredentialService=// Your CredentialService instancevaldidResolver=// Your DidResolver instancevalhttpClient=OkHttpClient()valsiopV2Service=SiopV2Service(kms=kms,config=SiopV2Config(),httpClient=httpClient,)valprotocol=SiopV2ExchangeProtocol(siopV2Service)valregistry=ExchangeProtocolRegistries.default()registry.register(protocol)valexchangeService=ExchangeServices.createExchangeService(protocolRegistry=registry,credentialService=credentialService,didResolver=didResolver,)
Alternatively, the plugin auto-registers via ServiceLoader. The provider name is "siop-v2", and it expects a KeyManagementService under option key "kms" (and an optional OkHttpClient under "httpClient"):
importkotlinx.coroutines.runBlockingimportorg.trustweave.credential.siop.SiopV2Configimportorg.trustweave.credential.siop.SiopV2Serviceimportorg.trustweave.testkit.kms.InMemoryKeyManagementServicefunmain()=runBlocking{valkms=InMemoryKeyManagementService()valservice=SiopV2Service(kms=kms,config=SiopV2Config())valsession=service.createAuthorizationRequest(clientId="did:key:z6Mkw...verifier",responseUri="https://verifier.example.com/siop/response",responseType="vp_token id_token",// presentationDefinition = pexDefinition, // optional)// Persist session.sessionId for later correlation; render the request// as a deep link (same-device) or QR code (cross-device).println("sessionId = ${session.sessionId}")println("nonce = ${session.request.nonce}")println("state = ${session.request.state}")}
createAuthorizationRequest returns a SiopV2Session that bundles a generated sessionId with the underlying SiopV2AuthorizationRequest. The session is also stored in the service’s in-memory map so the wallet-side code can look it up later via getSession(sessionId).
importkotlinx.coroutines.runBlockingimportorg.trustweave.credential.siop.SiopV2Serviceimportorg.trustweave.kms.Algorithmimportorg.trustweave.kms.results.GenerateKeyResultimportorg.trustweave.testkit.kms.InMemoryKeyManagementServicefunmain()=runBlocking{valkms=InMemoryKeyManagementService()valservice=SiopV2Service(kms=kms)valkeyHandle=when(valr=kms.generateKey(Algorithm.Ed25519)){isGenerateKeyResult.Success->r.keyHandleelse->error("Key generation failed: $r")}valholderDid="did:key:z6Mkp...holder"// Same-device deep link (or scanned from a cross-device QR that carries// request_uri=...; parseAuthorizationRequest follows either form).valauthorizationUrl="openid-vc://?response_type=id_token"+"&client_id=did:key:z6Mkw...verifier"+"&client_id_scheme=did"+"&response_uri=https://verifier.example.com/siop/response"+"&nonce=abc123"valsession=service.parseAuthorizationRequest(authorizationUrl)valresponse=service.buildAuthorizationResponse(session=session,holderDid=holderDid,keyId=keyHandle.id.value,// presentation = vp, // required if responseType contains vp_token// presentationSubmission = submission,)service.submitResponse(session,response)}
The wallet-issued id_token is a compact JWT whose payload carries iss = sub = holderDid, aud = clientId, iat, exp (10 minutes), and the original nonce. When response_type contains vp_token and a VerifiablePresentation is supplied, vp_token is a JWT-VP wrapping the presentation under the standard vp claim.
Using the Unified ExchangeService
SiopV2ExchangeProtocol exposes REQUEST_PROOF and PRESENT_PROOF through the protocol-agnostic API. Issuance operations (offer/request/issue) are intentionally not supported and will surface as ExchangeResult.Failure from the service.
importkotlinx.coroutines.runBlockingimportkotlinx.serialization.json.JsonPrimitiveimportorg.trustweave.credential.exchange.options.ExchangeOptionsimportorg.trustweave.credential.exchange.request.ProofExchangeRequestimportorg.trustweave.credential.exchange.request.ProofRequestimportorg.trustweave.credential.exchange.result.ExchangeResultimportorg.trustweave.credential.identifiers.RequestIdimportorg.trustweave.credential.identifiers.requireExchangeProtocolNameimportorg.trustweave.did.identifiers.Didfunmain()=runBlocking{// Verifier: ask for a proof.valrequestResult=exchangeService.requestProof(ProofExchangeRequest.Request(protocolName="siop-v2".requireExchangeProtocolName(),verifierDid=Did("did:key:z6Mkw...verifier"),proverDid=Did("did:key:z6Mkp...holder"),proofRequest=ProofRequest(name="login",requestedAttributes=emptyMap(),),options=ExchangeOptions.builder().addMetadata("responseUri",JsonPrimitive("https://verifier.example.com/siop/response")).addMetadata("clientId",JsonPrimitive("did:key:z6Mkw...verifier")).build(),),)valresponse=when(requestResult){isExchangeResult.Success->requestResult.valueisExchangeResult.Failure->error("requestProof failed: ${requestResult.errors}")}// sessionId is round-tripped through messageEnvelope.metadata; the wallet// side uses it as the requestId on the presentation step. (response.requestId// is a separate UUID minted by ExchangeService and is NOT the SIOPv2 sessionId.)valsessionId=(response.messageEnvelope.metadata["sessionId"]asJsonPrimitive).content}
importkotlinx.coroutines.runBlockingimportkotlinx.serialization.json.JsonPrimitiveimportorg.trustweave.core.identifiers.Iriimportorg.trustweave.credential.exchange.options.ExchangeOptionsimportorg.trustweave.credential.exchange.request.ProofExchangeRequestimportorg.trustweave.credential.exchange.result.ExchangeResultimportorg.trustweave.credential.identifiers.RequestIdimportorg.trustweave.credential.identifiers.requireExchangeProtocolNameimportorg.trustweave.credential.model.CredentialTypeimportorg.trustweave.credential.model.vc.VerifiablePresentationimportorg.trustweave.did.identifiers.Didfunmain()=runBlocking{// Wallet: present the proof.valpresentation=VerifiablePresentation(type=listOf(CredentialType.fromString("VerifiablePresentation")),holder=Iri("did:key:z6Mkp...holder"),// holder is Iri, not DidverifiableCredential=listOf(/* credentials */),)valpresentResult=exchangeService.presentProof(ProofExchangeRequest.Presentation(protocolName="siop-v2".requireExchangeProtocolName(),proverDid=Did("did:key:z6Mkp...holder"),verifierDid=Did("did:key:z6Mkw...verifier"),presentation=presentation,requestId=RequestId(sessionId),options=ExchangeOptions.builder().addMetadata("keyId",JsonPrimitive("holder-key-id")).build(),),)when(presentResult){isExchangeResult.Success->println("Presented: ${presentResult.value}")isExchangeResult.Failure->error("presentProof failed: ${presentResult.errors}")}}
SiopV2Service raises a single exception type, SiopV2Exception, with the following codes:
Code
Source
Meaning
FETCH_FAILED
parseAuthorizationRequest
The request_uri returned an empty body.
NO_RESPONSE_URI
submitResponse
The parsed request had no response_uri.
SUBMISSION_FAILED
submitResponse
The verifier returned a non-2xx HTTP response.
SIGN_FAILED
buildAuthorizationResponse
The KMS signing call failed (key not found, unsupported algorithm, or generic error).
1
2
3
4
5
6
7
8
9
10
11
12
importorg.trustweave.credential.siop.SiopV2Exceptiontry{service.submitResponse(session,response)}catch(e:SiopV2Exception){when(e.code){"NO_RESPONSE_URI"->println("Verifier request had no response_uri")"SUBMISSION_FAILED"->println("Verifier rejected the response: ${e.message}")"SIGN_FAILED"->println("Could not sign the token: ${e.message}")else->throwe}}
When you drive SIOPv2 through ExchangeService, failures surface as ExchangeResult.Failure instead. The sealed variants are ProtocolNotSupported, OperationNotSupported, InvalidRequest, MessageNotFound, NetworkError, and Unknown:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
importorg.trustweave.credential.exchange.result.ExchangeResultwhen(valr=exchangeService.requestProof(req)){isExchangeResult.Success->handle(r.value)isExchangeResult.Failure.ProtocolNotSupported->println("siop-v2 not registered; available: ${r.availableProtocols}")isExchangeResult.Failure.OperationNotSupported->println("Operation ${r.operation} not supported by ${r.protocolName.value}")isExchangeResult.Failure.InvalidRequest->println("Invalid ${r.field}: ${r.reason}")isExchangeResult.Failure.MessageNotFound->println("No SIOPv2 session for ${r.messageId}")isExchangeResult.Failure.NetworkError->println("Transport failed: ${r.reason}")isExchangeResult.Failure.Unknown->println("Unexpected error: ${r.reason}")}
Limitations
The current SiopV2Service is intentionally minimal and ships with the following known gaps:
No inbound verification. The service signs and submits responses, but the verifier-side JWT signature check, DID resolution, presentation-submission matching, and replay protection (nonce/state correlation) are left to the consuming application.
In-memory session store. Sessions are kept in a ConcurrentHashMap inside SiopV2Service and are lost when the process restarts. Production deployments should plug in a persistent store.
EdDSA-only signing. The JWT header is hardcoded to alg=EdDSA; the algorithm lists in SiopV2Config are advertised metadata only.
No JAR / signed Request Object validation. Authorization request objects fetched from request_uri are parsed as plaintext JSON; JWS-wrapped JAR requests are not validated.
No encrypted responses.direct_post.jwt is declared in the SiopResponseMode enum but only plain direct_post JSON is sent by submitResponse.
Presentation Exchange is pass-through. A supplied presentation_definition is carried through verbatim; matching candidate credentials against the definition is the caller’s responsibility (see the presentation-exchange plugin).
Configuration
SiopV2Config controls service defaults:
Field
Default
Purpose
defaultClientIdScheme
SiopClientIdScheme.DID
Value stamped into client_id_scheme on outbound authorization requests. Other options: PRE_REGISTERED, REDIRECT_URI, ENTITY_ID.
supportedResponseModes
[DIRECT_POST]
Response modes the verifier advertises. Also defined: DIRECT_POST_JWT, FRAGMENT, FORM_POST.
idTokenSigningAlgorithms
["EdDSA", "ES256"]
Advertised id_token signing algorithms.
vpTokenSigningAlgorithms
["EdDSA", "ES256"]
Advertised vp_token signing algorithms.
requestUriTimeoutSeconds
300
Lifetime of a request_uri-referenced request object.