Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
de75f43
feat(openid4vci): align error codes with v1.0 Section 8.3.1.2
JorisHeadease Feb 24, 2026
8c02553
feat(openid4vci): update types and issuer for v1.0 metadata and offer
JorisHeadease Feb 24, 2026
15654bb
feat(openid4vci): update holder and API handler for v1.0
JorisHeadease Feb 24, 2026
a8b0af6
feat(openid4vci): update OpenAPI spec and remove stale VP metadata
JorisHeadease Feb 24, 2026
6ee11b2
fix(openid4vci): align wire formats with v1.0 spec review
JorisHeadease Mar 6, 2026
a09c50a
feat(openid4vci): align auth module with v1.0 spec
JorisHeadease Mar 9, 2026
1aa3837
fix(openid4vci): harden input validation and add missing tests
JorisHeadease Mar 9, 2026
77b932d
Merge remote-tracking branch 'origin/master' into feature/openid4vci-v1
JorisHeadease Mar 9, 2026
906dd0f
fix(openid4vci): restore PreAuthorizedGrantAnonymousAccessSupported i…
JorisHeadease Mar 9, 2026
e0dfecb
docs(openid4vci): improve OpenAPI spec v1.0 accuracy
JorisHeadease Mar 10, 2026
301be91
fix(openid4vci): correct holder error code for unsupported format
JorisHeadease Mar 10, 2026
a0b4a31
test(openid4vci): fix auth header bug and add missing test coverage
JorisHeadease Mar 10, 2026
1d66b58
refactor(openid4vci): restore original error comments and simplify de…
JorisHeadease Mar 10, 2026
c0e8347
fix(openid4vci): restore JSON deep copy and remove resolved TODO
JorisHeadease Mar 10, 2026
9cae8c8
fix(openid4vci): harden validation and fix spec compliance issues
JorisHeadease Mar 10, 2026
355330a
refactor(openid4vci): clean up CredentialRequest and rename Id to ID
JorisHeadease Mar 10, 2026
dfc9f6c
fix(openid4vci): use json.RawMessage for CredentialResponseEntry
JorisHeadease Mar 10, 2026
276bf0a
refactor(openid4vci): unify duplicate types across packages
JorisHeadease Mar 11, 2026
2474568
qlty fmt
qltysh[bot] Mar 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 55 additions & 32 deletions auth/api/iam/openid4vci.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,17 @@ import (
"github.com/nuts-foundation/nuts-node/core"
"github.com/nuts-foundation/nuts-node/crypto"
nutsHttp "github.com/nuts-foundation/nuts-node/http"
"github.com/nuts-foundation/nuts-node/vcr/openid4vci"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
)

var timeFunc = time.Now

// jwtTypeOpenID4VCIProof defines the OpenID4VCI JWT-subtype (used as typ claim in the JWT).
const jwtTypeOpenID4VCIProof = "openid4vci-proof+jwt"

func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, request RequestOpenid4VCICredentialIssuanceRequestObject) (RequestOpenid4VCICredentialIssuanceResponseObject, error) {
if request.Body == nil {
// why did oapi-codegen generate a pointer for the body??
return nil, core.InvalidInputError("missing request body")
}
walletDID, err := did.ParseDID(request.Body.WalletDid)
if err != nil {
return nil, core.InvalidInputError("invalid wallet DID")
Expand All @@ -52,11 +54,6 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques
} else if !owned {
return nil, core.InvalidInputError("wallet DID does not belong to the subject")
}

if request.Body == nil {
// why did oapi-codegen generate a pointer for the body??
return nil, core.InvalidInputError("missing request body")
}
// Parse the issuer
issuer := request.Body.Issuer
if issuer == "" {
Expand All @@ -81,8 +78,12 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques

// Read and parse the authorization details
authorizationDetails := []byte("[]")
var credentialConfigID string
if len(request.Body.AuthorizationDetails) > 0 {
authorizationDetails, _ = json.Marshal(request.Body.AuthorizationDetails)
if id, ok := request.Body.AuthorizationDetails[0]["credential_configuration_id"].(string); ok {
credentialConfigID = id
}
}
// Generate the state and PKCE
state := crypto.GenerateNonce()
Expand All @@ -100,9 +101,11 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques
PKCEParams: pkceParams,
// OpenID4VCI issuers may use multiple Authorization Servers
// We must use the token_endpoint that corresponds to the same Authorization Server used for the authorization_endpoint
TokenEndpoint: authzServerMetadata.TokenEndpoint,
IssuerURL: authzServerMetadata.Issuer,
IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint,
TokenEndpoint: authzServerMetadata.TokenEndpoint,
IssuerURL: authzServerMetadata.Issuer,
IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint,
IssuerNonceEndpoint: credentialIssuerMetadata.NonceEndpoint,
IssuerCredentialConfigurationID: credentialConfigID,
})
if err != nil {
return nil, fmt.Errorf("failed to store session: %w", err)
Expand All @@ -129,8 +132,6 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques
}

