diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go index 3310ad2ba9..c4c309869c 100644 --- a/auth/api/auth/v1/api_test.go +++ b/auth/api/auth/v1/api_test.go @@ -28,6 +28,7 @@ import ( pkg2 "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/contract" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" oauth2 "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/auth/services" "github.com/nuts-foundation/nuts-node/auth/services/dummy" @@ -86,6 +87,10 @@ func (m *mockAuthClient) IAMClient() iam.Client { return m.iamClient } +func (m *mockAuthClient) OpenID4VCIClient() openid4vci.Client { + return nil +} + func (m *mockAuthClient) ContractNotary() services.ContractNotary { return m.contractNotary } diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index bedbba113d..6ef8405d44 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -47,6 +47,7 @@ import ( iamclient "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/log" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/core" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" nutsHttp "github.com/nuts-foundation/nuts-node/http" @@ -867,8 +868,8 @@ func (r Wrapper) StatusList(ctx context.Context, request StatusListRequestObject return StatusList200JSONResponse(*cred), nil } -func (r Wrapper) openid4vciMetadata(ctx context.Context, issuer string) (*oauth.OpenIDCredentialIssuerMetadata, *oauth.AuthorizationServerMetadata, error) { - credentialIssuerMetadata, err := r.auth.IAMClient().OpenIdCredentialIssuerMetadata(ctx, issuer) +func (r Wrapper) openid4vciMetadata(ctx context.Context, issuer string) (*openid4vci.OpenIDCredentialIssuerMetadata, *oauth.AuthorizationServerMetadata, error) { + credentialIssuerMetadata, err := r.auth.OpenID4VCIClient().OpenIDCredentialIssuerMetadata(ctx, issuer) if err != nil { return nil, nil, err } diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 5be4de20e1..57fe97570b 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -43,6 +43,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" oauthServices "github.com/nuts-foundation/nuts-node/auth/services/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/core/to" @@ -1588,6 +1589,7 @@ type testCtx struct { wallet *holder.MockWallet subjectManager *didsubject.MockManager jar *MockJAR + openid4vciClient *openid4vci.MockClient } func newTestClient(t testing.TB) *testCtx { @@ -1605,6 +1607,7 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b vcIssuer := issuer.NewMockIssuer(ctrl) vcVerifier := verifier.NewMockVerifier(ctrl) iamClient := iam.NewMockClient(ctrl) + openid4vciClient := openid4vci.NewMockClient(ctrl) mockDocumentOwner := didsubject.NewMockDocumentOwner(ctrl) subjectManager := didsubject.NewMockManager(ctrl) mockVCR := vcr.NewMockVCR(ctrl) @@ -1620,6 +1623,7 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b mockVCR.EXPECT().Verifier().Return(vcVerifier).AnyTimes() mockVCR.EXPECT().Wallet().Return(mockWallet).AnyTimes() authnServices.EXPECT().IAMClient().Return(iamClient).AnyTimes() + authnServices.EXPECT().OpenID4VCIClient().Return(openid4vciClient).AnyTimes() authnServices.EXPECT().AuthorizationEndpointEnabled().Return(authEndpointEnabled).AnyTimes() subjectManager.EXPECT().ListDIDs(gomock.Any(), holderSubjectID).Return([]did.DID{holderDID}, nil).AnyTimes() @@ -1657,5 +1661,6 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b jwtSigner: jwtSigner, jar: mockJAR, client: client, + openid4vciClient: openid4vciClient, } } diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 859cff5efd..9e214e72fc 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -19,6 +19,11 @@ const ( JwtBearerAuthScopes = "jwtBearerAuth.Scopes" ) +// Defines values for AuthorizationDetailType. +const ( + OpenidCredential AuthorizationDetailType = "openid_credential" +) + // Defines values for ServiceAccessTokenRequestTokenType. const ( ServiceAccessTokenRequestTokenTypeBearer ServiceAccessTokenRequestTokenType = "Bearer" @@ -31,6 +36,26 @@ const ( UserAccessTokenRequestTokenTypeDPoP UserAccessTokenRequestTokenType = "DPoP" ) +// AuthorizationDetail A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1.1. +// Only the fields used by the user/browser issuance flow are modeled. +type AuthorizationDetail struct { + // CredentialConfigurationId References a credential configuration from the issuer's + // credential_configurations_supported metadata. REQUIRED for + // type=openid_credential per §5.1.1. + CredentialConfigurationId string `json:"credential_configuration_id"` + + // Format Optional credential format hint (e.g. "vc+sd-jwt"). + Format *string `json:"format,omitempty"` + + // Type The authorization details type. For OpenID4VCI flows this MUST + // be "openid_credential" per §5.1.1. + Type AuthorizationDetailType `json:"type"` +} + +// AuthorizationDetailType The authorization details type. For OpenID4VCI flows this MUST +// be "openid_credential" per §5.1.1. +type AuthorizationDetailType string + // DPoPRequest defines model for DPoPRequest. type DPoPRequest struct { // Htm The HTTP method for which the DPoP proof is requested. @@ -106,7 +131,7 @@ type ExtendedTokenIntrospectionResponse struct { // PresentationSubmissions Mapping of Presentation Definition IDs that were fulfilled to Presentation Submissions. PresentationSubmissions *map[string]PresentationSubmission `json:"presentation_submissions,omitempty"` - // Scope granted scopes + // Scope Granted scopes, as a space-separated list. Scope *string `json:"scope,omitempty"` Vps *[]VerifiablePresentation `json:"vps,omitempty"` AdditionalProperties map[string]interface{} `json:"-"` @@ -135,12 +160,15 @@ type ServiceAccessTokenRequest struct { AuthorizationServer string `json:"authorization_server"` // CredentialSelection Optional key-value mapping for credential selection when the wallet contains multiple - // credentials matching a single input descriptor. Each key must match a field id declared + // credentials matching a single input descriptor. Each key must match a field ID declared // in the Presentation Definition's input descriptor constraints. The value narrows the // match to credentials where that field equals the given value. // // The selection must narrow to exactly one credential per input descriptor. // Zero matches or multiple matches will result in an error. + // + // When omitted and multiple credentials match an input descriptor, + // the first matching credential is used. CredentialSelection *map[string]string `json:"credential_selection,omitempty"` // Credentials Additional credentials to present (if required by the authorizer), in addition to those in the requester's wallet. @@ -197,7 +225,7 @@ type UserAccessTokenRequestTokenType string // UserDetails Claims about the authorized user. type UserDetails struct { - // Id Machine-readable identifier, uniquely identifying the user in the issuing system. + // Id Machine-readable identifier, uniquely identifying the user in the issuing system. The format is not specified; it could be a username, email address, employee number, etc. Id string `json:"id"` // Name Human-readable name of the user. @@ -215,7 +243,10 @@ type Cnf struct { // RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance. type RequestOpenid4VCICredentialIssuanceJSONBody struct { - AuthorizationDetails []map[string]interface{} `json:"authorization_details"` + // AuthorizationDetails Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1.1. + // The current implementation processes a single credential + // issuance per call and only consumes the first entry. + AuthorizationDetails []AuthorizationDetail `json:"authorization_details"` // Issuer The OAuth Authorization Server's identifier, that issues the Verifiable Credentials, as specified in RFC 8414 (section 2), // used to locate the OAuth2 Authorization Server metadata. diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 021c1b7463..c799895cd1 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -31,6 +31,7 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" nutsHttp "github.com/nuts-foundation/nuts-node/http" @@ -39,10 +40,11 @@ import ( 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") @@ -52,16 +54,20 @@ 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 == "" { return nil, core.InvalidInputError("issuer is empty") } + // Per §5.1.1 the openid_credential authorization_details flow requires at + // least one entry; the current implementation issues a single credential + // per call and only consumes the first entry. The OpenAPI schema declares + // both minItems: 1 and maxItems: 1, but the StrictServer middleware does + // not enforce array bounds at runtime, so reject here before any outbound + // metadata fetches. + if len(request.Body.AuthorizationDetails) != 1 { + return nil, core.InvalidInputError("authorization_details must contain exactly one entry") + } // Fetch metadata containing the endpoints credentialIssuerMetadata, authzServerMetadata, err := r.openid4vciMetadata(ctx, request.Body.Issuer) if err != nil { @@ -79,11 +85,12 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques clientID := r.subjectToBaseURL(request.SubjectID) - // Read and parse the authorization details - authorizationDetails := []byte("[]") - if len(request.Body.AuthorizationDetails) > 0 { - authorizationDetails, _ = json.Marshal(request.Body.AuthorizationDetails) - } + // Per §5.1.1, type and credential_configuration_id are required on each + // openid_credential authorization_details entry; the OpenAPI schema + // enforces both. Non-emptiness was checked above before any outbound + // metadata fetches. + authorizationDetails, _ := json.Marshal(request.Body.AuthorizationDetails) + credentialConfigID := request.Body.AuthorizationDetails[0].CredentialConfigurationId // Generate the state and PKCE state := crypto.GenerateNonce() pkceParams := generatePKCEParams() @@ -100,9 +107,12 @@ 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, + IssuerCredentialIssuer: credentialIssuerMetadata.CredentialIssuer, }) if err != nil { return nil, fmt.Errorf("failed to store session: %w", err) @@ -129,8 +139,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) @@ -142,31 +150,72 @@ 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)) + // Per §3.3.4 / §8.2: when the Token Response carries authorization_details + // with credential_identifiers, the Credential Request MUST use a + // credential_identifier (not credential_configuration_id). When the AS + // did not return authorization_details, fall back to + // credential_configuration_id (§3.3.4 scope-flow alternative). + credentialIdentifier, err := extractCredentialIdentifier(tokenResponse, oauthSession.IssuerCredentialConfigurationID) 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) + return nil, withCallbackURI(oauthError(oauth.ServerError, err.Error()), appCallbackURI) + } + + // fetch nonce from the Nonce Endpoint (v1.0 Section 7) + var nonce string + if oauthSession.IssuerNonceEndpoint != "" { + nonce, err = r.auth.OpenID4VCIClient().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, credentialIdentifier, 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) + // Per OpenID4VCI 1.0 §8.3.1.2: on invalid_nonce the wallet retrieves a + // new c_nonce. Retrying once is local policy to bound recovery; a + // second invalid_nonce surfaces as a generic ServerError below. + var oauthErr oauth.OAuth2Error + if errors.As(err, &oauthErr) && oauthErr.Code == oauth.InvalidNonce && oauthSession.IssuerNonceEndpoint != "" { + nonce, err = r.auth.OpenID4VCIClient().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, credentialIdentifier, 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) + if len(credentialResponse.Credentials) == 0 { + return nil, withCallbackURI(oauthError(oauth.ServerError, "credential response does not contain any credentials"), appCallbackURI) + } + + // Per OpenID4VCI 1.0 §8.3 the credential field is either a JSON string + // (JWT-VC, SD-JWT-VC) or a JSON object (JSON-LD). Because Credential is + // typed as json.RawMessage, the field keeps the raw JSON encoding — for + // a JWT that includes the surrounding quotes, which ParseVerifiableCredential + // would reject as invalid base64. Unmarshal the bytes as a Go string first + // to strip those quotes; on failure (the JSON-LD object case) fall back to + // the raw bytes as-is. + rawCredential := credentialResponse.Credentials[0].Credential + var credentialJSON string + if err := json.Unmarshal(rawCredential, &credentialJSON); err != nil { + credentialJSON = string(rawCredential) + } + 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) @@ -176,18 +225,70 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode }, nil } +func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *OAuthSession, accessToken string, credentialIdentifier string, nonce string) (*openid4vci.CredentialResponse, error) { + // Per §F.1, the proof JWT `aud` MUST be the Credential Issuer Identifier, + // not the Authorization Server issuer URL. + proofJWT, err := r.openid4vciProof(ctx, *oauthSession.OwnDID, oauthSession.IssuerCredentialIssuer, nonce) + if err != nil { + return nil, fmt.Errorf("error building proof: %w", err) + } + return r.auth.OpenID4VCIClient().RequestCredential(ctx, openid4vci.RequestCredentialOpts{ + CredentialEndpoint: oauthSession.IssuerCredentialEndpoint, + AccessToken: accessToken, + CredentialConfigurationID: oauthSession.IssuerCredentialConfigurationID, + CredentialIdentifier: credentialIdentifier, + ProofJWT: proofJWT, + }) +} + +// extractCredentialIdentifier reads authorization_details from the Token +// Response and returns a credential_identifier matching the requested +// configuration. Per OpenID4VCI 1.0 §3.3.4 / §8.2, when the AS returns +// authorization_details with credential_identifiers, the wallet MUST use a +// credential_identifier in the Credential Request — silently falling back +// to credential_configuration_id is not allowed. Returns ("", nil) only +// when the Token Response did not carry authorization_details at all +// (which permits the §3.3.4 scope-flow fallback to credential_configuration_id). +func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse, credentialConfigurationID string) (string, error) { + raw, ok := tokenResponse.GetAny(oauth.AuthorizationDetailsParam) + if !ok { + return "", nil + } + bytes, err := json.Marshal(raw) + if err != nil { + return "", fmt.Errorf("token response authorization_details: %w", err) + } + var details []struct { + Type string `json:"type"` + CredentialConfigurationID string `json:"credential_configuration_id"` + CredentialIdentifiers []string `json:"credential_identifiers"` + } + if err := json.Unmarshal(bytes, &details); err != nil { + return "", fmt.Errorf("token response authorization_details malformed: %w", err) + } + for _, d := range details { + if d.Type != "openid_credential" { + continue + } + if d.CredentialConfigurationID != credentialConfigurationID { + continue + } + if len(d.CredentialIdentifiers) == 0 { + return "", fmt.Errorf("token response authorization_details for %q is missing credential_identifiers", credentialConfigurationID) + } + return d.CredentialIdentifiers[0], nil + } + return "", fmt.Errorf("token response authorization_details has no entry for credential_configuration_id %q", credentialConfigurationID) +} + 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(), diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 957595cbc8..4e03d107c2 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -20,6 +20,7 @@ package iam import ( "context" + "encoding/json" "errors" "net/url" "testing" @@ -27,8 +28,8 @@ import ( "github.com/nuts-foundation/nuts-node/core/to" - "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" @@ -39,7 +40,7 @@ import ( func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { redirectURI := "https://test.test/iam/123/cb" authServer := "https://auth.server/" - metadata := oauth.OpenIDCredentialIssuerMetadata{ + metadata := openid4vci.OpenIDCredentialIssuerMetadata{ CredentialIssuer: "issuer", CredentialEndpoint: "endpoint", AuthorizationServers: []string{authServer}, @@ -52,12 +53,12 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { } t.Run("ok", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "format": "vc+sd-jwt"}}, + AuthorizationDetails: []AuthorizationDetail{{Type: "openid_credential", CredentialConfigurationId: "UniversityDegreeCredential", Format: to.Ptr("vc+sd-jwt")}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -75,19 +76,18 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { assert.Equal(t, holderClientID, redirectUri.Query().Get("client_id")) assert.Equal(t, "S256", redirectUri.Query().Get("code_challenge_method")) assert.Equal(t, "code", redirectUri.Query().Get("response_type")) - assert.Equal(t, `[{"format":"vc+sd-jwt","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) - println(redirectUri.String()) + assert.Equal(t, `[{"credential_configuration_id":"UniversityDegreeCredential","format":"vc+sd-jwt","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) }) t.Run("openid4vciMetadata", func(t *testing.T) { t.Run("ok - fallback to issuerDID on empty AuthorizationServers", func(t *testing.T) { ctx := newTestClient(t) - metadata := oauth.OpenIDCredentialIssuerMetadata{ + metadata := openid4vci.OpenIDCredentialIssuerMetadata{ CredentialIssuer: "issuer", CredentialEndpoint: "endpoint", AuthorizationServers: []string{}, // empty Display: nil, } - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, issuerClientID).Return(nil, assert.AnError) _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.ErrorIs(t, err, assert.AnError) @@ -95,7 +95,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { t.Run("error - none of the authorization servers can be reached", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, issuerClientID).Return(nil, assert.AnError) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(nil, assert.AnError) @@ -104,7 +104,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { }) t.Run("error - fetching credential issuer metadata fails", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(nil, assert.AnError) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(nil, assert.AnError) _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.ErrorIs(t, err, assert.AnError) }) @@ -118,9 +118,36 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { assert.EqualError(t, err, "issuer is empty") }) + t.Run("error - empty authorization_details", func(t *testing.T) { + // Schema declares minItems: 1 but the StrictServer middleware does not + // enforce array bounds at runtime; the handler must reject empty arrays + // before any outbound metadata fetches. + req := requestCredentials(holderSubjectID, issuerClientID, redirectURI) + req.Body.AuthorizationDetails = []AuthorizationDetail{} + ctx := newTestClient(t) + // Deliberately no mock expectations: rejection must happen before + // metadata is fetched. + + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) + + assert.ErrorContains(t, err, "must contain exactly one entry") + }) + t.Run("error - multiple authorization_details", func(t *testing.T) { + // Schema declares maxItems: 1; same StrictServer gap as minItems. + req := requestCredentials(holderSubjectID, issuerClientID, redirectURI) + req.Body.AuthorizationDetails = []AuthorizationDetail{ + {Type: "openid_credential", CredentialConfigurationId: "First"}, + {Type: "openid_credential", CredentialConfigurationId: "Second"}, + } + ctx := newTestClient(t) + + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) + + assert.ErrorContains(t, err, "must contain exactly one entry") + }) t.Run("error - invalid authorization endpoint in metadata", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) invalidAuthzMetadata := oauth.AuthorizationServerMetadata{ AuthorizationEndpoint: ":", TokenEndpoint: "https://auth.server/token", @@ -136,7 +163,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { ctx := newTestClient(t) metadata := metadata metadata.CredentialEndpoint = "" - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "no credential_endpoint found") @@ -145,7 +172,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { ctx := newTestClient(t) authzMetadata := authzMetadata authzMetadata.AuthorizationEndpoint = "" - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "no authorization_endpoint found") @@ -154,7 +181,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { ctx := newTestClient(t) authzMetadata := authzMetadata authzMetadata.TokenEndpoint = "" - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "no token_endpoint found") @@ -165,9 +192,10 @@ func requestCredentials(subjectID string, issuer string, redirectURI string) Req return RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: subjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - Issuer: issuer, - RedirectUri: redirectURI, - WalletDid: holderDID.String(), + AuthorizationDetails: []AuthorizationDetail{{Type: "openid_credential", CredentialConfigurationId: "UniversityDegreeCredential"}}, + Issuer: issuer, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), }, } } @@ -176,6 +204,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { redirectURI := "https://example.com/oauth2/holder/callback" authServer := "https://auth.server" tokenEndpoint := authServer + "/token" + nonceEndpoint := authServer + "/nonce" cNonce := crypto.GenerateNonce() credEndpoint := authServer + "/credz" pkceParams := generatePKCEParams() @@ -185,30 +214,38 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { verifiableCredential := createIssuerCredential(issuerDID, holderDID) redirectUrl := "https://client.service/issuance_is_done" + credentialConfigID := "NutsOrganizationCredential_ldp_vc" session := OAuthSession{ AuthorizationServerMetadata: &oauth.AuthorizationServerMetadata{ ClientIdSchemesSupported: clientIdSchemesSupported, }, - ClientFlow: "openid4vci_credential_request", - OwnSubject: &holderSubjectID, - OwnDID: &holderDID, - RedirectURI: redirectUrl, - PKCEParams: pkceParams, - TokenEndpoint: tokenEndpoint, - IssuerURL: issuerClientID, - IssuerCredentialEndpoint: credEndpoint, + ClientFlow: "openid4vci_credential_request", + OwnSubject: &holderSubjectID, + OwnDID: &holderDID, + RedirectURI: redirectUrl, + PKCEParams: pkceParams, + TokenEndpoint: tokenEndpoint, + IssuerURL: issuerClientID, + IssuerCredentialEndpoint: credEndpoint, + IssuerNonceEndpoint: nonceEndpoint, + IssuerCredentialConfigurationID: credentialConfigID, + IssuerCredentialIssuer: issuerClientID, } - tokenResponse := (&oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"}).With("c_nonce", cNonce) - credentialResponse := iam.CredentialResponse{ - Credential: verifiableCredential.Raw(), + sessionWithoutNonce := session + sessionWithoutNonce.IssuerNonceEndpoint = "" + + tokenResponse := &oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"} + credentialResponse := openid4vci.CredentialResponse{ + Credentials: []openid4vci.CredentialResponseEntry{{Credential: json.RawMessage(verifiableCredential.Raw())}}, } now := time.Now() timeFunc = func() time.Time { return now } defer func() { timeFunc = time.Now }() - t.Run("ok", func(t *testing.T) { + t.Run("ok - with nonce endpoint", func(t *testing.T) { ctx := newTestClient(t) require.NoError(t, ctx.client.oauthClientStateStore().Put(state, &session)) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").DoAndReturn(func(_ context.Context, claims map[string]interface{}, headers map[string]interface{}, key interface{}) (string, error) { assert.Equal(t, map[string]interface{}{"typ": "openid4vci-proof+jwt", "kid": "kid"}, headers) @@ -221,7 +258,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Equal(t, expectedClaims, claims) return "signed-proof", nil }) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&credentialResponse, nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) ctx.wallet.EXPECT().Put(nil, *verifiableCredential) @@ -238,6 +275,141 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { actual := callback.(Callback302Response) assert.Equal(t, redirectUrl, actual.Headers.Location) }) + t.Run("ok - no nonce endpoint", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").DoAndReturn(func(_ context.Context, claims map[string]interface{}, headers map[string]interface{}, key interface{}) (string, error) { + _, hasNonce := claims["nonce"] + assert.False(t, hasNonce, "nonce should not be set when no nonce endpoint is configured") + return "signed-proof", nil + }) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionWithoutNonce) + + require.NoError(t, err) + assert.NotNil(t, callback) + }) + t.Run("ok - invalid_nonce retry succeeds", func(t *testing.T) { + ctx := newTestClient(t) + freshNonce := "fresh-nonce" + invalidNonceErr := oauth.OAuth2Error{Code: oauth.InvalidNonce} + + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + // first attempt fails with invalid_nonce + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil).Times(2) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-1", nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof-1"}).Return(nil, invalidNonceErr) + // retry with fresh nonce + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(freshNonce, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-2", nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof-2"}).Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + require.NoError(t, err) + assert.NotNil(t, callback) + }) + t.Run("error - invalid_nonce retry also fails", func(t *testing.T) { + ctx := newTestClient(t) + invalidNonceErr := oauth.OAuth2Error{Code: oauth.InvalidNonce} + + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil).Times(2) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-1", nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof-1"}).Return(nil, invalidNonceErr) + // retry also fails + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("fresh-nonce", nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-2", nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof-2"}).Return(nil, errors.New("still failing")) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "error while fetching the credential from endpoint") + }) + t.Run("error - nonce endpoint fails during retry", func(t *testing.T) { + ctx := newTestClient(t) + invalidNonceErr := oauth.OAuth2Error{Code: oauth.InvalidNonce} + + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(nil, invalidNonceErr) + // retry nonce fetch fails + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("", errors.New("nonce endpoint down")) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "error fetching nonce for retry") + }) + t.Run("ok - uses credential_identifier from token response authorization_details", func(t *testing.T) { + ctx := newTestClient(t) + // Per §3.3.4 / §8.2: when the AS returns authorization_details with + // credential_identifiers, the wallet MUST send credential_identifier + // in the Credential Request. + tokenResponseWithAuthDetails := (&oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"}). + With(oauth.AuthorizationDetailsParam, []map[string]interface{}{{ + "type": "openid_credential", + "credential_configuration_id": credentialConfigID, + "credential_identifiers": []string{"CivilEngineeringDegree-2023"}, + }}) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponseWithAuthDetails, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{ + CredentialEndpoint: credEndpoint, + AccessToken: accessToken, + CredentialConfigurationID: credentialConfigID, + CredentialIdentifier: "CivilEngineeringDegree-2023", + ProofJWT: "signed-proof", + }).Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + require.NoError(t, err) + require.NotNil(t, callback) + }) + t.Run("error - authorization_details present but missing credential_identifiers", func(t *testing.T) { + ctx := newTestClient(t) + // Per §6.2 / §8.2: when the AS returns authorization_details for the + // requested credential_configuration_id, credential_identifiers is + // REQUIRED. Silent fallback to credential_configuration_id is not + // permitted; the wallet must surface an error. + tokenResponseWithBadDetails := (&oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"}). + With(oauth.AuthorizationDetailsParam, []map[string]interface{}{{ + "type": "openid_credential", + "credential_configuration_id": credentialConfigID, + // credential_identifiers omitted + }}) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponseWithBadDetails, nil) + + _, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + require.Error(t, err) + assert.Contains(t, err.Error(), "credential_identifiers") + }) + t.Run("error - initial nonce request fails", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("", errors.New("nonce endpoint unavailable")) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "error fetching nonce from") + }) t.Run("fail_access_token", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(nil, errors.New("FAIL")) @@ -251,9 +423,10 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("fail_credential_response", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(nil, errors.New("FAIL")) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(nil, errors.New("FAIL")) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) @@ -263,23 +436,25 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("err - invalid credential", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&iam.CredentialResponse{ - Credential: "super invalid", + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&openid4vci.CredentialResponse{ + Credentials: []openid4vci.CredentialResponseEntry{{Credential: json.RawMessage(`"super invalid"`)}}, }, nil) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) assert.Nil(t, callback) - assert.EqualError(t, err, "server_error - error while parsing the credential: super invalid, error: invalid JWT") + assert.ErrorContains(t, err, "error while parsing the credential") }) t.Run("fail_verify", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&credentialResponse, nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil).Return(errors.New("FAIL")) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) @@ -290,6 +465,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("error - key not found", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("", nil, resolver.ErrKeyNotFound) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) @@ -300,6 +476,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("error - signature failure", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("", errors.New("signature failed")) @@ -308,4 +485,29 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Nil(t, callback) assert.ErrorContains(t, err, "failed to sign the JWT with kid (kid): signature failed") }) + t.Run("error - nil OwnDID in session", func(t *testing.T) { + ctx := newTestClient(t) + sessionNilDID := session + sessionNilDID.OwnDID = nil + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionNilDID) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "missing wallet DID in session") + }) + t.Run("error - empty credentials array", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&openid4vci.CredentialResponse{ + Credentials: []openid4vci.CredentialResponseEntry{}, + }, nil) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "credential response does not contain any credentials") + }) } diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index 2f4626429b..00c71b4544 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -55,6 +55,15 @@ type OAuthSession struct { UseDPoP bool `json:"use_dpop,omitempty"` // IssuerCredentialEndpoint: endpoint to exchange the access_token for a credential in the OpenID4VCI flow IssuerCredentialEndpoint string `json:"issuer_credential_endpoint,omitempty"` + // IssuerNonceEndpoint: endpoint to request a fresh c_nonce in the OpenID4VCI flow (v1.0 Section 7) + IssuerNonceEndpoint string `json:"issuer_nonce_endpoint,omitempty"` + // IssuerCredentialConfigurationID: the credential_configuration_id for the credential request in the OpenID4VCI flow + IssuerCredentialConfigurationID string `json:"issuer_credential_configuration_id,omitempty"` + // IssuerCredentialIssuer is the Credential Issuer Identifier (`credential_issuer` + // from the metadata, §12.2.1). It is used as the `aud` claim in the proof JWT + // per §F.1; this can differ from IssuerURL (the AS issuer) when the metadata + // declares `authorization_servers`. + IssuerCredentialIssuer string `json:"issuer_credential_issuer,omitempty"` } // oauthClientFlow is used by a client to identify the flow a particular callback is part of diff --git a/auth/auth.go b/auth/auth.go index f135335c01..52528e4f0e 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -22,6 +22,7 @@ import ( "crypto/tls" "errors" "github.com/nuts-foundation/nuts-node/auth/client/iam" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/didjwk" "github.com/nuts-foundation/nuts-node/vdr/didkey" @@ -41,6 +42,7 @@ import ( "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/didman" + httpclient "github.com/nuts-foundation/nuts-node/http/client" "github.com/nuts-foundation/nuts-node/jsonld" "github.com/nuts-foundation/nuts-node/pki" "github.com/nuts-foundation/nuts-node/vcr" @@ -68,6 +70,7 @@ type Auth struct { httpClientTimeout time.Duration tlsConfig *tls.Config subjectManager didsubject.Manager + openID4VCIClient openid4vci.Client // configuredDIDMethods contains the DID methods that are configured in the Nuts node, // of which VDR will create DIDs. configuredDIDMethods []string @@ -129,6 +132,11 @@ func (auth *Auth) IAMClient() iam.Client { return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.strictMode, auth.httpClientTimeout) } +// OpenID4VCIClient returns the OpenID4VCI 1.0 HTTP client. +func (auth *Auth) OpenID4VCIClient() openid4vci.Client { + return auth.openID4VCIClient +} + // Configure the Auth struct by creating a validator and create an Irma server func (auth *Auth) Configure(config core.ServerConfig) error { if auth.config.Irma.SchemeManager == "" { @@ -173,6 +181,7 @@ func (auth *Auth) Configure(config core.ServerConfig) error { // auth.http.config got deprecated in favor of httpclient.timeout auth.httpClientTimeout = config.HTTPClient.Timeout } + auth.openID4VCIClient = openid4vci.NewClient(httpclient.NewWithCache(auth.httpClientTimeout), auth.strictMode) // V1 API related stuff accessTokenLifeSpan := time.Duration(auth.config.AccessTokenLifeSpan) * time.Second auth.authzServer = oauth.NewAuthorizationServer(auth.vdrInstance.Resolver(), auth.vcr, auth.vcr.Verifier(), auth.serviceResolver, diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 348adaf354..3112ede9bf 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -19,7 +19,6 @@ package iam import ( - "bytes" "context" "encoding/json" "errors" @@ -241,19 +240,6 @@ func (hb HTTPClient) PostAuthorizationResponse(ctx context.Context, vp vc.Verifi return hb.postFormExpectRedirect(ctx, data, verifierResponseURI) } -func (hb HTTPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) { - metadataURL, err := oauth.IssuerIdToWellKnown(oauthIssuerURI, oauth.OpenIdCredIssuerWellKnown, hb.strictMode) - if err != nil { - return nil, err - } - var metadata oauth.OpenIDCredentialIssuerMetadata - err = hb.doGet(ctx, metadataURL.String(), &metadata) - if err != nil { - return nil, err - } - return &metadata, err -} - func (hb HTTPClient) OpenIDConfiguration(ctx context.Context, issuerURL string) (*oauth.OpenIDConfiguration, error) { metadataURL, err := oauth.IssuerIdToWellKnown(issuerURL, oauth.OpenIdConfigurationWellKnown, hb.strictMode) if err != nil { @@ -307,65 +293,6 @@ func (hb HTTPClient) KeyProvider() jws.KeyProviderFunc { } } -// CredentialRequest represents ths request to fetch a credential, the JSON object holds the proof as -// CredentialRequestProof. -type CredentialRequest struct { - Proof CredentialRequestProof `json:"proof"` -} - -// CredentialRequestProof holds the ProofType and Jwt for a credential request -type CredentialRequestProof struct { - ProofType string `json:"proof_type"` - Jwt string `json:"jwt"` -} - -// CredentialResponse represents the response of a verifiable credential request. -// It contains the Format and the actual Credential in JSON format. -type CredentialResponse struct { - Credential string `json:"credential"` -} - -func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, proofJwt string) (*CredentialResponse, error) { - credentialEndpointURL, err := url.Parse(credentialEndpoint) - if err != nil { - return nil, err - } - - credentialRequest := CredentialRequest{ - Proof: CredentialRequestProof{ - ProofType: "jwt", - Jwt: proofJwt, - }, - } - jsonBody, _ := json.Marshal(credentialRequest) - request, err := http.NewRequestWithContext(ctx, http.MethodPost, credentialEndpointURL.String(), bytes.NewBuffer(jsonBody)) - if err != nil { - return nil, err - } - request.Header.Add("Accept", "application/json") - request.Header.Add("Content-Type", "application/json") - request.Header.Add("Authorization", "Bearer "+accessToken) - - response, err := hb.httpClient.Do(request.WithContext(ctx)) - if err != nil { - return nil, fmt.Errorf("failed to call endpoint: %w", err) - } - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - log.Logger().WithError(err).Warn("Trouble closing reader") - } - }(response.Body) - if err = core.TestResponseCode(http.StatusOK, response); err != nil { - return nil, err - } - var credential CredentialResponse - if err = json.NewDecoder(response.Body).Decode(&credential); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - return &credential, nil - -} func (hb HTTPClient) postFormExpectRedirect(ctx context.Context, form url.Values, redirectURL url.URL) (string, error) { request, err := http.NewRequestWithContext(ctx, http.MethodPost, redirectURL.String(), strings.NewReader(form.Encode())) if err != nil { diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 5ccf9caaf5..b5ab97a03b 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -20,6 +20,7 @@ package iam import ( "context" + "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -49,13 +50,8 @@ type Client interface { RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error) - // OpenIdCredentialIssuerMetadata returns the metadata of the remote credential issuer. - // oauthIssuer is the URL of the issuer as specified by RFC 8414 (OAuth 2.0 Authorization Server Metadata). - OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) // OpenIDConfiguration returns the OpenID Configuration of the remote wallet. OpenIDConfiguration(ctx context.Context, issuer string) (*oauth.OpenIDConfiguration, error) - // VerifiableCredentials requests Verifiable Credentials from the issuer at the given endpoint. - VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, proofJWT string) (*CredentialResponse, error) // RequestObjectByGet retrieves the RequestObjectByGet from the authorization request's 'request_uri' endpoint using a GET method as defined in RFC9101/OpenID4VP. // This method is used when there is no 'request_uri_method', or its value is 'get'. RequestObjectByGet(ctx context.Context, requestURI string) (string, error) diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index b6ad933a61..6587d7f3af 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -103,21 +103,6 @@ func (mr *MockClientMockRecorder) OpenIDConfiguration(ctx, issuer any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenIDConfiguration", reflect.TypeOf((*MockClient)(nil).OpenIDConfiguration), ctx, issuer) } -// OpenIdCredentialIssuerMetadata mocks base method. -func (m *MockClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OpenIdCredentialIssuerMetadata", ctx, oauthIssuerURI) - ret0, _ := ret[0].(*oauth.OpenIDCredentialIssuerMetadata) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// OpenIdCredentialIssuerMetadata indicates an expected call of OpenIdCredentialIssuerMetadata. -func (mr *MockClientMockRecorder) OpenIdCredentialIssuerMetadata(ctx, oauthIssuerURI any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenIdCredentialIssuerMetadata", reflect.TypeOf((*MockClient)(nil).OpenIdCredentialIssuerMetadata), ctx, oauthIssuerURI) -} - // PostAuthorizationResponse mocks base method. func (m *MockClient) PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI, state string) (string, error) { m.ctrl.T.Helper() @@ -207,18 +192,3 @@ func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjec mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection) } - -// VerifiableCredentials mocks base method. -func (m *MockClient) VerifiableCredentials(ctx context.Context, credentialEndpoint, accessToken, proofJWT string) (*CredentialResponse, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "VerifiableCredentials", ctx, credentialEndpoint, accessToken, proofJWT) - ret0, _ := ret[0].(*CredentialResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// VerifiableCredentials indicates an expected call of VerifiableCredentials. -func (mr *MockClientMockRecorder) VerifiableCredentials(ctx, credentialEndpoint, accessToken, proofJWT any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifiableCredentials", reflect.TypeOf((*MockClient)(nil).VerifiableCredentials), ctx, credentialEndpoint, accessToken, proofJWT) -} diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index bf7f8fef68..7116dec09d 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -346,24 +346,6 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID return &tokenResponse, nil } -func (c *OpenID4VPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) { - iamClient := c.httpClient - rsp, err := iamClient.OpenIdCredentialIssuerMetadata(ctx, oauthIssuerURI) - if err != nil { - return nil, fmt.Errorf("failed to retrieve Openid credential issuer metadata: %w", err) - } - return rsp, nil -} - -func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, proofJWT string) (*CredentialResponse, error) { - iamClient := c.httpClient - rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, proofJWT) - if err != nil { - return nil, fmt.Errorf("remote server: failed to retrieve credentials: %w", err) - } - return rsp, nil -} - func (c *OpenID4VPClient) dpop(ctx context.Context, requester did.DID, request http.Request) (string, string, error) { // find the key to sign the DPoP token with keyID, _, err := c.keyResolver.ResolveKey(requester, nil, resolver.AssertionMethod) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index f4a725a09c..99cc6f305d 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -39,6 +39,7 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/crypto" http2 "github.com/nuts-foundation/nuts-node/test/http" "github.com/nuts-foundation/nuts-node/vcr/holder" @@ -512,7 +513,7 @@ type clientTestContext struct { type clientServerTestContext struct { *clientTestContext authzServerMetadata *oauth.AuthorizationServerMetadata - openIDCredentialIssuerMetadata *oauth.OpenIDCredentialIssuerMetadata + openIDCredentialIssuerMetadata *openid4vci.OpenIDCredentialIssuerMetadata handler http.HandlerFunc tlsServer *httptest.Server verifierDID did.DID @@ -524,12 +525,13 @@ type clientServerTestContext struct { presentationDefinition func(writer http.ResponseWriter) response func(writer http.ResponseWriter) token func(writer http.ResponseWriter) + nonce func(writer http.ResponseWriter) credentials func(writer http.ResponseWriter) requestObjectJWT func(writer http.ResponseWriter) } func createClientServerTestContext(t *testing.T) *clientServerTestContext { - credentialIssuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{} + credentialIssuerMetadata := &openid4vci.OpenIDCredentialIssuerMetadata{} metadata := &oauth.AuthorizationServerMetadata{VPFormatsSupported: oauth.DefaultOpenIDSupportedFormats(), DIDMethodsSupported: []string{"test"}} ctx := &clientServerTestContext{ clientTestContext: createClientTestContext(t, nil), @@ -578,10 +580,16 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { _, _ = writer.Write([]byte(`{"access_token": "token", "token_type": "bearer"}`)) return }, + nonce: func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte(`{"c_nonce": "server-nonce"}`)) + return + }, credentials: func(writer http.ResponseWriter) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) - _, _ = writer.Write([]byte(`{"format": "format", "credential": "credential"}`)) + _, _ = writer.Write([]byte(`{"credentials": [{"credential": {"type": "VerifiableCredential"}}]}`)) return }, requestObjectJWT: func(writer http.ResponseWriter) { @@ -628,6 +636,11 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { ctx.token(writer) return } + case "/nonce": + if ctx.nonce != nil { + ctx.nonce(writer) + return + } case "/credentials": if ctx.credentials != nil { ctx.credentials(writer) @@ -659,64 +672,3 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { return ctx } -func TestIAMClient_OpenIdCredentialIssuerMetadata(t *testing.T) { - t.Run("ok", func(t *testing.T) { - ctx := createClientServerTestContext(t) - - metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") - - require.NoError(t, err) - require.NotNil(t, metadata) - assert.Equal(t, *ctx.openIDCredentialIssuerMetadata, *metadata) - }) - t.Run("error - failed to get metadata", func(t *testing.T) { - ctx := createClientServerTestContext(t) - ctx.credentialIssuerMetadata = nil - - response, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") - - require.Error(t, err) - assert.Nil(t, response) - assert.EqualError(t, err, "failed to retrieve Openid credential issuer metadata: server returned HTTP 404 (expected: 200)") - }) -} - -func TestIAMClient_VerifiableCredentials(t *testing.T) { - accessToken := "code" - proowJWT := "top secret" - - t.Run("ok", func(t *testing.T) { - ctx := createClientServerTestContext(t) - - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, proowJWT) - - require.NoError(t, err) - require.NotNil(t, response) - assert.Equal(t, "credential", response.Credential) - }) - t.Run("error - failed to get access token", func(t *testing.T) { - ctx := createClientServerTestContext(t) - - ctx.credentials = nil - - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, proowJWT) - - assert.EqualError(t, err, "remote server: failed to retrieve credentials: server returned HTTP 404 (expected: 200)") - assert.Nil(t, response) - }) - t.Run("error - invalid access token", func(t *testing.T) { - ctx := createClientServerTestContext(t) - - ctx.credentials = func(writer http.ResponseWriter) { - writer.Header().Add("Content-Type", "application/json") - writer.WriteHeader(http.StatusOK) - _, _ = writer.Write([]byte(`{"format": "format", "credential": fail}`)) - return - } - - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, proowJWT) - - assert.Error(t, err) - assert.Nil(t, response) - }) -} diff --git a/auth/interface.go b/auth/interface.go index 6a0cd7eecb..894911652a 100644 --- a/auth/interface.go +++ b/auth/interface.go @@ -20,6 +20,7 @@ package auth import ( "github.com/nuts-foundation/nuts-node/auth/client/iam" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/auth/services" "github.com/nuts-foundation/nuts-node/auth/services/oauth" "net/url" @@ -34,6 +35,8 @@ type AuthenticationServices interface { AuthzServer() oauth.AuthorizationServer // IAMClient returns the IAM Client API IAMClient() iam.Client + // OpenID4VCIClient returns the OpenID4VCI 1.0 HTTP client. + OpenID4VCIClient() openid4vci.Client // RelyingParty returns the oauth.RelyingParty RelyingParty() oauth.RelyingParty // ContractNotary returns an instance of ContractNotary diff --git a/auth/mock.go b/auth/mock.go index e92db3f34a..a04365018a 100644 --- a/auth/mock.go +++ b/auth/mock.go @@ -14,6 +14,7 @@ import ( reflect "reflect" iam "github.com/nuts-foundation/nuts-node/auth/client/iam" + openid4vci "github.com/nuts-foundation/nuts-node/auth/openid4vci" services "github.com/nuts-foundation/nuts-node/auth/services" oauth "github.com/nuts-foundation/nuts-node/auth/services/oauth" gomock "go.uber.org/mock/gomock" @@ -99,6 +100,20 @@ func (mr *MockAuthenticationServicesMockRecorder) IAMClient() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IAMClient", reflect.TypeOf((*MockAuthenticationServices)(nil).IAMClient)) } +// OpenID4VCIClient mocks base method. +func (m *MockAuthenticationServices) OpenID4VCIClient() openid4vci.Client { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OpenID4VCIClient") + ret0, _ := ret[0].(openid4vci.Client) + return ret0 +} + +// OpenID4VCIClient indicates an expected call of OpenID4VCIClient. +func (mr *MockAuthenticationServicesMockRecorder) OpenID4VCIClient() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenID4VCIClient", reflect.TypeOf((*MockAuthenticationServices)(nil).OpenID4VCIClient)) +} + // PublicURL mocks base method. func (m *MockAuthenticationServices) PublicURL() *url.URL { m.ctrl.T.Helper() diff --git a/auth/oauth/error.go b/auth/oauth/error.go index 905a67798e..fcb92da65e 100644 --- a/auth/oauth/error.go +++ b/auth/oauth/error.go @@ -63,6 +63,10 @@ const ( InvalidRequestURI ErrorCode = "invalid_request_uri" // InvalidRequestURIMethod is returned when the request_uri_method is not 'get' or 'post'. (OpenID4VP) InvalidRequestURIMethod ErrorCode = "invalid_request_uri_method" + // InvalidNonce is returned when at least one of the key proofs in a Credential + // Request contains an invalid c_nonce. The wallet should fetch a new c_nonce + // from the Nonce Endpoint (OpenID4VCI 1.0 §8.3.1.2). + InvalidNonce ErrorCode = "invalid_nonce" ) // Make sure the error implements core.HTTPStatusCodeError, so the HTTP request logger can log the correct status code. diff --git a/auth/oauth/types.go b/auth/oauth/types.go index c0a6d769d2..f039884c92 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -116,6 +116,17 @@ func (t TokenResponse) Get(key string) string { return "" } +// GetAny returns the value of the additional parameter with the given key, untyped. +// Use this for structured extension parameters such as authorization_details (RFC 9396). +// The boolean indicates whether the key was present. +func (t TokenResponse) GetAny(key string) (interface{}, bool) { + if t.additionalParams == nil { + return nil, false + } + val, ok := t.additionalParams[key] + return val, ok +} + const ( // AccessTokenRequestStatusPending is the status for a pending access token AccessTokenRequestStatusPending = "pending" @@ -403,18 +414,6 @@ type Redirect struct { RedirectURI string `json:"redirect_uri"` } -// OpenIDCredentialIssuerMetadata represents the metadata of an OpenID credential issuer -type OpenIDCredentialIssuerMetadata struct { - // - CredentialIssuer: an url representing the credential issuer - CredentialIssuer string `json:"credential_issuer"` - // - CredentialEndpoint: an url representing the credential endpoint - CredentialEndpoint string `json:"credential_endpoint"` - // - AuthorizationServers: a slice of urls representing the authorization servers (optional) - AuthorizationServers []string `json:"authorization_servers,omitempty"` - // - Display: a slice of maps where each map represents the display information (optional) - Display []map[string]string `json:"display,omitempty"` -} - // OpenIDConfiguration represents the OpenID configuration // It contains the minimal information required for OpenID4VP, the required `jwks` is also omitted // see https://openid.net/specs/openid-connect-federation-1_0-29.html#entity-statement diff --git a/auth/openid4vci/client.go b/auth/openid4vci/client.go new file mode 100644 index 0000000000..23f34b70e0 --- /dev/null +++ b/auth/openid4vci/client.go @@ -0,0 +1,245 @@ +/* + * Nuts node + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package openid4vci + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/core" +) + +// wellKnownPath is the path segment defined in OpenID4VCI 1.0 §12.2.2 for +// retrieving the Credential Issuer Metadata document. +const wellKnownPath = "/.well-known/openid-credential-issuer" + +// RequestCredentialOpts carries all parameters for a Credential Request. +// +// CredentialIdentifier and CredentialConfigurationID are mutually exclusive +// per §8.2: when the Token Response carried authorization_details with +// credential_identifiers, the wallet MUST set CredentialIdentifier (and +// CredentialConfigurationID MUST NOT be present); otherwise the wallet sets +// CredentialConfigurationID. If both are non-empty, CredentialIdentifier +// takes precedence to enforce the spec rule. +type RequestCredentialOpts struct { + CredentialEndpoint string + AccessToken string + CredentialConfigurationID string + CredentialIdentifier string + ProofJWT string +} + +// Client is the OpenID4VCI 1.0 HTTP client interface. +// It covers the three wire interactions a wallet makes against a Credential +// Issuer: fetching issuer metadata, obtaining a fresh nonce, and requesting +// a credential. +type Client interface { + // OpenIDCredentialIssuerMetadata fetches and parses the Credential Issuer + // Metadata document. The well-known URL is constructed from issuerURL per + // RFC 8615 (well-known segment inserted at the authority root, with the + // issuer path appended after). + OpenIDCredentialIssuerMetadata(ctx context.Context, issuerURL string) (*OpenIDCredentialIssuerMetadata, error) + + // RequestNonce retrieves a fresh c_nonce from the Nonce Endpoint (§7.2). + RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) + + // RequestCredential posts a Credential Request (§8.2) and returns the + // Credential Response (§8.3). On non-2xx the method returns a structured + // Error when the body is a valid OpenID4VCI error object; otherwise a + // generic error. + RequestCredential(ctx context.Context, opts RequestCredentialOpts) (*CredentialResponse, error) +} + +// NewClient returns a Client backed by the provided HTTP request doer. +// In production callers should pass *httpclient.StrictHTTPClient so the +// shared transport policies apply (HTTPS-in-strict, body size limit, +// User-Agent header). +// +// When strictMode is true, target URLs are additionally validated via +// core.ParsePublicURL: HTTPS scheme, no IP hosts, no reserved hostnames. +func NewClient(httpClient core.HTTPRequestDoer, strictMode bool) Client { + return &client{httpClient: httpClient, strictMode: strictMode} +} + +type client struct { + httpClient core.HTTPRequestDoer + strictMode bool +} + +// validateURL guards against SSRF by rejecting target URLs that fail +// core.ParsePublicURL (in strict mode: HTTPS only, no IP/reserved hosts). +// Called at the entry of every method that makes outbound HTTP. +// +// TODO: this validation belongs on httpclient.StrictHTTPClient so every +// outbound HTTP call (not just OpenID4VCI) gets the IP/reserved-host check, +// not only the HTTPS scheme check that StrictHTTPClient.Do enforces today. +// Placed here for now to preserve parity with master, where the equivalent +// caller (auth/client/iam.HTTPClient) validated via oauth.IssuerIdToWellKnown +// → core.ParsePublicURL before issuing the request, and to address a CodeQL +// SSRF finding on this PR. Tracked as a follow-up to consolidate the check +// in the shared HTTP client. +func (c *client) validateURL(name, target string) error { + if _, err := core.ParsePublicURL(target, c.strictMode); err != nil { + return fmt.Errorf("openid4vci: invalid %s URL: %w", name, err) + } + return nil +} + +func (c *client) OpenIDCredentialIssuerMetadata(ctx context.Context, issuerURL string) (*OpenIDCredentialIssuerMetadata, error) { + if err := c.validateURL("issuer", issuerURL); err != nil { + return nil, err + } + // Per §12.2.1, the Credential Issuer Identifier MUST NOT contain query + // or fragment components. + if parsed, _ := url.Parse(issuerURL); parsed != nil && (parsed.RawQuery != "" || parsed.Fragment != "") { + return nil, fmt.Errorf("openid4vci: invalid issuer URL: query and fragment components are not allowed") + } + wellKnownURL, err := credentialIssuerWellKnown(issuerURL) + if err != nil { + return nil, fmt.Errorf("openid4vci: invalid issuer URL: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL, http.NoBody) + if err != nil { + return nil, err + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("openid4vci: fetching issuer metadata returned status %d", resp.StatusCode) + } + var metadata OpenIDCredentialIssuerMetadata + if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { + return nil, fmt.Errorf("openid4vci: decoding issuer metadata: %w", err) + } + // Per §12.2.4: the credential_issuer value MUST match the issuer identifier + // the metadata document was retrieved for. Mismatched metadata MUST NOT be used. + if metadata.CredentialIssuer != issuerURL { + return nil, fmt.Errorf("openid4vci: credential_issuer %q does not match requested issuer %q", metadata.CredentialIssuer, issuerURL) + } + return &metadata, nil +} + +func (c *client) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { + if err := c.validateURL("nonce endpoint", nonceEndpoint); err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, nonceEndpoint, http.NoBody) + if err != nil { + return "", err + } + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return "", fmt.Errorf("openid4vci: nonce endpoint returned status %d", resp.StatusCode) + } + var nonceResp NonceResponse + if err := json.NewDecoder(resp.Body).Decode(&nonceResp); err != nil { + return "", fmt.Errorf("openid4vci: decoding nonce response: %w", err) + } + if nonceResp.CNonce == "" { + return "", fmt.Errorf("openid4vci: nonce endpoint returned empty c_nonce") + } + return nonceResp.CNonce, nil +} + +func (c *client) RequestCredential(ctx context.Context, opts RequestCredentialOpts) (*CredentialResponse, error) { + if err := c.validateURL("credential endpoint", opts.CredentialEndpoint); err != nil { + return nil, err + } + body := CredentialRequest{ + Proofs: &CredentialRequestProofs{ + JWT: []string{opts.ProofJWT}, + }, + } + // Per §8.2: CredentialIdentifier and CredentialConfigurationID are mutually + // exclusive. CredentialIdentifier wins when set. + if opts.CredentialIdentifier != "" { + body.CredentialIdentifier = opts.CredentialIdentifier + } else { + body.CredentialConfigurationID = opts.CredentialConfigurationID + } + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, opts.CredentialEndpoint, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+opts.AccessToken) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Buffer the body once so the non-2xx path can attempt structured-error + // parsing before falling back to a generic error. + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + var oauthErr oauth.OAuth2Error + if jsonErr := json.Unmarshal(respBody, &oauthErr); jsonErr == nil && oauthErr.Code != "" { + return nil, oauthErr + } + return nil, fmt.Errorf("openid4vci: credential endpoint returned status %d", resp.StatusCode) + } + var credResp CredentialResponse + if err := json.Unmarshal(respBody, &credResp); err != nil { + return nil, fmt.Errorf("openid4vci: decoding credential response: %w", err) + } + return &credResp, nil +} + +// credentialIssuerWellKnown returns the Credential Issuer Metadata URL for +// the given issuer identifier per RFC 8615: the well-known segment is +// inserted at the authority root, and the issuer's path is appended after. +// +// Example: https://example.com/oauth2/alice +// -> https://example.com/.well-known/openid-credential-issuer/oauth2/alice +func credentialIssuerWellKnown(issuerURL string) (string, error) { + u, err := url.Parse(issuerURL) + if err != nil { + return "", err + } + // Prepend the well-known segment to both Path (decoded) and RawPath + // (encoded) when the latter is set, so u.String() does not double-escape + // pre-encoded characters like %2F via EscapedPath's reescaping pass. + u.Path = wellKnownPath + u.Path + if u.RawPath != "" { + u.RawPath = wellKnownPath + u.RawPath + } + return u.String(), nil +} diff --git a/auth/openid4vci/client_test.go b/auth/openid4vci/client_test.go new file mode 100644 index 0000000000..c88034d64e --- /dev/null +++ b/auth/openid4vci/client_test.go @@ -0,0 +1,293 @@ +/* + * Nuts node + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package openid4vci + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ---- RequestNonce ---- + +func TestClient_RequestNonce(t *testing.T) { + t.Run("returns c_nonce from response", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(NonceResponse{CNonce: "test-nonce-123"}) + })) + defer srv.Close() + + client := NewClient(srv.Client(), false) + nonce, err := client.RequestNonce(context.Background(), srv.URL) + require.NoError(t, err) + assert.Equal(t, "test-nonce-123", nonce) + }) + + t.Run("error on non-2xx", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + })) + defer srv.Close() + + client := NewClient(srv.Client(), false) + _, err := client.RequestNonce(context.Background(), srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "500") + }) + + t.Run("error on empty c_nonce", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(NonceResponse{CNonce: ""}) + })) + defer srv.Close() + + client := NewClient(srv.Client(), false) + _, err := client.RequestNonce(context.Background(), srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty c_nonce") + }) +} + +// ---- OpenIDCredentialIssuerMetadata ---- + +func TestClient_OpenIDCredentialIssuerMetadata(t *testing.T) { + t.Run("fetches and parses metadata from well-known path", func(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/.well-known/openid-credential-issuer", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(OpenIDCredentialIssuerMetadata{ + CredentialIssuer: srv.URL, + CredentialEndpoint: srv.URL + "/credential", + NonceEndpoint: srv.URL + "/nonce", + }) + })) + defer srv.Close() + + client := NewClient(srv.Client(), false) + metadata, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL) + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, srv.URL, metadata.CredentialIssuer) + assert.Equal(t, srv.URL+"/credential", metadata.CredentialEndpoint) + assert.Equal(t, srv.URL+"/nonce", metadata.NonceEndpoint) + }) + + t.Run("appends issuer path after well-known segment per RFC 8615", func(t *testing.T) { + var capturedPath string + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(OpenIDCredentialIssuerMetadata{CredentialIssuer: srv.URL + "/oauth2/alice"}) + })) + defer srv.Close() + + client := NewClient(srv.Client(), false) + _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL+"/oauth2/alice") + require.NoError(t, err) + assert.Equal(t, "/.well-known/openid-credential-issuer/oauth2/alice", capturedPath) + }) + + t.Run("preserves percent-encoded path segments without double-escaping", func(t *testing.T) { + var capturedRawPath string + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedRawPath = r.URL.EscapedPath() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(OpenIDCredentialIssuerMetadata{CredentialIssuer: srv.URL + "/foo%2Fbar"}) + })) + defer srv.Close() + + client := NewClient(srv.Client(), false) + _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL+"/foo%2Fbar") + require.NoError(t, err) + assert.Equal(t, "/.well-known/openid-credential-issuer/foo%2Fbar", capturedRawPath) + }) + + t.Run("rejects metadata when credential_issuer mismatches requested issuer", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://attacker.example/", + }) + })) + defer srv.Close() + + client := NewClient(srv.Client(), false) + _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "credential_issuer") + assert.Contains(t, err.Error(), "does not match") + }) + + t.Run("error on non-2xx", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer srv.Close() + + client := NewClient(srv.Client(), false) + _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "404") + }) + + t.Run("rejects non-https issuer URL in strict mode", func(t *testing.T) { + client := NewClient(http.DefaultClient, true) + _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), "http://issuer.example/") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid issuer URL") + }) + + t.Run("rejects issuer URL with query or fragment per §12.2.1", func(t *testing.T) { + client := NewClient(http.DefaultClient, false) + _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), "https://issuer.example/?foo=bar") + require.Error(t, err) + assert.Contains(t, err.Error(), "query and fragment") + + _, err = client.OpenIDCredentialIssuerMetadata(context.Background(), "https://issuer.example/#section") + require.Error(t, err) + assert.Contains(t, err.Error(), "query and fragment") + }) + + t.Run("error on bad JSON body", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("{not valid json")) + })) + defer srv.Close() + + client := NewClient(srv.Client(), false) + _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "decoding issuer metadata") + }) +} + +// ---- RequestCredential ---- + +func TestClient_RequestCredential(t *testing.T) { + t.Run("posts request and parses response", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + var credReq CredentialRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&credReq)) + assert.Equal(t, "SomeCredentialConfig", credReq.CredentialConfigurationID) + require.NotNil(t, credReq.Proofs) + assert.Equal(t, []string{"proof-jwt-value"}, credReq.Proofs.JWT) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(CredentialResponse{ + Credentials: []CredentialResponseEntry{ + {Credential: json.RawMessage(`"eyJhbGciOiJFUzI1NiJ9"`)}, + }, + }) + })) + defer srv.Close() + + client := NewClient(srv.Client(), false) + resp, err := client.RequestCredential(context.Background(), RequestCredentialOpts{ + CredentialEndpoint: srv.URL, + AccessToken: "test-token", + CredentialConfigurationID: "SomeCredentialConfig", + ProofJWT: "proof-jwt-value", + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.Len(t, resp.Credentials, 1) + assert.JSONEq(t, `"eyJhbGciOiJFUzI1NiJ9"`, string(resp.Credentials[0].Credential)) + }) + + t.Run("uses credential_identifier when provided and omits credential_configuration_id", func(t *testing.T) { + var credReq CredentialRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewDecoder(r.Body).Decode(&credReq)) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(CredentialResponse{ + Credentials: []CredentialResponseEntry{{Credential: json.RawMessage(`"vc"`)}}, + }) + })) + defer srv.Close() + + client := NewClient(srv.Client(), false) + _, err := client.RequestCredential(context.Background(), RequestCredentialOpts{ + CredentialEndpoint: srv.URL, + AccessToken: "t", + CredentialConfigurationID: "ignored-when-identifier-set", + CredentialIdentifier: "CivilEngineeringDegree-2023", + ProofJWT: "p", + }) + require.NoError(t, err) + assert.Equal(t, "CivilEngineeringDegree-2023", credReq.CredentialIdentifier) + assert.Empty(t, credReq.CredentialConfigurationID, "credential_configuration_id MUST NOT be present when credential_identifier is used (§8.2)") + }) + + t.Run("returns structured oauth.OAuth2Error on invalid_nonce", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(oauth.OAuth2Error{Code: oauth.InvalidNonce}) + })) + defer srv.Close() + + client := NewClient(srv.Client(), false) + _, err := client.RequestCredential(context.Background(), RequestCredentialOpts{ + CredentialEndpoint: srv.URL, + AccessToken: "test-token", + }) + require.Error(t, err) + + var oauthErr oauth.OAuth2Error + require.True(t, errors.As(err, &oauthErr)) + assert.Equal(t, oauth.InvalidNonce, oauthErr.Code) + }) + + t.Run("returns generic error on non-2xx with no structured body", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "something went wrong", http.StatusServiceUnavailable) + })) + defer srv.Close() + + client := NewClient(srv.Client(), false) + _, err := client.RequestCredential(context.Background(), RequestCredentialOpts{ + CredentialEndpoint: srv.URL, + AccessToken: "test-token", + }) + require.Error(t, err) + + var oauthErr oauth.OAuth2Error + assert.False(t, errors.As(err, &oauthErr)) + assert.Contains(t, err.Error(), "503") + }) +} diff --git a/auth/openid4vci/mock.go b/auth/openid4vci/mock.go new file mode 100644 index 0000000000..85369a8fee --- /dev/null +++ b/auth/openid4vci/mock.go @@ -0,0 +1,86 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: auth/openid4vci/client.go +// +// Generated by this command: +// +// mockgen -destination=auth/openid4vci/mock.go -package=openid4vci -source=auth/openid4vci/client.go +// + +// Package openid4vci is a generated GoMock package. +package openid4vci + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder + isgomock struct{} +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// OpenIDCredentialIssuerMetadata mocks base method. +func (m *MockClient) OpenIDCredentialIssuerMetadata(ctx context.Context, issuerURL string) (*OpenIDCredentialIssuerMetadata, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OpenIDCredentialIssuerMetadata", ctx, issuerURL) + ret0, _ := ret[0].(*OpenIDCredentialIssuerMetadata) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// OpenIDCredentialIssuerMetadata indicates an expected call of OpenIDCredentialIssuerMetadata. +func (mr *MockClientMockRecorder) OpenIDCredentialIssuerMetadata(ctx, issuerURL any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenIDCredentialIssuerMetadata", reflect.TypeOf((*MockClient)(nil).OpenIDCredentialIssuerMetadata), ctx, issuerURL) +} + +// RequestCredential mocks base method. +func (m *MockClient) RequestCredential(ctx context.Context, opts RequestCredentialOpts) (*CredentialResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequestCredential", ctx, opts) + ret0, _ := ret[0].(*CredentialResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequestCredential indicates an expected call of RequestCredential. +func (mr *MockClientMockRecorder) RequestCredential(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestCredential", reflect.TypeOf((*MockClient)(nil).RequestCredential), ctx, opts) +} + +// RequestNonce mocks base method. +func (m *MockClient) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequestNonce", ctx, nonceEndpoint) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequestNonce indicates an expected call of RequestNonce. +func (mr *MockClientMockRecorder) RequestNonce(ctx, nonceEndpoint any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestNonce", reflect.TypeOf((*MockClient)(nil).RequestNonce), ctx, nonceEndpoint) +} diff --git a/auth/openid4vci/types.go b/auth/openid4vci/types.go new file mode 100644 index 0000000000..c763664246 --- /dev/null +++ b/auth/openid4vci/types.go @@ -0,0 +1,98 @@ +/* + * Nuts node + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Package openid4vci implements the OpenID for Verifiable Credential Issuance +// 1.0 (ID-1) protocol surface used by the user/browser flow in auth/api/iam. +// +// This package owns the v1.0 protocol types, error codes, and the HTTP client +// used to talk to a Credential Issuer. Consumers in auth/api/iam (HTTP +// handlers) and auth/client/iam (low-level HTTP plumbing) import from here. +// +// This package is independent of vcr/openid4vci, which is an internal +// node-to-node draft-11 issuance flow that diverges from v1.0 in several +// material ways and is not consumed from auth/. +// +// Reference: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html +package openid4vci + +import ( + "encoding/json" +) + +// JWTTypeOpenID4VCIProof is the JWT typ claim value used in OpenID4VCI key +// proofs (Appendix F.1). +const JWTTypeOpenID4VCIProof = "openid4vci-proof+jwt" + +// OpenIDCredentialIssuerMetadata describes the OpenID4VCI Credential Issuer +// Metadata document published at /.well-known/openid-credential-issuer +// (Section 12.2). The document is OpenID4VCI-defined; it is not an OAuth +// authorization-server metadata document. +type OpenIDCredentialIssuerMetadata struct { + CredentialIssuer string `json:"credential_issuer"` + CredentialEndpoint string `json:"credential_endpoint"` + NonceEndpoint string `json:"nonce_endpoint,omitempty"` + AuthorizationServers []string `json:"authorization_servers,omitempty"` + Display []map[string]string `json:"display,omitempty"` +} + +// NonceResponse is the body returned by the Nonce Endpoint (Section 7.2). +type NonceResponse struct { + CNonce string `json:"c_nonce"` +} + +// CredentialRequest is the body of a Credential Request (Section 8.2). +// +// Either CredentialConfigurationID or CredentialIdentifier identifies the +// requested credential — see §5.1.1: when the Token Response carried +// authorization_details with credential_identifiers, the wallet sends +// CredentialIdentifier; otherwise it sends CredentialConfigurationID. +// Today the auth-side flow only emits CredentialConfigurationID; the field +// for CredentialIdentifier is present so future support is non-breaking. +type CredentialRequest struct { + CredentialConfigurationID string `json:"credential_configuration_id,omitempty"` + CredentialIdentifier string `json:"credential_identifier,omitempty"` + Proofs *CredentialRequestProofs `json:"proofs,omitempty"` +} + +// CredentialRequestProofs carries one or more key proofs in a Credential +// Request (the proofs parameter defined in Section 8.2; proof type formats +// are listed in Appendix F). +type CredentialRequestProofs struct { + JWT []string `json:"jwt,omitempty"` +} + +// CredentialResponse is the body returned by the Credential Endpoint +// (Section 8.3). +// +// TransactionID, Interval, and NotificationID are present for forward +// compatibility (deferred issuance via HTTP 202 with a transaction id, and +// notification ids consumed by the Notification Endpoint in §11). The +// auth-side flow today consumes only Credentials; the other fields are +// populated when the issuer sends them so they are available without a +// wire-format change later. +type CredentialResponse struct { + Credentials []CredentialResponseEntry `json:"credentials,omitempty"` + TransactionID string `json:"transaction_id,omitempty"` + Interval int `json:"interval,omitempty"` + NotificationID string `json:"notification_id,omitempty"` +} + +// CredentialResponseEntry is one issued credential in a Credential Response. +type CredentialResponseEntry struct { + Credential json.RawMessage `json:"credential"` +} diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 01cf6ba826..ce1419e30f 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -165,19 +165,15 @@ paths: used to locate the OAuth2 Authorization Server metadata. example: did:web:issuer.example.com authorization_details: + description: | + Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1.1. + The current implementation processes a single credential + issuance per call and only consumes the first entry. type: array + minItems: 1 + maxItems: 1 items: - type: object - description: | - The request parameter authorization_details defined in Section 2 of [RFC9396] MUST be used to convey the details about the Credentials the Wallet wants to obtain. - See the RFC9396/OpenID4VCI for the format of an authorization_details object, and consult the Credential Issuer for requestable credentials. - example: | - [ - { - "type": "openid_credential", - "credential_configuration_id": "UniversityDegreeCredential" - } - ] + $ref: '#/components/schemas/AuthorizationDetail' redirect_uri: type: string description: | @@ -721,6 +717,31 @@ components: description: | Presentation Definitions, as described in Presentation Exchange specification, fulfilled to obtain the access token The map key is the wallet owner (user/organization) + AuthorizationDetail: + description: | + A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1.1. + Only the fields used by the user/browser issuance flow are modeled. + type: object + required: + - type + - credential_configuration_id + properties: + type: + type: string + enum: [openid_credential] + description: | + The authorization details type. For OpenID4VCI flows this MUST + be "openid_credential" per §5.1.1. + credential_configuration_id: + type: string + description: | + References a credential configuration from the issuer's + credential_configurations_supported metadata. REQUIRED for + type=openid_credential per §5.1.1. + format: + type: string + description: | + Optional credential format hint (e.g. "vc+sd-jwt"). securitySchemes: jwtBearerAuth: type: http diff --git a/docs/pages/deployment/oauth.rst b/docs/pages/deployment/oauth.rst index 61891dae61..71b7dcbb6c 100644 --- a/docs/pages/deployment/oauth.rst +++ b/docs/pages/deployment/oauth.rst @@ -14,6 +14,7 @@ The Nuts node implements (parts of) the following RFCs: - `RFC 9449 `_ - OAuth 2.0 Demonstrating Proof of Possession (DPoP) - `Nuts RFC021 `_ - RFC021 VP Token Grant Type - `OpenID4VP `_ - OpenID for Verifiable Presentations - draft 20 +- `OpenID4VCI `_ - OpenID for Verifiable Credential Issuance 1.0 (ID-1) - `StatusList2021 `_ - Status List 2021 - `Presentation Exchange `_ - Presentation Exchange @@ -49,6 +50,31 @@ The Nuts node implements the following: DPoP is optional, usage is determined by the client. +OpenID4VCI +********** + +The Nuts node implements the OpenID for Verifiable Credential Issuance 1.0 wallet flow. +On behalf of a user, the node requests a Verifiable Credential from a remote Credential Issuer over the Authorization Code Flow: + +- Authorization Request with ``authorization_details`` of type ``openid_credential`` (RFC 9396 / OpenID4VCI §5.1.1). +- PKCE for the authorization code, as in the OpenID4VP flow. +- Token Response with ``credential_identifiers`` per the requested ``credential_configuration_id`` (§6.2 and §3.3.4). +- Nonce Endpoint to obtain a fresh ``c_nonce`` before requesting a Credential (§7). +- Credential Request with a key proof JWT bound to the holder's DID (Appendix F.1). +- On an ``invalid_nonce`` response, the wallet fetches a fresh ``c_nonce`` and retries the Credential Request once (§8.3.1.2 prescribes fetching a new ``c_nonce``; retrying once is local policy). + +The relevant API: + +- ``POST /internal/auth/v2/{subjectID}/request-credential`` + +Not implemented: + +- Deferred issuance (HTTP 202 with ``transaction_id`` / ``interval``). +- The Notification Endpoint (§11). ``notification_id`` returned by the issuer is ignored. +- Multiple credentials per call: only a single ``authorization_details`` entry is accepted and only the first credential in the response is processed. + +Note: the unrelated internal flow in the ``vcr/openid4vci`` package is used by Nuts nodes to issue ``NutsAuthorizationCredential`` to each other over HTTP. That flow is based on a subset of OpenID4VCI draft-11 and is not a wallet implementation per §2. + DPoP **** diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 51c1f31772..95a69b3541 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -20,6 +20,11 @@ const ( JwtBearerAuthScopes = "jwtBearerAuth.Scopes" ) +// Defines values for AuthorizationDetailType. +const ( + OpenidCredential AuthorizationDetailType = "openid_credential" +) + // Defines values for ServiceAccessTokenRequestTokenType. const ( ServiceAccessTokenRequestTokenTypeBearer ServiceAccessTokenRequestTokenType = "Bearer" @@ -32,6 +37,26 @@ const ( UserAccessTokenRequestTokenTypeDPoP UserAccessTokenRequestTokenType = "DPoP" ) +// AuthorizationDetail A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1.1. +// Only the fields used by the user/browser issuance flow are modeled. +type AuthorizationDetail struct { + // CredentialConfigurationId References a credential configuration from the issuer's + // credential_configurations_supported metadata. REQUIRED for + // type=openid_credential per §5.1.1. + CredentialConfigurationId string `json:"credential_configuration_id"` + + // Format Optional credential format hint (e.g. "vc+sd-jwt"). + Format *string `json:"format,omitempty"` + + // Type The authorization details type. For OpenID4VCI flows this MUST + // be "openid_credential" per §5.1.1. + Type AuthorizationDetailType `json:"type"` +} + +// AuthorizationDetailType The authorization details type. For OpenID4VCI flows this MUST +// be "openid_credential" per §5.1.1. +type AuthorizationDetailType string + // DPoPRequest defines model for DPoPRequest. type DPoPRequest struct { // Htm The HTTP method for which the DPoP proof is requested. @@ -107,7 +132,7 @@ type ExtendedTokenIntrospectionResponse struct { // PresentationSubmissions Mapping of Presentation Definition IDs that were fulfilled to Presentation Submissions. PresentationSubmissions *map[string]PresentationSubmission `json:"presentation_submissions,omitempty"` - // Scope granted scopes + // Scope Granted scopes, as a space-separated list. Scope *string `json:"scope,omitempty"` Vps *[]VerifiablePresentation `json:"vps,omitempty"` AdditionalProperties map[string]interface{} `json:"-"` @@ -129,12 +154,15 @@ type ServiceAccessTokenRequest struct { AuthorizationServer string `json:"authorization_server"` // CredentialSelection Optional key-value mapping for credential selection when the wallet contains multiple - // credentials matching a single input descriptor. Each key must match a field id declared + // credentials matching a single input descriptor. Each key must match a field ID declared // in the Presentation Definition's input descriptor constraints. The value narrows the // match to credentials where that field equals the given value. // // The selection must narrow to exactly one credential per input descriptor. // Zero matches or multiple matches will result in an error. + // + // When omitted and multiple credentials match an input descriptor, + // the first matching credential is used. CredentialSelection *map[string]string `json:"credential_selection,omitempty"` // Credentials Additional credentials to present (if required by the authorizer), in addition to those in the requester's wallet. @@ -191,7 +219,7 @@ type UserAccessTokenRequestTokenType string // UserDetails Claims about the authorized user. type UserDetails struct { - // Id Machine-readable identifier, uniquely identifying the user in the issuing system. + // Id Machine-readable identifier, uniquely identifying the user in the issuing system. The format is not specified; it could be a username, email address, employee number, etc. Id string `json:"id"` // Name Human-readable name of the user. @@ -209,7 +237,10 @@ type Cnf struct { // RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance. type RequestOpenid4VCICredentialIssuanceJSONBody struct { - AuthorizationDetails []map[string]interface{} `json:"authorization_details"` + // AuthorizationDetails Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1.1. + // The current implementation processes a single credential + // issuance per call and only consumes the first entry. + AuthorizationDetails []AuthorizationDetail `json:"authorization_details"` // Issuer The OAuth Authorization Server's identifier, that issues the Verifiable Credentials, as specified in RFC 8414 (section 2), // used to locate the OAuth2 Authorization Server metadata. diff --git a/makefile b/makefile index fd9a0cfcac..bec1275f5a 100644 --- a/makefile +++ b/makefile @@ -14,6 +14,7 @@ gen-mocks: mockgen -destination=auth/api/iam/jar_mock.go -package=iam -source=auth/api/iam/jar.go mockgen -destination=auth/contract/signer_mock.go -package=contract -source=auth/contract/signer.go mockgen -destination=auth/client/iam/mock.go -package=iam -source=auth/client/iam/interface.go + mockgen -destination=auth/openid4vci/mock.go -package=openid4vci -source=auth/openid4vci/client.go mockgen -destination=auth/services/mock.go -package=services -source=auth/services/services.go mockgen -destination=auth/services/oauth/mock.go -package=oauth -source=auth/services/oauth/interface.go mockgen -destination=auth/services/selfsigned/types/mock.go -package=types -source=auth/services/selfsigned/types/types.go diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index e5d030b005..e425f0e6c0 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -16,8 +16,28 @@ * */ -// This file defines types specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html - +// Package openid4vci implements an internal node-to-node credential +// issuance flow between Nuts nodes. It is the HTTP replacement of the +// Nuts v5 gRPC network for issuing NutsAuthorizationCredentials. +// +// This package is based on a subset of OpenID for Verifiable Credential +// Issuance draft-11. It is NOT a standards-conformant Wallet implementation +// per OpenID4VCI 1.0 §2 and diverges from v1.0 in several ways: +// +// - Single credential per request only. +// - No credential_offer_uri (offers are inline only). +// - No Nonce Endpoint (c_nonce delivered with token/credential responses, +// draft-11 style). +// - No authorization_details with type "openid_credential". +// - Draft-11 wire format for Credential Request and Response. +// +// For the standards-conformant OpenID4VCI 1.0 user/browser flow, see +// the auth/openid4vci package and auth/api/iam/openid4vci.go. +// +// This package is in production. Do not extend or migrate it without a +// deliberate decision — the next migration is the right time to also +// question whether OpenID4VCI is the right protocol for an internal +// node-to-node flow at all. package openid4vci import (