Skip to main content
Version: v0.25.0 (Latest)

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:

Auth Bridge Fast Path

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.

Auth Bridge IDV Flow

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

Auth Session Status Transitions
StatusMeaning
PENDINGQR code generated, waiting for wallet scan
INTERACTION_STARTEDWallet retrieved the authorization request
VERIFIEDCredentials verified by Universal OID4VP
IDV_REQUIREDIdentity verification needed before completion
COMPLETEDAuthentication complete, claims available
EXPIREDSession TTL exceeded (default 5 minutes)
ERRORFlow failed

IDV Requirement Reasons

When the session transitions to IDV_REQUIRED, the reason indicates why:

ReasonDescription
FIRST_TIME_LINKNew holder, no binding exists
EXPIRED_BINDINGBinding found but expired
CANDIDATE_MATCH_CONFIRMATIONMultiple candidate matches need disambiguation
FORCED_RECONCILIATIONforceReconciliation flag was set on session creation

HTTP Endpoints

The bridge exposes a set of HTTP endpoints via CommandBackedHttpAdapter:

Session Management

MethodPathDescription
POST/auth/oid4vp/sessionsCreate a new auth session, returns QR code and status URI
GET/auth/oid4vp/sessions/{sessionId}/statusPoll session status
POST/auth/oid4vp/sessions/{sessionId}/completeComplete authentication, get OIDC claims

IDV Integration

MethodPathDescription
POST/auth/oid4vp/sessions/{sessionId}/idv/initiateStart IDV flow, get OIDC redirect URL
GET/auth/oid4vp/sessions/{sessionId}/idv/statusCheck IDV status
GET/auth/oid4vp/idv/callbackOIDC 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 SourceMeaning
WALLET_ONLYClaims projected directly from wallet credentials
CANONICAL_BINDINGClaims 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 bindingPersistent identifierKey fingerprint role
Rotatable DID (did:web, did:ion, did:tdw)The DID itself, survives key rotation via DID document updatesFast-path cache only
Key-bound DID (did:key, did:jwk)The key fingerprint, the DID IS the key, so rotation produces a new DIDPrimary identifier
cnf.jwk (no DID)The key fingerprint, no persistent identifier existsPrimary identifier
X.509 certificateSubject DN (optionally scoped by Issuer DN), survives certificate renewalFast-path cache only

Extraction sources (first available):

  1. VP proof signature, key + optional DID from verification method reference
  2. cnf.jwk confirmation claim
  3. DID document resolution, match against proof signature's kid
  4. X.509 SubjectPublicKeyInfo from certificate-bound credentials

Canonicalization and hashing:

  1. Canonicalize the key material (RFC 7638 JWK thumbprint for JWK, DER-encoded SubjectPublicKeyInfo for X.509)
  2. HMAC hash via ReconciliationCryptoService.hashHolderKey() using the tenant-scoped pepper
  3. 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
PropertyDefaultDescription
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-seconds300Session expiration (5 minutes)
auto-create-usertrueCreate user in user service if not found
require-reconciliationfalseEnforce 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:

idvRequirementReasonreconciliationPlanTypeUser-facing meaning
FIRST_TIME_LINKRUN_IDVNew holder, no binding exists, full verification required
EXPIRED_BINDINGSTEP_UPPrevious binding expired, re-verification needed
CANDIDATE_MATCH_CONFIRMATIONSTEP_UPClaim-tuple match found but needs confirmation via institutional login
FORCED_RECONCILIATIONRUN_IDVforceReconciliation=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:

ServicePortRole
service-auth-bridge8090OID4VP verification, reconciliation, IDV flow, claims mapping, external API
service-sts8080OAuth2 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:

  1. Keycloak: Custom authenticator SPI that calls the bridge endpoints
  2. Auth0: Custom Action that redirects through the bridge
  3. 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.