func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode string, oauthSession *OAuthSession) (CallbackResponseObject, error) {
// extract callback URI at calling app from OAuthSession
// this is the URI where the user-agent will be redirected to
appCallbackURI := oauthSession.redirectURI()

baseURL := r.subjectToBaseURL(*oauthSession.OwnSubject)
Expand All @@ -142,31 +143,49 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode
}

// use code to request access token from remote token endpoint
response, err := r.auth.IAMClient().AccessToken(ctx, authorizationCode, oauthSession.TokenEndpoint, checkURL.String(), *oauthSession.OwnSubject, clientID, oauthSession.PKCEParams.Verifier, false)
tokenResponse, err := r.auth.IAMClient().AccessToken(ctx, authorizationCode, oauthSession.TokenEndpoint, checkURL.String(), *oauthSession.OwnSubject, clientID, oauthSession.PKCEParams.Verifier, false)
if err != nil {
return nil, withCallbackURI(oauthError(oauth.AccessDenied, fmt.Sprintf("error while fetching the access_token from endpoint: %s, error: %s", oauthSession.TokenEndpoint, err.Error())), appCallbackURI)
}

// make proof and collect credential
proofJWT, err := r.openid4vciProof(ctx, *oauthSession.OwnDID, oauthSession.IssuerURL, response.Get(oauth.CNonceParam))
if err != nil {
return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error building proof to fetch the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI)
// fetch nonce from the Nonce Endpoint (v1.0 Section 7)
var nonce string
if oauthSession.IssuerNonceEndpoint != "" {
nonce, err = r.auth.IAMClient().RequestNonce(ctx, oauthSession.IssuerNonceEndpoint)
if err != nil {
return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error fetching nonce from %s: %s", oauthSession.IssuerNonceEndpoint, err.Error())), appCallbackURI)
}
}
credentials, err := r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, response.AccessToken, proofJWT)

// build proof and request credential
credentialResponse, err := r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, nonce)
if err != nil {
return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while fetching the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI)
}
// validate credential
// TODO: check that issued credential is bound to DID that requested it (OwnDID)???
credential, err := vc.ParseVerifiableCredential(credentials.Credential)
// on invalid_nonce: fetch a fresh nonce and retry once
var oidcErr openid4vci.Error
if errors.As(err, &oidcErr) && oidcErr.Code == openid4vci.InvalidNonce && oauthSession.IssuerNonceEndpoint != "" {
nonce, err = r.auth.IAMClient().RequestNonce(ctx, oauthSession.IssuerNonceEndpoint)
if err != nil {
return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error fetching nonce for retry from %s: %s", oauthSession.IssuerNonceEndpoint, err.Error())), appCallbackURI)
}
credentialResponse, err = r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, nonce)
}
if err != nil {
return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while fetching the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI)
}
}
if len(credentialResponse.Credentials) == 0 {
return nil, withCallbackURI(oauthError(oauth.ServerError, "credential response does not contain any credentials"), appCallbackURI)
}

credentialJSON := string(credentialResponse.Credentials[0].Credential)
credential, err := vc.ParseVerifiableCredential(credentialJSON)
if err != nil {
return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while parsing the credential: %s, error: %s", credentials.Credential, err.Error())), appCallbackURI)
return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while parsing the credential: %s, error: %s", credentialJSON, err.Error())), appCallbackURI)
}
err = r.vcr.Verifier().Verify(*credential, true, true, nil)
if err != nil {
return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while verifying the credential from issuer: %s, error: %s", credential.Issuer.String(), err.Error())), appCallbackURI)
}
// store credential in wallet
err = r.vcr.Wallet().Put(ctx, *credential)
if err != nil {
return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while storing credential with id: %s, error: %s", credential.ID, err.Error())), appCallbackURI)
Expand All @@ -176,18 +195,22 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode
}, nil
}

func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *OAuthSession, accessToken string, nonce string) (*openid4vci.CredentialResponse, error) {
proofJWT, err := r.openid4vciProof(ctx, *oauthSession.OwnDID, oauthSession.IssuerURL, nonce)
if err != nil {
return nil, fmt.Errorf("error building proof: %w", err)
}
return r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, accessToken, oauthSession.IssuerCredentialConfigurationID, proofJWT)
}

func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audience string, nonce string) (string, error) {
kid, _, err := r.keyResolver.ResolveKey(holderDid, nil, resolver.AssertionMethod)
if err != nil {
return "", fmt.Errorf("failed to resolve key for did (%s): %w", holderDid.String(), err)
}
headers := map[string]interface{}{
"typ": jwtTypeOpenID4VCIProof, // MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725].
"kid": kid, // JOSE Header containing the key ID. If the Credential shall be bound to a DID, the kid refers to a DID URL which identifies a particular key in the DID Document that the Credential shall be bound to.
}
if err != nil {
// can't fail or would have failed before
return "", err
"typ": openid4vci.JWTTypeOpenID4VCIProof, // MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725].
"kid": kid, // JOSE Header containing the key ID. If the Credential shall be bound to a DID, the kid refers to a DID URL which identifies a particular key in the DID Document that the Credential shall be bound to.
}
claims := map[string]interface{}{
jwt.IssuerKey: holderDid.String(),
Expand Down
Loading
Loading