Auth Bridge
The OID4VP Authentication Bridge connects wallet-based credential presentation to OAuth2/OIDC authorization servers. It sits between an authorization server (internal or external, Keycloak, Auth0, Azure AD) and the user's wallet, handling the full flow from QR code generation through credential verification, identity matching, optional IDV, and user resolution.
The result is standard OIDC claims that the authorization server can use to issue tokens, the authorization server doesn't need to know anything about wallets or verifiable credentials.
Flow Overview
Fast Path (Known Holder)
When the user's wallet key already has a binding in the identity matching store, the bridge skips IDV entirely and serves cached attributes:
IDV Path (Unknown Holder)
When no binding exists or the binding has expired, the reconciliation selector evaluates its rules and produces a plan, RunIdv, StepUp, or FailClosed. For RunIdv and StepUp, the user is redirected to an identity verification flow (OIDC login, document scan, email OTP, or any configured IDV method). On completion, the wallet attributes are merged with the verification results, a new identity match and encrypted binding are created, and the result flows back to the authorization server as OIDC claims.
Session Lifecycle
data class Oid4vpAuthSession(
val sessionId: String,
val correlationId: String, // Universal OID4VP correlation ID
val oauthSessionId: String?, // Linked OAuth2 session
val queryId: String, // Credential query ID
val status: Oid4vpAuthSessionStatus,
val verifiedData: VerifiedData?, // Verified credentials (when VERIFIED)
val resolvedUserId: String?, // Resolved user (when COMPLETED)
val reconciliationSessionId: String?, // IDV session link
val holderIdentifierHash: String?, // Cryptographic holder binding
val knownHolderState: KnownHolderState?,
val idvRequirementReason: IdvRequirementReason?,
val createdAt: Instant,
val expiresAt: Instant,
)
Status Transitions
| Status | Meaning |
|---|---|
PENDING | QR code generated, waiting for wallet scan |
INTERACTION_STARTED | Wallet retrieved the authorization request |
VERIFIED | Credentials verified by Universal OID4VP |
IDV_REQUIRED | Identity verification needed before completion |
COMPLETED | Authentication complete, claims available |
EXPIRED | Session TTL exceeded (default 5 minutes) |
ERROR | Flow failed |
IDV Requirement Reasons
When the session transitions to IDV_REQUIRED, the reason indicates why:
| Reason | Description |
|---|---|
FIRST_TIME_LINK | New holder, no binding exists |
EXPIRED_BINDING | Binding found but expired |
CANDIDATE_MATCH_CONFIRMATION | Multiple candidate matches need disambiguation |
FORCED_RECONCILIATION | forceReconciliation flag was set on session creation |
HTTP Endpoints
The bridge exposes a set of HTTP endpoints via CommandBackedHttpAdapter:
Session Management
| Method | Path | Description |
|---|---|---|
POST | /auth/oid4vp/sessions | Create a new auth session, returns QR code and status URI |
GET | /auth/oid4vp/sessions/{sessionId}/status | Poll session status |
POST | /auth/oid4vp/sessions/{sessionId}/complete | Complete authentication, get OIDC claims |
IDV Integration
| Method | Path | Description |
|---|---|---|
POST | /auth/oid4vp/sessions/{sessionId}/idv/initiate | Start IDV flow, get OIDC redirect URL |
GET | /auth/oid4vp/sessions/{sessionId}/idv/status | Check IDV status |
GET | /auth/oid4vp/idv/callback | OIDC callback handler (receives auth code from institutional provider) |
Session Creation
POST /auth/oid4vp/sessions
Content-Type: application/json
{
"queryId": "pid-query",
"oauthSessionId": "keycloak-session-123",
"forceReconciliation": false
}
Response:
{
"sessionId": "abc-123",
"qrCodeDataUri": "data:image/png;base64,...",
"requestUri": "openid4vp://authorize?request_uri=...",
"statusUri": "/auth/oid4vp/sessions/abc-123/status",
"qrPageUri": "/auth/oid4vp/sessions/abc-123/qr"
}
Authentication Result
data class Oid4vpAuthResult(
val userId: String,
val claims: Map<String, JsonElement>,
val jwtClaims: String?, // Pre-built JWT payload
val isNewUser: Boolean,
val authenticatedAt: Instant,
val acr: String = "urn:sphereon:oid4vp:vp", // Auth Context Class Reference
val amr: List<String> = listOf("vp"), // Auth Method References
val claimSource: ClaimSource, // WALLET_ONLY or CANONICAL_BINDING
)
| Claim Source | Meaning |
|---|---|
WALLET_ONLY | Claims projected directly from wallet credentials |
CANONICAL_BINDING | Claims from a reconciled identity link binding (higher trust, includes institutional verification) |
When the claim source is CANONICAL_BINDING, the acr and amr values reflect the institutional verification, for example, acr might be urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport and amr might include ["pwd", "mfa"].
Reconciliation Integration
The bridge delegates identity matching and reconciliation to the ReconciliationOrchestratorApi:
interface ReconciliationOrchestratorApi {
// Check if the holder key has a known binding
suspend fun resolveKnownHolder(
holderKeyHash: String,
tenantId: String,
rawHolderKey: String? = null,
walletAttributes: Map<String, JsonElement>? = null,
): IdkResult<ResolvedKnownHolder?, IdkError>
// Start an IDV/reconciliation flow
suspend fun initiateReconciliation(
oid4vpSessionId: String,
redirectUri: String,
): IdkResult<ReconciliationInitiateResult, IdkError>
// Handle the OIDC callback from the institutional provider
suspend fun handleCallback(
code: String,
state: String,
walletAttributes: Map<String, JsonElement> = emptyMap(),
): IdkResult<ReconciliationCallbackResult, IdkError>
}
Known Holder Resolution
data class ResolvedKnownHolder(
val userId: String,
val bindingId: String,
val matchId: String,
val canonicalAttributes: Map<String, JsonElement>,
val assurance: AssuranceSummary?,
val state: KnownHolderState = KnownHolderState.MATCHED_HOLDER_KEY,
)
When a known holder is found, the bridge skips IDV entirely, it uses the cached canonical attributes and assurance metadata from the binding. This is the fast path that avoids any external calls.
Holder Key Extraction
The bridge extracts a persistent identifier from the VP token. The extraction strategy depends on the holder binding type:
| Holder binding | Persistent identifier | Key fingerprint role |
|---|---|---|
Rotatable DID (did:web, did:ion, did:tdw) | The DID itself, survives key rotation via DID document updates | Fast-path cache only |
Key-bound DID (did:key, did:jwk) | The key fingerprint, the DID IS the key, so rotation produces a new DID | Primary identifier |
cnf.jwk (no DID) | The key fingerprint, no persistent identifier exists | Primary identifier |
| X.509 certificate | Subject DN (optionally scoped by Issuer DN), survives certificate renewal | Fast-path cache only |
Extraction sources (first available):
- VP proof signature, key + optional DID from verification method reference
cnf.jwkconfirmation claim- DID document resolution, match against proof signature's
kid - X.509 SubjectPublicKeyInfo from certificate-bound credentials
Canonicalization and hashing:
- Canonicalize the key material (RFC 7638 JWK thumbprint for JWK, DER-encoded SubjectPublicKeyInfo for X.509)
- HMAC hash via
ReconciliationCryptoService.hashHolderKey()using the tenant-scoped pepper - Look up the hash in the identity matching store
For rotatable DID methods, if a key fingerprint lookup misses but a DID is present, the system resolves the DID document and checks whether the presenting key is a current key, if so, it's the same identity with a rotated key (no full reconciliation needed, just add the new key fingerprint to the fast-path cache).
If the wallet doesn't include a holder key (e.g., bearer credentials without key binding), the bridge falls back to claim-based matching using AttributeTupleMaterial.
Configuration
oid4vp:
auth-bridge:
# Verifier identity
client-id: "did:web:verifier.example.com"
response-uri: "https://api.example.com/auth/oid4vp/response"
# Credential queries
default-query-id: "pid-query"
# Session settings
session-ttl-seconds: 300
auto-create-user: true
require-reconciliation: false # Set to true to enforce holder binding
user-identifier-claim-path: "sub"
# Universal OID4VP service
universal-api:
base-url: "https://oid4vp.example.com"
connection-timeout-ms: 30000
request-timeout-ms: 120000
# User microservice
user-api:
base-url: "https://users.example.com/api/v1"
connection-timeout-ms: 30000
request-timeout-ms: 60000
| Property | Default | Description |
|---|---|---|
client-id | - | Verifier identity (DID or URI) for OID4VP requests |
response-uri | - | direct_post callback URI for wallet responses |
default-query-id | - | Default credential query ID if not specified per-session |
default-dcql-query | - | Inline DCQL query JSON (fallback) |
session-ttl-seconds | 300 | Session expiration (5 minutes) |
auto-create-user | true | Create user in user service if not found |
require-reconciliation | false | Enforce cryptographic holder binding + IDV for all sessions |
user-identifier-claim-path | "sub" | JSON path to extract user ID from verified credentials |
Step-Up Contract
When the session transitions to IDV_REQUIRED, the status endpoint exposes structured metadata so the frontend can render appropriate UI:
idvRequirementReason | reconciliationPlanType | User-facing meaning |
|---|---|---|
FIRST_TIME_LINK | RUN_IDV | New holder, no binding exists, full verification required |
EXPIRED_BINDING | STEP_UP | Previous binding expired, re-verification needed |
CANDIDATE_MATCH_CONFIRMATION | STEP_UP | Claim-tuple match found but needs confirmation via institutional login |
FORCED_RECONCILIATION | RUN_IDV | forceReconciliation=true was set on session creation |
The StepUp plan uses the same OIDC redirect mechanism as RunIdv, the difference is in the callback behavior: StepUp updates the existing binding rather than creating a new one. The API exposes the distinction so policy and UX can treat them differently, even though the redirect flow is the same.
Service Architecture
In a typical deployment, the auth bridge runs as a dedicated service alongside the authorization server:
| Service | Port | Role |
|---|---|---|
| service-auth-bridge | 8090 | OID4VP verification, reconciliation, IDV flow, claims mapping, external API |
| service-sts | 8080 | OAuth2 authorization server, federation login, wallet login via auth-bridge HTTP calls |
The auth bridge uses PostgreSQL via SQLDelight for all reconciliation persistence (identity matches, link bindings, reconciliation sessions, auxiliary data). See Matching & Reconciliation for the database schema and external API details.
Token enrichment bridges the two services: when the STS mints tokens, the TokenEnrichmentService reads canonical claims and auxiliary data from the auth bridge's database, merging them into the token's claim set.
Integration with Authorization Servers
The auth bridge is designed to be called by an authorization server during its authentication flow. The typical integration pattern:
- Keycloak: Custom authenticator SPI that calls the bridge endpoints
- Auth0: Custom Action that redirects through the bridge
- Custom AS: Direct HTTP calls from the authorization endpoint handler
The bridge returns Oid4vpAuthResult with pre-built OIDC claims that the AS can embed in its ID tokens and access tokens. The acr and amr values are standards-compliant, so downstream relying parties can evaluate authentication strength without knowing the bridge exists.