Skip to content

OpenID4VCI: select credential by type in request-credential; node resolves config + authorization flow from metadata #4372

Description

@reinkrul

Related: #4248 (supersedes), #4338, #4316, #4310, #4365
Parent PRD: #3972

Problem Statement

POST /internal/auth/v2/{subjectID}/request-credential requires the EHR to hand-build an
authorization_details array containing OpenID4VCI/RFC 9396 internals — type: "openid_credential",
credential_configuration_id, format (auth/api/iam/openid4vci.go:88-91,
auth/api/iam/generated.go AuthorizationDetail). The EHR should be able to ask for a credential with the
only facts it actually owns: the credential type it needs (a VC concept) and which issuer it trusts.

The node also needs to drive two issuers in the network that select the credential differently — one via
authorization_details, one via credential_configuration_id at the credential endpoint. Both selection
points are derivable from metadata the node already fetches, so the node can negotiate the flow instead of
the caller encoding it.

Trigger: a generic OpenID4VCI 1.0 client for the Nuts network — (a) the Authorization Details flow used by the Vektis demo
issuer and (b) the AET SDK (authorization scope + credential-endpoint selection).

Solution

Expose credential type as the request input. The node resolves the rest from metadata:

  1. type → credential_configuration_id, by matching the type against the issuer's
    credential_configurations_supported.
  2. credential_configuration_id → authorization-stage flow, chosen from AS metadata.
  3. credential endpoint → the resolved credential_configuration_id (the node also honors
    credential_identifier when an AS returns one in the Token Response — §8.2; already handled by
    extractCredentialIdentifier, auth/api/iam/openid4vci.go:197).

The node keeps the resolved credential_configuration_id in the session
(OAuthSession.IssuerCredentialConfigurationID, auth/api/iam/session.go), so both flows terminate at the
credential endpoint with no extra plumbing.

Considered and rejected: making credential_configuration_id the primary input (#4248). The type is the
caller's natural handle; the config id is an issuer-specific string the node can resolve.

User Stories

User Stories
  1. As an EHR integrator, I want to request a credential by its type and issuer, so that I work entirely in
    VC concepts.
  2. As a Nuts node operator, I want the node to choose the authorization flow from issuer/AS metadata, so that
    one API serves both the Vektis demo issuer and the AET SDK.

Implementation Decisions

API extension

POST /internal/auth/v2/{subjectID}/request-credential
{
  "wallet_did": "did:web:example.com:iam:123",   // (#4365 may make this optional)
  "issuer": "https://issuer.example.com",          // trust decision
  "credential_type": "HealthcareProviderRoleTypeCredential", // which credential to request
  "redirect_uri": "https://ehr.example.com/cb",
  "profile": "aet"                                 // optional: trusted param bundle (#4338)
}

credential_type is the only credential-specific field the caller provides. The node derives
credential_configuration_id, the credential format, and the authorization-stage flow from the issuer's
metadata.

Type → configuration resolution (new)

Match credential_type against each entry in credential_configurations_supported, by the type-locating
field per format:

Format Type field
jwt_vc_json, ldp_vc credential_definition.type (array; match ignoring base VerifiableCredential)
vc+sd-jwt, dc+sd-jwt vct

credential_definition / vct are read from the issuer's Credential Issuer Metadata
(/.well-known/openid-credential-issuer, §12.2). Match on the type field, not the map key: the spec lets
credential_configuration_id be an arbitrary issuer-chosen string.

Resolution outcome:

Result Behavior
1 match use it
Multiple matches take the first whose format the node supports, iterating a fixed node format-preference order (deterministic — never Go map order)
0 matches 400issuer does not offer a credential of type "<type>"
matches only in formats the node does not support 400issuer offers "<type>" only in format(s): <list>

Authorization-stage flow (chosen from AS metadata, no probing)

  1. Authorization Details flow — AS advertises authorization_details_types_supported containing
    openid_credential: the node sends the resolved credential_configuration_id inside
    authorization_details. The Credential Request uses credential_identifier when the Token Response
    returns credential_identifiers, otherwise credential_configuration_id (§8.2). Used by the Vektis mock.
  2. Credential Configuration ID flow — otherwise: the node identifies the credential with
    credential_configuration_id in the Credential Request (§8.2). The authorization scope the AS requires is
    supplied by the request profile. Used by the AET SDK (the aet profile supplies
    scope=openid profile api).

Both flows reuse the same resolved credential_configuration_id; extractCredentialIdentifier picks the
correct credential-endpoint field independent of the flow.

Target-issuer compliance

Validated against the two issuers in the network. The Vektis mock is our demo app (it implements OpenID4VCI
itself; the Nuts node has no issuer side); the AET SDK is a third-party issuer whose development we influence.
Both use jwt_vc_json with credential_definition.type.

Vektis mock (nuts-knooppunt/mock-components/vc-issuer) AET SDK
Format jwt_vc_json jwt_vc_json
Type-locating field credential_definition.type = […,"HealthcareProviderRoleTypeCredential"] credential_definition.type = […,"HealthcareProfessionalDelegationCredential"] / PatientEnrollmentCredential
Authorization-stage flow Authorization Details flow (authorization_details_types_supported: [openid_credential]) Credential Configuration ID flow (scope=openid profile api via the aet profile)
Credential selected by credential_configuration_id in authorization_details → credential endpoint credential_configuration_id at the Credential Request

Dependency for e2e: AET's credential_configurations_supported is populated with the credential types (we
influence this upstream).

