PID Issuer architecture
This page is a code-oriented companion to the protocol-level sequence diagram at
Issuance with OpenID4VCI.
That diagram treats the PID Issuer as a black box and shows what goes over the
wire between the wallet, the PID Issuer and RDO Max. This page opens that box:
which crates contribute which pieces, where state lives, and which traits are
the extension points for plugging in a different backend.
The key structural idea is that issuance is split into two phases, each with its own component:
an Authorization Phase, owned by
AuthorizingIssuer, covering/par,/authorizeand the upstream-IdP round-trip; andan Issuance Phase, owned by
Issuer, covering/token,/nonce,/credentialand the credential previews.
The Authorization Phase ends by writing an issuance session (keyed by an
authorization code) that the Issuance Phase then consumes. The pre-authorized
code flow (the issuance_server) skips the Authorization Phase entirely and
uses the bare Issuer directly.
Crate map
The PID Issuer process is assembled from three crates:
wallet_core/lib/openid4vc— protocol types and traits, with no HTTP or storage baked in. Relevant pieces:authorization::PushedAuthorizationRequest,authorization::PushedAuthorizationResponse,authorization::VciAuthorizationRequest(with itsfor_auth_codeconstructor and theOidcAuthorizationRequestwrapper that adds the OIDCnonce),token::TokenRequest,token::TokenResponse,credential::CredentialRequest(s),credential::CredentialResponse(s).issuer::Issuer— the Issuance Phase state machine: serves/token,/nonce,/credentialand the previews. It holds theSessionStore<IssuanceData>and the nonce store.process_token_requestloads the issuance session keyed by the code, verifies the wallet’s PKCE according to the session’sGrant, and issues the access token. It does no upstream interaction and knows nothing about DigiD or a BSN — by the time it runs, the issuables are already in the session.issuer::{AuthCodeIssued, Grant, IssuanceData}— the issuance session data.AuthCodeIssuedcarries theissuable_documentsplus aGrant: eitherGrant::PreAuthorizedCode(no PKCE) orGrant::AuthorizationCode { wallet_code_challenge }(the wallet’s PKCE challenge, verified at/token).Issuer::new_preauthorized_sessionwrites the pre-authorized variant directly; the auth-code variant is written byAuthorizingIssuer::complete_authorization.authorizing_issuer::AuthorizingIssuer— the Authorization Phase wrapper around anIssuer. Owns the PAR store and anAuthorizationCodeFlowimpl. Serves/parand/authorize, and exposescomplete_authorization, which mints a fresh issuer-side authorization code, writes theAuthCodeIssuedsession (withGrant::AuthorizationCode), and builds the wallet-facing redirect URL. Deployments doing only the pre-authorized grant never construct one.authorization_code_flow::{AuthorizationCodeFlow, AuthorizeOutcome}— the trait abstracting a single OAuth authorization-code grant at/authorize.authorize()returns eitherAuthorizeOutcome::RedirectTo(url)(send the user-agent to an external IdP) orAuthorizeOutcome::IssuedCode(code)(an issuer-minted code with no external round-trip). Any state that must survive between/authorizeand the eventual callback is private to the impl. This is the seam where the upstream IdP is plugged in.par::ParStore/store::Store,nonce::store::NonceStore,server_state::SessionStore<IssuanceData>— abstractions over where PAR entries, c_nonces and issuance sessions live. Default in-memory impls ship alongside. The upstream PKCE verifier is not held here; it travels in the flow’s own state-bridge entry (see below).
wallet_core/lib/openid4vc_server— generic axum wiring for an OpenID4VCI issuer, knows nothing about DigiD or BRP. It exposes two routers:issuer::create_issuance_routermounts the Issuance Phase handlers:/.well-known/openid-credential-issuer,/.well-known/oauth-authorization-server,/issuance/token,/issuance/nonce,/issuance/credential(+batch_credential, thedeletereject routes) and/issuance/credential_preview(an extension we support on top of the spec). Backed byIssuanceState { issuer }. Both flows mount this: the pre-authorizedissuance_servermounts it standalone, the auth-codepid_issuermounts it alongside the authorization router.issuer::create_authorization_routermounts the Authorization Phase handlers/issuance/parand/issuance/authorize. Backed byAuthorizationState { authorizing_issuer }. The/authorizehandler just callsAuthorizingIssuer::process_authorizeand 302-redirects to whatever URL it returns; it has no knowledge of state bridging or the upstream IdP.The upstream-IdP callback route is not in this crate. It is owned by the concrete
AuthorizationCodeFlowimpl and mounted by the binary.
wallet_core/wallet_server/pid_issuer— the PID-specific concrete types:pid::auth_code_flow::UpstreamOidcAuthorizationCodeFlow— the concreteAuthorizationCodeFlow. It owns the DigiD client, the state-bridge store, the BRP client, the recovery-code HMAC key, the issuer’s DigiDclient_idand the issuer’s own callback URL. Itsauthorize()generates the upstream PKCE pair and a randomissuer_state, writes aStateBridgeEntrykeyed by thatissuer_state, and returns aRedirectTothe upstream/authorize. It also owns the/digid/callbackroute (viacallback_router): the termination point for the upstream redirect, which consumes the bridge entry, obtains the BSN, looks up BRP attributes, builds the PIDIssuableDocuments, and callsAuthorizingIssuer::complete_authorization.pid::digid::DigidMetadataCache— fetches and holds the upstream OIDC discovery document.pid::digid::{DigidClient, HttpDigidClient}— the trait + HTTP impl for the upstream exchange.authorization_endpoint()resolves the upstream/authorizeURL (from the cache);bsn(code, code_verifier, redirect_uri)performs the upstream/token+/userinfoexchange (fetching the JWKS, JWE-decrypting and JWS-verifying the userinfo) to extract the BSN.pid::brp::client::HttpBrpClient(implementsBrpClient) — queries the BRP for personal data by BSN.server::servemergescreate_issuance_router,create_authorization_routerand the flow’scallback_router, and serves them.issuer_common::state_bridge_store::IssuerStateBridgeStore— the store backing the flow’s state bridge (Postgres or in-memory). It is generic over the entry type, serializing it to/from JSON, so the entry’s shape stays private to theAuthorizationCodeFlowimpl.
Component diagram
flowchart LR
subgraph ovcs["openid4vc_server (HTTP wiring)"]
direction TB
IssRouter["create_issuance_router<br/>(IssuanceState)"]
AuthRouter["create_authorization_router<br/>(AuthorizationState)"]
end
subgraph ovc["openid4vc (protocol types)"]
direction TB
AuthIssuer["struct AuthorizingIssuer"]
Issuer["struct Issuer"]
AF_trait["trait AuthorizationCodeFlow<br/>(+ AuthorizeOutcome)"]
Store_traits["trait ParStore / Store<br/>trait SessionStore of IssuanceData<br/>trait NonceStore"]
end
subgraph pidi["pid_issuer (PID-specific)"]
direction TB
Flow["UpstreamOidcAuthorizationCodeFlow<br/>(owns /digid/callback)"]
DigidClient["HttpDigidClient"]
DigidCache["DigidMetadataCache<br/>(holds OIDC metadata)"]
BrpClient["HttpBrpClient"]
Bridge["IssuerStateBridgeStore"]
end
subgraph ext["external"]
direction TB
RDO[("RDO Max / DigiD")]
BRP[("BRP")]
end
AuthRouter --> AuthIssuer
IssRouter --> Issuer
AuthIssuer -->|wraps| Issuer
AuthIssuer -->|holds| AF_trait
AuthIssuer -->|reads/writes PAR| Store_traits
Issuer -->|reads/writes sessions, nonces| Store_traits
Flow -.implements.-> AF_trait
Flow -->|owns| DigidClient
Flow -->|owns| BrpClient
Flow -->|reads/writes| Bridge
Flow -->|complete_authorization| AuthIssuer
DigidClient -->|owns| DigidCache
DigidCache -->|GET /.well-known/<br/>openid-configuration| RDO
DigidClient -->|POST /token,<br/>GET /userinfo,<br/>GET jwks_uri| RDO
BrpClient -->|get_person_by_bsn| BRP
The extension points worth noting:
AuthorizationCodeFlow— plugs in the upstream IdP and its callback. Today the only implementor isUpstreamOidcAuthorizationCodeFlow(DigiD via RDO Max); a different IdP would get a sibling implementation that owns its own callback route. The generic/authorizehandler doesn’t know what DigiD is.The pre-authorized code flow reuses the bare
Issuer(viacreate_issuance_router) without anAuthorizingIssuerat all. That is how theissuance_server(disclosure-based issuance) shares the Issuance Phase: it computes the issuables from the disclosed attributes and writes them withIssuer::new_preauthorized_session, then hands the wallet a Credential Offer carrying the pre-authorized code. The/tokenhandler doesn’t know what a BSN is.
State lives behind the *Store traits (PAR, sessions, nonces) plus the flow’s
own IssuerStateBridgeStore. The in-memory variants can all be replaced by
stateful (Postgres) variants.