OID4VP 1.0 verifier (relying party) for the EU Digital Identity Wallet and any wallet that speaks the same dialect. Every endpoint below is reachable on the live host phone.codeb.io and the response shapes are verified against the running build. Wallets and integrators can use this page as the canonical integration reference.
Lead with x509_hash — the wallet‑preferred Client Identifier Prefix.x509_hash (OID4VP 1.0 §5.9.3, base64url SHA‑256 of the DER‑encoded leaf certificate) is the form most real EUDI wallets bind to. The verifier also supports x509_san_dns (HAIP 1.0 Final) as a compatibility option. The caller of vp‑start picks the prefix per session via ?client_id_prefix=x509_hash|x509_san_dns; wallet integrators should explicitly request x509_hash. The server default when the parameter is absent is now x509_hash — aligned with the wallet‑preferred form. Once chosen the prefix is locked onto the session and persisted so the JAR client_id claim and the ExpectedClientId validation stay consistent.
Health check and live verifier metrics. Public, no authentication. Use this from monitoring or to confirm a deployed build version.
Response
JSON body. Field-by-field:
ok — true if the OIDC handler is initialised.
build — build version string of the handler. Format YYYY-MM-DD-slug. Bumped on every meaningful change.
tenant — tenant the request resolved to (multi‑tenancy by domain).
now — current server time, seconds since epoch.
vp_started — counter, total vp‑start sessions created since service start.
vp_completed — counter, total vp‑response verifications that returned ok=true.
vp_abandoned — counter, sessions that started and were never followed by a vp‑response within the TTL (operators can spot the abandonment rate from this).
vp_pending_or_inflight — gauge, sessions currently waiting on a wallet response.
Verifier (relying party) descriptor — lists the cryptographic algorithms, response‑encryption modes, supported credential formats, and the endpoints a wallet should talk to. Mirrors the verifier‑metadata pattern used by EUDI reference deployments.
x509_hash_value — base64url SHA‑256 of the DER‑encoded leaf certificate. This is the primary identifier real EUDI wallets bind to; x509_hash:<hash> is the recommended Client Identifier Prefix form.
client_id_schemes_supported — the full set the verifier will honour per‑request (x509_hash recommended; x509_san_dns for HAIP 1.0 Final compatibility).
client_id — the prefixed identifier returned when no ?client_id_prefix= is supplied to vp‑start. Server default is now x509_hash (wallet‑preferred form).
vp_formats_supported — CodeB ships dc+sd-jwt only. mdoc / ISO mDL is not in scope.
authorization_encrypted_response_alg / _enc — wallets MUST encrypt the VP response (JWE) when the request opts into direct_post.jwt.
Standard OpenID Connect discovery document. Lists OIDC endpoints, claim names, signing algorithms, and the amr / acr values the verifier emits for wallet‑backed sessions. Same handler as the rest of the OIDC IdP — see oidc_api.html for the full envelope; the EU Wallet‑relevant bits are below.
EU Wallet relevant fields
acr_values_supported includes eudi:pid:high, eudi:pid:substantial, urn:codeb:vc:member — the verifier tags the resulting OIDC session with acr=urn:codeb:acr:eudi-wallet when the bearer authenticated via the EU Wallet flow.
amr_values_supported includes the implicit vc — reported in the SSO assertion (see §SSO assertion).
claims_supported covers the standard PID claim shape returned by the EU PID issuer (given_name, family_name, birth_date, email_address, etc.).
Begin a verifiable presentation flow. The caller is typically a CodeB‑hosted login page (logineu.html) or an external integrator. Returns the session id, the URL the wallet will fetch to retrieve the signed request, and a wallet‑invocation deep link.
Request
No body. Query parameters:
client_id_prefix — optional, one of x509_hash (recommended for real EUDI wallets per OID4VP 1.0 §5.9.3) or x509_san_dns (HAIP 1.0 Final compatibility profile). Locks the Client Identifier Prefix scheme onto the session. Server default when omitted: x509_hash.
The chosen prefix is encoded in the client_id value: x509_hash:<base64url‑sha256‑of‑DER‑leaf> (wallet‑preferred) or x509_san_dns:phone.codeb.io (HAIP 1.0 Final). Callers can split at the first : to recover the prefix. status starts at pending and advances as the wallet round‑trips.
Examples
# Wallet‑preferred (x509_hash per OID4VP 1.0 §5.9.3) — recommended for real EUDI wallets.
curl 'https://phone.codeb.io/oidc.ashx?action=vp-start&client_id_prefix=x509_hash'
# Compatibility profile (x509_san_dns per HAIP 1.0 Final).
curl 'https://phone.codeb.io/oidc.ashx?action=vp-start&client_id_prefix=x509_san_dns'
# Without the parameter, the server now defaults to x509_hash (wallet‑preferred).
curl https://phone.codeb.io/oidc.ashx?action=vp-start
Session lifecycle
The session id is opaque, ~16 hex characters, scoped to the tenant. The session record is persisted to disk under App_Data/<tenant>/vp‑sessions/<id>.json so app‑pool recycles and multi‑worker IIS deployments do not lose state. Sessions expire after expires_in seconds; abandoned sessions are counted in the vp_abandoned metric returned by ping.
Wallet fetches this endpoint to obtain the signed authorization request (JAR — JWT‑Secured Authorization Request, RFC 9101). The body is a single ES256‑signed JWT with the verifier‑certificate x5c chain in the JWS header.
Wallet posts the verifiable presentation here. The verifier accepts both wire shapes defined by OID4VP 1.0 §8.4 — pick whichever the wallet emits, the handler auto‑detects.
The <JWE> is encrypted with ECDH‑ES key agreement and A128GCM content encryption, against the ephemeral JWK the verifier shipped in the JAR client_metadata.jwks. The plaintext inside the JWE is the form‑encoded body shown in Shape B.
The vp_token is the SD‑JWT VC with selectively‑disclosed claims and the holder‑binding KB‑JWT appended. Format: <issuer‑signed JWT>~<disclosure1>~<disclosure2>~...~<KB‑JWT>.
redirect — where the calling page should send the browser next. Always /account.html on the default tenant; can be a deeper return-url if the wallet flow was started from one.
sso_assertion — RS256-signed assertion the browser tab plants into sessionStorage and then exchanges for an OIDC code at /oauth2/v1/authorize (see SSO assertion).
sso_max_age — assertion lifetime in seconds.
vct + issuer — credential type and issuer of the SD-JWT VC that was just verified. The PID claims themselves are written to the user record on disk; they are not echoed in the response body to keep it small. Use the resulting OIDC session (after assertion exchange) to query claims via /oauth2/v1/userinfo.
disclosures_verified — count of selectively-disclosed claims whose hash matched the SD-claim digest in the issuer-signed JWT.
Error response
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"ok": false,
"error": "invalid_vp_token",
"error_description": "kb-jwt signature did not verify against holder cnf"
}
Disclosure hash check against the SD claim digests.
KB‑JWT signature verification against the holder‑binding key in cnf.
User identifier binding (one of: userHint, personal_administrative_number, email_address, pid_name_dob_hash, anonymous).
SSO assertion mint (RS256, OIDC‑compatible).
Each gate emits a structured log event keyed by session id (vp‑shape‑detected, vp‑verify‑result, vp‑user‑resolved, vp‑sso‑minted, etc.) so a failing wallet's path can be traced from the OIDC log alone.
The vp‑response success payload includes sso_assertion — a short‑lived RS256‑signed JWT that any CodeB‑federated application can exchange for a standard OIDC session. This is the bridge from "wallet just presented credentials" to "user is now signed in".
Run a standard OIDC Authorization Code + PKCE flow against /oauth2/v1/authorize as documented in oidc_api.html. Add acr_values=urn:codeb:acr:eudi-wallet to require an EU Wallet‑backed login.
The CodeB IdP will redirect the user through logineu.html, which drives vp‑start, vp‑request, and vp‑response behind the scenes.
On success, the IdP completes the OIDC flow normally — your app receives an authorization code, exchanges it at /oauth2/v1/token, and receives an id_token and access_token as if the user had used password login.
The id_token carries amr: ["vc"] and acr: "urn:codeb:acr:eudi-wallet", plus the PID claims your app requested via standard OIDC scopes (profile, email).
User identifiers minted from the wallet flow are stable per‑subject (e.g. pin_de_3f8a1d7e for a PID with a personal administrative number, eu_3f8a1d7e for a name‑and‑DOB hash fallback). The role on first sign‑in is guest; admins can promote.
POST/oauth2/v1/token · JWT‑bearer grant (RFC 7523) #
Exchange an SSO assertion (the one returned by vp‑response) for a standard OIDC access_token + id_token + refresh_token. Useful for service‑to‑service flows and for integrators (or test scripts) that have just verified a wallet holder and want a bearer to call any OIDC‑bearer endpoint without bouncing the browser through /authorize.
Request
POST /oauth2/v1/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&assertion=<sso_assertion> (the one returned by vp-response)
&client_id=<client_id> (must be a registered client)
&client_secret=<secret> (confidential clients only)
&scope=openid (optional; defaults to openid)
It lets a wallet integrator skip the full Authorization Code + PKCE choreography (which requires a registered redirect_uri + PKCE pair + a 302 hop) when the user has already proved identity via OID4VP. The wallet round‑trip is at least as strong a factor as a password login; turning that proof directly into a Bearer keeps automated tests + S2S integrations clean.
Type guard. The assertion typ claim must be "sso". An access_token or id_token presented in the assertion parameter will be rejected with invalid_grant — an anti‑confused‑deputy guard against token reuse.
Read the signed‑in user's account state. Used immediately before account=password so the caller knows the realm (needed to compute HA1) and whether the user already has a stored password.
Request
GET /signal.ashx?account=get
Authorization: Bearer <access_token>
Response (200)
{
"user": "eu_3f8a1d7e",
"role": "guest",
"disabled": false,
"profile": { ... OIDC claims the user has saved ... },
"has_password": false, // true if a HA1 is already stored
"realm": "phone.codeb.io" // for HA1 = MD5(user:realm:password)
}
Errors
401 invalid_token — bearer missing, expired, or wrong audience.
Why expose realm? The credentials file uses SIP‑style digest HA1 = MD5(user:realm:password). Returning realm on account=get means the caller can compute the HA1 client‑side without a separate config lookup; the plaintext password never crosses the wire.
POST/signal.ashx?account=password · first‑time set + wallet‑as‑recovery #
Set or rotate the signed‑in user's password. Three distinct paths based on stored state and bearer factor:
First‑time set (user has no HA1 yet) — current_ha1 NOT required. Wallet‑created guests use this to enable username + password sign‑in.
Wallet‑as‑recovery (user has a HA1, bearer carries acr=urn:codeb:acr:eudi-wallet) — current_ha1 NOT required. The wallet auth is the proof‑of‑identity; this is the "forgot password" path.
Regular change (user has a HA1, bearer is password‑authenticated) — current_ha1 MUST be supplied and verified constant‑time.
Request
POST /signal.ashx?account=password
Authorization: Bearer <access_token>
Content-Type: application/json
{
"ha1": "<32 lowercase hex = MD5(user:realm:password)>",
"current_ha1": "<optional; required only for regular change>"
}
Response (200)
{
"ok": true,
"first_set": true, // true if user had no password before
"wallet_recovery": true // true if wallet auth skipped current_ha1
}
Both flags can be true simultaneously on a brand‑new wallet‑driven set — the user had no password (first_set) AND the wallet auth was the proof (wallet_recovery).
Errors
400 missing_ha1 / invalid_ha1 — body missing or malformed.
400 missing_current_ha1 — regular change required current_ha1 but it wasn't supplied.
400 invalid_current_ha1 — supplied value isn't a valid HA1 shape.
400 new_equals_current — new password is identical to current.
401 current_password_incorrect — supplied current_ha1 doesn't match what's stored.
Audit trail
Three distinct event names in the connection log so an operator can tell the paths apart:
account-password-first-set — wallet‑only guest set first password.
account-password-wallet-reset — existing user reset via wallet, no current_ha1.
account-password-changed — existing user rotated via password.
The full integration story. Six request/response hops that let a brand‑new EUDI Wallet user (a) sign in via wallet, (b) enable username + password sign‑in via the same wallet‑authenticated session, and (c) verify the password works by logging in with it. Same shape as eu‑wallet‑mock.py --set-password, which is the shipping automated test for this exact flow.
Pre‑condition: the integrator already drove vp‑start → vp‑request → vp‑response and now holds:
An sso_assertion from the success body of POST /oidc.ashx?action=vp‑response (one‑time use, 30‑min TTL).
The username that came back in the same body (e.g. eu_3f8a1d7e).
Step 1 — Exchange SSO assertion for an OIDC access_token
Step 6 — (Manual UI sign‑in) the user can now visit /login.html and sign in
Username = eu_3f8a1d7e (or whatever vp‑response handed back), password = the plaintext from step 3. The browser will hash to HA1 locally before posting; the server matches against what step 4 stored. The user is now permanently enabled for both wallet and password sign‑in.
Forgot‑password path
The exact same six steps work when the user already has a password. Step 4 returns first_set: false, wallet_recovery: true — the wallet auth bypassed current_ha1. Step 5 confirms the new password works. The audit log records account-password-wallet-reset.
Security argument. Bypassing current_ha1 only happens when the bearer carries acr=urn:codeb:acr:eudi-wallet — that's set only when the OID4VP verifier completed a full SD‑JWT VC + KB‑JWT round‑trip with cryptographically‑verified holder binding. A successful wallet auth is at least as strong as any password, so requiring current_ha1 on top would be a UX bug, not a security feature.
TRYeu-wallet-mock-both.py · local mock‑wallet round trip #
The shipping test harness exercises both Client Identifier Prefixes back‑to‑back, end‑to‑end. If both calls return HTTP 200 with revealed PID claims and an SSO assertion, the integration is working.
Outline
# 1. Begin a session with x509_hash (wallet‑preferred)
GET https://phone.codeb.io/oidc.ashx?action=vp-start&client_id_prefix=x509_hash
→ { id, request_uri, deep_link, client_id, status }
# 2. Fetch the signed request (as the wallet would)
GET {request_uri}
→ ES256-signed JWT, application/oauth-authz-req+jwt
# 3. Verify the JWS, decrypt nothing yet, build the VP response
# (SD-JWT VC + KB-JWT against the verifier's ephemeral ECDH-ES key)
# 4. POST the response
POST https://phone.codeb.io/oidc.ashx?action=vp-response&id={id}
Content-Type: application/x-www-form-urlencoded
Body: response=<JWE>
→ { ok:true, vct, iss, disclosures_verified, claims, sso_assertion }
# 5. Repeat with client_id_prefix=x509_san_dns to verify the HAIP‑1.0‑Final
# compatibility path. Same script, just flip the query parameter.
Verify the build matches
# Confirm you are testing the build you think you are testing.
curl https://phone.codeb.io/oidc.ashx?action=ping
# build = current handler version. Bumped on every meaningful change.
Logs an operator can grep
Every gate listed in vp‑response emits a structured event keyed by session id, plus a small handful of failure‑path events. Greppable vocabulary (incomplete list):
Caveats & defensible scope. CodeB's verifier is a relying party, not a notified national wallet, and not a qualified trust service provider. The protocol substrate is live and verifiable (proof of work), but high‑assurance identity proofing should wait for iter‑2 hardening (issuer signature chain against the EU List of Trusted Lists, OAuth Status List revocation, wallet‑attestation conformance).