Post-issuance validation

After the Credential Endpoint returns, verify the issued credential's type matches the requested
credential_type before storing (vcr.Wallet().Put, auth/api/iam/openid4vci.go:258); return a
502-class error on mismatch. Implement on the auth side.

Modules to build/modify

  1. auth/openid4vci/types.go: extend OpenIDCredentialIssuerMetadata with
    CredentialConfigurationsSupported map[string]CredentialConfiguration; add CredentialConfiguration
    (format, credential_definition / vct).
  2. auth/oauth/types.go: add AuthorizationDetailsTypesSupported []string to
    AuthorizationServerMetadata.
  3. auth/api/iam/openid4vci.go: type-match resolution + flow selection in
    RequestOpenid4VCICredentialIssuance; post-issuance type check in handleOpenID4VCICallback.
  4. docs/_static/auth/v2.yaml + regenerate (make gen-api): credential_type request body.
  5. auth/openid4vci/client.go: RequestCredential already emits credential_identifier /
    credential_configuration_id per §8.2 — reused as-is.

Testing Decisions

A good test asserts external wire behavior: given issuer + AS metadata fixtures and a credential_type,
assert (a) the resolved credential_configuration_id, (b) the authorization-stage flow chosen, (c) the
Credential Request body, (d) the failure response for each unhappy path.

Modules to test:

  • Type resolution: single match; multiple matches → first supported by preference order (deterministic);
    0 matches → 400; matches only in unsupported formats → 400; per-format type field
    (credential_definition.type, vct).
  • Flow selection: Authorization Details flow (AS advertises authorization_details_types_supported); Credential Configuration ID flow
    (AS does not) with profile-supplied scope.
  • Post-issuance: issuer returns a different type → rejected, not stored.
  • Prior art: auth/api/iam/openid4vci_test.go.

Impact Assessment

  • Backwards compatibility: the request body now takes credential_type; breaking change to the
    experimental API. Acceptable — flagged experimental.
  • Versioning: minor; auth v2, experimental.
  • Configuration/deployment: Authorization Details flow issuers advertise
    authorization_details_types_supported; AET-style issuers use a request profile for the authorization scope.
  • Security: no new key material. The post-issuance type check guarantees the stored credential matches the
    request. Existing redaction on credential_request_params (PII) retained.
  • Spec stability: OpenID4VCI 1.0 (ID-1), §8.2, §12.2; RFC 9396.

Out of Scope

  • Metadata-driven scope-flow (credential-selection scope from credential_configurations_supported[*].scope)
    — future; the current issuers are covered by the Authorization Details flow and Credential Configuration ID flow.
  • Pre-authorized_code flow and Credential Offer consumption (issuer-initiated) — tracked under Make OpenID4VCI implementation ready for general use #3972.
  • Multiple credentials per request — future credential_type array.
  • vcr/openid4vci / vcr/holder/openid.go — separate draft-11 flow, not this code path.

Further Notes

Implementation Plan

Logical steps, ordered by dependency.

  1. Parse credential_configurations_supported (issuer) + authorization_details_types_supported (AS).
  2. Type → credential_configuration_id resolution: match + deterministic format-preference pick + errors.
    (needs 1)
  3. Authorization-stage flow selection (Authorization Details flow / Credential Configuration ID flow) +
    credential-endpoint wiring. (needs 1)
  4. API surface: credential_type request body; OpenAPI + regen. (needs 2, 3)
  5. Post-issuance credential-type validation before wallet store. (needs 2)
  6. Docs + e2e (Vektis Authorization Details flow, AET Credential Configuration ID flow). (needs 4, 5)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions