diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 5dbe21544d..6232594bf8 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -206,7 +206,7 @@ type Cnf struct { // RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance. type RequestOpenid4VCICredentialIssuanceJSONBody struct { - AuthorizationDetails []map[string]interface{} `json:"authorization_details"` + AuthorizationDetails *[]map[string]interface{} `json:"authorization_details,omitempty"` // 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. @@ -215,6 +215,11 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // RedirectUri The URL to which the user-agent will be redirected after the authorization request. RedirectUri string `json:"redirect_uri"` + // Scope OAuth2 scope value mapped to a credential configuration in the issuer's metadata (v1.0 Section 5.1.2). + // The issuer's credential_configurations_supported must contain an entry with a matching 'scope' field. + // Can be used together with authorization_details; the issuer interprets them individually. + Scope *string `json:"scope,omitempty"` + // WalletDid The DID to which the Verifiable Credential must be issued. Must be owned by the given subject. WalletDid string `json:"wallet_did"` } diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index e9b3eb5daf..ede9d26104 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -25,6 +25,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "time" "github.com/lestrrat-go/jwx/v2/jwt" @@ -76,21 +77,55 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques clientID := r.subjectToBaseURL(request.SubjectID) - // Read and parse the authorization details - authorizationDetails := []byte("[]") + // Validate authorization_details and/or scope (at least one must be provided) + hasAuthzDetails := request.Body.AuthorizationDetails != nil && len(*request.Body.AuthorizationDetails) > 0 + hasScope := request.Body.Scope != nil && *request.Body.Scope != "" + if !hasAuthzDetails && !hasScope { + return nil, core.InvalidInputError("either authorization_details or scope is required") + } + + var authorizationDetails []byte var credentialConfigID string - if len(request.Body.AuthorizationDetails) > 0 { - authorizationDetails, _ = json.Marshal(request.Body.AuthorizationDetails) - if id, ok := request.Body.AuthorizationDetails[0]["credential_configuration_id"].(string); ok { - credentialConfigID = id + if hasAuthzDetails { + var sanitized []map[string]interface{} + credentialConfigID, sanitized, err = validateAuthorizationDetails(*request.Body.AuthorizationDetails, credentialIssuerMetadata) + if err != nil { + return nil, core.InvalidInputError("%s", err) + } + authorizationDetails, err = json.Marshal(sanitized) + if err != nil { + return nil, fmt.Errorf("failed to marshal authorization_details: %w", err) } } + + // Resolve credential_configuration_id from scope (v1.0 Section 5.1.2) + if hasScope { + scopeConfigID, scopeErr := resolveCredentialConfigIDByScope(*request.Body.Scope, credentialIssuerMetadata) + if scopeErr != nil { + return nil, core.InvalidInputError("%s", scopeErr) + } + // Use scope's credential_configuration_id when authorization_details didn't provide one + if credentialConfigID == "" { + credentialConfigID = scopeConfigID + } + } + // Generate the state and PKCE state := crypto.GenerateNonce() pkceParams := generatePKCEParams() // Figure out our own redirect URL by parsing the did:web and extracting the host. redirectUri := clientID.JoinPath(oauth.CallbackPath) + // Extract proof_signing_alg_values_supported from the credential configuration (v1.0 Appendix F.1) + var proofSigningAlgValues []string + if credentialConfigID != "" { + if config, exists := credentialIssuerMetadata.CredentialConfigurationsSupported[credentialConfigID]; exists { + proofSigningAlgValues, err = openid4vci.ProofSigningAlgValues(config) + if err != nil { + return nil, core.Error(http.StatusFailedDependency, "%s", err) + } + } + } // Store the session err = r.oauthClientStateStore().Put(state, &OAuthSession{ AuthorizationServerMetadata: authzServerMetadata, @@ -102,10 +137,11 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques // 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, + IssuerURL: credentialIssuerMetadata.CredentialIssuer, IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, IssuerNonceEndpoint: credentialIssuerMetadata.NonceEndpoint, IssuerCredentialConfigurationID: credentialConfigID, + ProofSigningAlgValuesSupported: proofSigningAlgValues, }) if err != nil { return nil, fmt.Errorf("failed to store session: %w", err) @@ -115,16 +151,39 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques if err != nil { return nil, fmt.Errorf("failed to parse the authorization_endpoint: %w", err) } - redirectUrl := nutsHttp.AddQueryParams(*authorizationEndpoint, map[string]string{ - oauth.ResponseTypeParam: oauth.CodeResponseType, - oauth.StateParam: state, - oauth.ClientIDParam: clientID.String(), - oauth.ClientIDSchemeParam: entityClientIDScheme, - oauth.AuthorizationDetailsParam: string(authorizationDetails), - oauth.RedirectURIParam: redirectUri.String(), - oauth.CodeChallengeParam: pkceParams.Challenge, - oauth.CodeChallengeMethodParam: pkceParams.ChallengeMethod, - }) + authzParams := url.Values{ + oauth.ResponseTypeParam: {oauth.CodeResponseType}, + oauth.StateParam: {state}, + oauth.ClientIDParam: {clientID.String()}, + oauth.ClientIDSchemeParam: {entityClientIDScheme}, + oauth.RedirectURIParam: {redirectUri.String()}, + oauth.CodeChallengeParam: {pkceParams.Challenge}, + oauth.CodeChallengeMethodParam: {pkceParams.ChallengeMethod}, + } + if hasAuthzDetails { + authzParams.Set(oauth.AuthorizationDetailsParam, string(authorizationDetails)) + } + if hasScope { + authzParams.Set(oauth.ScopeParam, *request.Body.Scope) + } + + var redirectUrl url.URL + if authzServerMetadata.PushedAuthorizationRequestEndpoint != "" { + parResponse, parErr := r.auth.IAMClient().PushedAuthorizationRequest(ctx, authzServerMetadata.PushedAuthorizationRequestEndpoint, authzParams) + if parErr != nil { + return nil, fmt.Errorf("PAR request failed: %w", parErr) + } + redirectUrl = nutsHttp.AddQueryParams(*authorizationEndpoint, map[string]string{ + oauth.ClientIDParam: clientID.String(), + "request_uri": parResponse.RequestURI, + }) + } else { + params := make(map[string]string, len(authzParams)) + for k, v := range authzParams { + params[k] = v[0] + } + redirectUrl = nutsHttp.AddQueryParams(*authorizationEndpoint, params) + } return RequestOpenid4VCICredentialIssuance200JSONResponse{ RedirectURI: redirectUrl.String(), @@ -157,8 +216,11 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode } } + // Check for credential_identifiers in the token response (v1.0 Section 6.2) + credentialIdentifier := extractCredentialIdentifier(tokenResponse) + // build proof and request credential - credentialResponse, err := r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, nonce) + credentialResponse, err := r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, credentialIdentifier, nonce) if err != nil { // on invalid_nonce: fetch a fresh nonce and retry once var oidcErr openid4vci.Error @@ -167,12 +229,15 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error fetching nonce for retry from %s: %s", oauthSession.IssuerNonceEndpoint, err.Error())), appCallbackURI) } - credentialResponse, err = r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, nonce) + 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) } } + if credentialResponse.TransactionID != "" { + return nil, withCallbackURI(oauthError(oauth.ServerError, "deferred credential issuance is not supported"), appCallbackURI) + } if len(credentialResponse.Credentials) == 0 { return nil, withCallbackURI(oauthError(oauth.ServerError, "credential response does not contain any credentials"), appCallbackURI) } @@ -195,26 +260,43 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode }, nil } -func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *OAuthSession, accessToken string, nonce string) (*openid4vci.CredentialResponse, error) { - proofJWT, err := r.openid4vciProof(ctx, *oauthSession.OwnDID, oauthSession.IssuerURL, nonce) +func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *OAuthSession, accessToken string, credentialIdentifier string, nonce string) (*openid4vci.CredentialResponse, error) { + proofJWT, err := r.openid4vciProof(ctx, oauthSession, nonce) if err != nil { return nil, fmt.Errorf("error building proof: %w", err) } - return r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, accessToken, oauthSession.IssuerCredentialConfigurationID, proofJWT) + credentialConfigID := oauthSession.IssuerCredentialConfigurationID + if credentialIdentifier != "" { + credentialConfigID = "" + } + return r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, proofJWT) } -func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audience string, nonce string) (string, error) { - kid, _, err := r.keyResolver.ResolveKey(holderDid, nil, resolver.AssertionMethod) +func (r *Wrapper) openid4vciProof(ctx context.Context, session *OAuthSession, nonce string) (string, error) { + if session.OwnDID == nil { + return "", errors.New("session has no holder DID") + } + holderDid := *session.OwnDID + kid, pubKey, 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) } + if len(session.ProofSigningAlgValuesSupported) > 0 { + alg, algErr := crypto.SignatureAlgorithm(pubKey) + if algErr != nil { + return "", fmt.Errorf("failed to determine signing algorithm: %w", algErr) + } + if err = openid4vci.ValidateProofSigningAlg(alg.String(), session.ProofSigningAlgValuesSupported); err != nil { + return "", err + } + } headers := map[string]interface{}{ "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(), - jwt.AudienceKey: audience, // Credential Issuer Identifier + jwt.AudienceKey: session.IssuerURL, // Credential Issuer Identifier jwt.IssuedAtKey: timeFunc().Unix(), } if nonce != "" { @@ -226,3 +308,85 @@ func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audien } return proofJwt, nil } + +// extractCredentialIdentifier extracts the first credential_identifier from the token response's +// authorization_details (v1.0 Section 6.2). Returns empty string if not present. +// Only considers entries with type "openid_credential" per RFC 9396. +// When multiple credential_identifiers are present, the first one is used. +func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse) string { + authzDetails, ok := tokenResponse.GetRaw("authorization_details").([]interface{}) + if !ok { + return "" + } + for _, item := range authzDetails { + entry, ok := item.(map[string]interface{}) + if !ok { + continue + } + if typ, _ := entry["type"].(string); typ != "openid_credential" { + continue + } + identifiers, ok := entry["credential_identifiers"].([]interface{}) + if !ok || len(identifiers) == 0 { + continue + } + identifier, ok := identifiers[0].(string) + if ok { + return identifier + } + } + return "" +} + +// resolveCredentialConfigIDByScope finds the credential_configuration_id that matches the given scope +// in the issuer's credential_configurations_supported (v1.0 Section 5.1.2). +// Per the spec, scope is a space-separated list where each value maps to a credential configuration. +// Only a single scope value is supported; multiple values are rejected (consistent with the +// single-entry restriction for authorization_details). +func resolveCredentialConfigIDByScope(scope string, metadata *oauth.OpenIDCredentialIssuerMetadata) (string, error) { + scopeValues := strings.Fields(scope) + if len(scopeValues) != 1 { + return "", fmt.Errorf("invalid scope: exactly one scope value is supported, got %d", len(scopeValues)) + } + scopeValue := scopeValues[0] + for configID, config := range metadata.CredentialConfigurationsSupported { + if s, _ := config["scope"].(string); s == scopeValue { + return configID, nil + } + } + return "", fmt.Errorf("scope %q not found in issuer's credential configurations", scopeValue) +} + +// validateAuthorizationDetails validates the authorization_details entries per v1.0 Section 5.1.1. +// It returns the credential_configuration_id and sanitized entries (only known keys, with locations injected). +// Only a single entry is supported; multiple entries are rejected. +func validateAuthorizationDetails(details []map[string]interface{}, metadata *oauth.OpenIDCredentialIssuerMetadata) (string, []map[string]interface{}, error) { + if len(details) != 1 { + return "", nil, errors.New("invalid authorization_details: exactly one entry is supported") + } + if len(metadata.CredentialConfigurationsSupported) == 0 { + return "", nil, errors.New("invalid authorization_details: issuer does not advertise any credential configurations") + } + entry := details[0] + typ, _ := entry["type"].(string) + if typ != "openid_credential" { + return "", nil, errors.New("invalid authorization_details: type must be \"openid_credential\"") + } + configID, ok := entry["credential_configuration_id"].(string) + if !ok || configID == "" { + return "", nil, errors.New("invalid authorization_details: credential_configuration_id is required") + } + if _, exists := metadata.CredentialConfigurationsSupported[configID]; !exists { + return "", nil, fmt.Errorf("invalid authorization_details: credential_configuration_id %q not found in issuer metadata", configID) + } + // Build sanitized entry with only known fields + sanitized := map[string]interface{}{ + "type": typ, + "credential_configuration_id": configID, + } + // Inject locations when authorization_servers is present (v1.0 Section 5.1.1) + if len(metadata.AuthorizationServers) > 0 { + sanitized["locations"] = []string{metadata.CredentialIssuer} + } + return configID, []map[string]interface{}{sanitized}, nil +} diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 7cdf016dfa..58c6e9e328 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -20,6 +20,9 @@ package iam import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "encoding/json" "errors" "net/url" @@ -28,6 +31,7 @@ import ( "github.com/nuts-foundation/nuts-node/core/to" + iamclient "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" @@ -44,28 +48,31 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { CredentialIssuer: "issuer", CredentialEndpoint: "endpoint", AuthorizationServers: []string{authServer}, - Display: nil, + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "NutsOrganizationCredential_ldp_vc": {"format": "ldp_vc"}, + }, + Display: nil, } authzMetadata := oauth.AuthorizationServerMetadata{ AuthorizationEndpoint: "https://auth.server/authorize", TokenEndpoint: "https://auth.server/token", ClientIdSchemesSupported: clientIdSchemesSupported, } - t.Run("ok", func(t *testing.T) { + t.Run("ok - locations injected when authorization_servers present", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) - response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, RequestOpenid4VCICredentialIssuanceRequestObject{ + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "format": "vc+sd-jwt"}}, + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), }, }) require.NoError(t, err) - require.NotNil(t, response) //RequestOid4vciCredentialIssuanceResponseObject + require.NotNil(t, response) redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) require.NoError(t, err) assert.Equal(t, "auth.server", redirectUri.Host) @@ -76,7 +83,269 @@ 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")) + assert.Equal(t, `[{"credential_configuration_id":"NutsOrganizationCredential_ldp_vc","locations":["issuer"],"type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) + }) + t.Run("ok - no locations when authorization_servers absent", func(t *testing.T) { + ctx := newTestClient(t) + metadataNoAS := oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: issuerClientID, + CredentialEndpoint: "endpoint", + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "NutsOrganizationCredential_ldp_vc": {"format": "ldp_vc"}, + }, + } + authzMetadataLocal := oauth.AuthorizationServerMetadata{ + AuthorizationEndpoint: "https://auth.server/authorize", + TokenEndpoint: "https://auth.server/token", + ClientIdSchemesSupported: clientIdSchemesSupported, + } + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadataNoAS, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), issuerClientID).Return(&authzMetadataLocal, nil) + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + require.NoError(t, err) + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + assert.Equal(t, `[{"credential_configuration_id":"NutsOrganizationCredential_ldp_vc","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) + }) + t.Run("ok - unknown keys in authorization_details are stripped", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc", "evil_key": "injected"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + require.NoError(t, err) + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + assert.Equal(t, `[{"credential_configuration_id":"NutsOrganizationCredential_ldp_vc","locations":["issuer"],"type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) + }) + t.Run("error - multiple authorization_details entries", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: &[]map[string]interface{}{ + {"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}, + {"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}, + }, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "invalid authorization_details: exactly one entry is supported") + }) + t.Run("error - authorization_details type is not openid_credential", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: &[]map[string]interface{}{{"type": "invalid_type", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "invalid authorization_details: type must be \"openid_credential\"") + }) + t.Run("error - authorization_details entry missing credential_configuration_id", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "invalid authorization_details: credential_configuration_id is required") + }) + t.Run("error - credential_configuration_id not in issuer metadata", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "unknown_config"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "invalid authorization_details: credential_configuration_id \"unknown_config\" not found in issuer metadata") + }) + t.Run("ok - uses PAR when endpoint advertised", func(t *testing.T) { + ctx := newTestClient(t) + parEndpoint := "https://auth.server/par" + authzMetadataWithPAR := oauth.AuthorizationServerMetadata{ + AuthorizationEndpoint: "https://auth.server/authorize", + TokenEndpoint: "https://auth.server/token", + ClientIdSchemesSupported: clientIdSchemesSupported, + PushedAuthorizationRequestEndpoint: parEndpoint, + } + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadataWithPAR, nil) + ctx.iamClient.EXPECT().PushedAuthorizationRequest(gomock.Any(), parEndpoint, gomock.Any()).DoAndReturn(func(_ context.Context, _ string, params url.Values) (*iamclient.PARResponse, error) { + assert.Equal(t, oauth.CodeResponseType, params.Get(oauth.ResponseTypeParam)) + assert.Equal(t, holderClientID, params.Get(oauth.ClientIDParam)) + assert.NotEmpty(t, params.Get(oauth.StateParam)) + assert.NotEmpty(t, params.Get(oauth.CodeChallengeParam)) + return &iamclient.PARResponse{RequestURI: "urn:ietf:params:oauth:request_uri:xyz", ExpiresIn: 60}, nil + }) + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + require.NoError(t, err) + require.NotNil(t, response) + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + assert.Equal(t, "auth.server", redirectUri.Host) + assert.Equal(t, "/authorize", redirectUri.Path) + assert.Equal(t, holderClientID, redirectUri.Query().Get("client_id")) + assert.Equal(t, "urn:ietf:params:oauth:request_uri:xyz", redirectUri.Query().Get("request_uri")) + assert.Empty(t, redirectUri.Query().Get("state"), "state should not be in redirect when using PAR") + assert.Empty(t, redirectUri.Query().Get("code_challenge"), "code_challenge should not be in redirect when using PAR") + }) + t.Run("error - PAR request fails", func(t *testing.T) { + ctx := newTestClient(t) + parEndpoint := "https://auth.server/par" + authzMetadataWithPAR := oauth.AuthorizationServerMetadata{ + AuthorizationEndpoint: "https://auth.server/authorize", + TokenEndpoint: "https://auth.server/token", + ClientIdSchemesSupported: clientIdSchemesSupported, + PushedAuthorizationRequestEndpoint: parEndpoint, + } + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadataWithPAR, nil) + ctx.iamClient.EXPECT().PushedAuthorizationRequest(gomock.Any(), parEndpoint, gomock.Any()).Return(nil, errors.New("PAR failed")) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "PAR request failed: PAR failed") + }) + t.Run("error - neither authorization_details nor scope provided", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "either authorization_details or scope is required") + }) + t.Run("ok - requests credential using scope", func(t *testing.T) { + ctx := newTestClient(t) + metadataWithScope := oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "issuer", + CredentialEndpoint: "endpoint", + AuthorizationServers: []string{authServer}, + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "NutsOrganizationCredential_ldp_vc": {"format": "ldp_vc", "scope": "nuts_org_credential"}, + }, + } + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadataWithScope, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + scope := "nuts_org_credential" + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + Scope: &scope, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + require.NoError(t, err) + require.NotNil(t, response) + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + assert.Equal(t, "nuts_org_credential", redirectUri.Query().Get("scope")) + assert.Empty(t, redirectUri.Query().Get("authorization_details"), "authorization_details should not be present when only scope is used") + }) + t.Run("error - scope not found in issuer credential configurations", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + scope := "unknown_scope" + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + Scope: &scope, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, `scope "unknown_scope" not found in issuer's credential configurations`) + }) + t.Run("ok - both authorization_details and scope provided", func(t *testing.T) { + ctx := newTestClient(t) + metadataWithScope := oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "issuer", + CredentialEndpoint: "endpoint", + AuthorizationServers: []string{authServer}, + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "NutsOrganizationCredential_ldp_vc": {"format": "ldp_vc"}, + "NutsOrganizationCredential_ldp_vc_v2": {"format": "ldp_vc", "scope": "nuts_org_credential_v2"}, + }, + } + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadataWithScope, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + scope := "nuts_org_credential_v2" + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Scope: &scope, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + require.NoError(t, err) + require.NotNil(t, response) + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + assert.NotEmpty(t, redirectUri.Query().Get("authorization_details"), "authorization_details should be present") + assert.Equal(t, "nuts_org_credential_v2", redirectUri.Query().Get("scope"), "scope should be present") }) t.Run("openid4vciMetadata", func(t *testing.T) { t.Run("ok - fallback to issuerDID on empty AuthorizationServers", func(t *testing.T) { @@ -87,25 +356,25 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { AuthorizationServers: []string{}, // empty Display: nil, } - ctx.iamClient.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)) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), issuerClientID).Return(nil, assert.AnError) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.ErrorIs(t, err, assert.AnError) }) 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.iamClient.EXPECT().AuthorizationServerMetadata(nil, issuerClientID).Return(nil, assert.AnError) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(nil, assert.AnError) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), issuerClientID).Return(nil, assert.AnError) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(nil, assert.AnError) - _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.ErrorIs(t, err, assert.AnError) }) 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) - _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(nil, assert.AnError) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.ErrorIs(t, err, assert.AnError) }) }) @@ -114,21 +383,21 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { req.Body.Issuer = "" ctx := newTestClient(t) - _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), req) assert.EqualError(t, err, "issuer is empty") }) 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.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) invalidAuthzMetadata := oauth.AuthorizationServerMetadata{ AuthorizationEndpoint: ":", TokenEndpoint: "https://auth.server/token", ClientIdSchemesSupported: []string{"did"}, } - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&invalidAuthzMetadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&invalidAuthzMetadata, nil) - _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "failed to parse the authorization_endpoint: parse \":\": missing protocol scheme") }) @@ -136,27 +405,27 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { ctx := newTestClient(t) metadata := metadata metadata.CredentialEndpoint = "" - ctx.iamClient.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)) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "no credential_endpoint found") }) t.Run("error - missing authorization_endpoint", func(t *testing.T) { ctx := newTestClient(t) authzMetadata := authzMetadata authzMetadata.AuthorizationEndpoint = "" - ctx.iamClient.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)) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "no authorization_endpoint found") }) t.Run("error - missing token_endpoint", func(t *testing.T) { ctx := newTestClient(t) authzMetadata := authzMetadata authzMetadata.TokenEndpoint = "" - ctx.iamClient.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)) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "no token_endpoint found") }) } @@ -165,9 +434,10 @@ func requestCredentials(subjectID string, issuer string, redirectURI string) Req return RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: subjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - Issuer: issuer, - RedirectUri: redirectURI, - WalletDid: holderDID.String(), + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Issuer: issuer, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), }, } } @@ -215,8 +485,8 @@ func TestWrapper_handleOpenID4VCICallback(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.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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) @@ -229,11 +499,11 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Equal(t, expectedClaims, claims) return "signed-proof", nil }) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) - ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) - callback, err := ctx.client.Callback(nil, CallbackRequestObject{ + callback, err := ctx.client.Callback(context.Background(), CallbackRequestObject{ SubjectID: holderSubjectID, Params: CallbackParams{ Code: to.Ptr(code), @@ -248,18 +518,18 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { }) 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.iamClient.EXPECT().AccessToken(gomock.Any(), 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.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) - ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionWithoutNonce) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &sessionWithoutNonce) require.NoError(t, err) assert.NotNil(t, callback) @@ -269,20 +539,20 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { freshNonce := "fresh-nonce" invalidNonceErr := openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: 400} - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof-1").Return(nil, invalidNonceErr) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof-1").Return(nil, invalidNonceErr) // retry with fresh nonce - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(freshNonce, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(freshNonce, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-2", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof-2").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof-2").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) - ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) require.NoError(t, err) assert.NotNil(t, callback) @@ -291,17 +561,17 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx := newTestClient(t) invalidNonceErr := openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: 400} - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof-1").Return(nil, invalidNonceErr) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof-1").Return(nil, invalidNonceErr) // retry also fails - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("fresh-nonce", nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return("fresh-nonce", nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-2", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof-2").Return(nil, errors.New("still failing")) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof-2").Return(nil, errors.New("still failing")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.ErrorContains(t, err, "error while fetching the credential from endpoint") @@ -310,34 +580,34 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx := newTestClient(t) invalidNonceErr := openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: 400} - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(nil, invalidNonceErr) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(nil, invalidNonceErr) // retry nonce fetch fails - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("", errors.New("nonce endpoint down")) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return("", errors.New("nonce endpoint down")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.ErrorContains(t, err, "error fetching nonce for retry") }) 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.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("", errors.New("nonce endpoint unavailable")) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return("", errors.New("nonce endpoint unavailable")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), 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")) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(nil, errors.New("FAIL")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Error(t, err) assert.Nil(t, callback) @@ -345,65 +615,65 @@ 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.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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, credentialConfigID, "signed-proof").Return(nil, errors.New("FAIL")) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(nil, errors.New("FAIL")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.EqualError(t, err, "server_error - error while fetching the credential from endpoint https://auth.server/credz, error: FAIL") }) 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.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&openid4vci.CredentialResponse{ Credentials: []openid4vci.CredentialResponseEntry{{Credential: json.RawMessage(`"super invalid"`)}}, }, nil) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) 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.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "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) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.EqualError(t, err, "server_error - error while verifying the credential from issuer: did:web:example.com:iam:issuer, error: FAIL") }) 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.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("", nil, resolver.ErrKeyNotFound) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.ErrorContains(t, err, "failed to resolve key for did (did:web:example.com:iam:holder): "+resolver.ErrKeyNotFound.Error()) }) 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.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.ErrorContains(t, err, "failed to sign the JWT with kid (kid): signature failed") @@ -413,24 +683,238 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { sessionNilDID := session sessionNilDID.OwnDID = nil - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionNilDID) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &sessionNilDID) assert.Nil(t, callback) assert.ErrorContains(t, err, "missing wallet DID in session") }) + t.Run("error - signing algorithm not supported by issuer", func(t *testing.T) { + ctx := newTestClient(t) + p256Key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + sessionAlgMismatch := session + sessionAlgMismatch.ProofSigningAlgValuesSupported = []string{"ES384"} + + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", &p256Key.PublicKey, nil) + + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &sessionAlgMismatch) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "signing algorithm ES256 is not supported by issuer (supported: ES384)") + }) + t.Run("ok - algorithm validation skipped when proof_signing_alg_values_supported absent", func(t *testing.T) { + ctx := newTestClient(t) + sessionNoAlg := session + sessionNoAlg.ProofSigningAlgValuesSupported = nil + + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &sessionNoAlg) + + require.NoError(t, err) + assert.NotNil(t, callback) + }) + t.Run("error - deferred issuance not supported", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&openid4vci.CredentialResponse{ + TransactionID: "txn-456", + }, nil) + + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "deferred credential issuance is not supported") + }) 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.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&openid4vci.CredentialResponse{ Credentials: []openid4vci.CredentialResponseEntry{}, }, nil) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.ErrorContains(t, err, "credential response does not contain any credentials") }) + t.Run("ok - uses credential_identifier from token response when present", func(t *testing.T) { + ctx := newTestClient(t) + tokenResponseWithIdentifier := &oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"} + tokenResponseWithIdentifier.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + "credential_configuration_id": credentialConfigID, + "credential_identifiers": []interface{}{"cred-id-1", "cred-id-2"}, + }, + }) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponseWithIdentifier, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, "", "cred-id-1", "signed-proof").Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) + + require.NoError(t, err) + assert.NotNil(t, callback) + }) + t.Run("ok - falls back to credential_configuration_id when no credential_identifiers", func(t *testing.T) { + ctx := newTestClient(t) + tokenResponseNoIdentifiers := &oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"} + tokenResponseNoIdentifiers.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + "credential_configuration_id": credentialConfigID, + }, + }) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponseNoIdentifiers, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), 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.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) + + require.NoError(t, err) + assert.NotNil(t, callback) + }) +} + +func TestExtractCredentialIdentifier(t *testing.T) { + t.Run("returns first identifier from openid_credential entry", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + "credential_identifiers": []interface{}{"id-1", "id-2"}, + }, + }) + + assert.Equal(t, "id-1", extractCredentialIdentifier(tokenResponse)) + }) + t.Run("skips non-openid_credential entries", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "other_type", + "credential_identifiers": []interface{}{"wrong"}, + }, + map[string]interface{}{ + "type": "openid_credential", + "credential_identifiers": []interface{}{"correct"}, + }, + }) + + assert.Equal(t, "correct", extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when authorization_details missing", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when authorization_details is not an array", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", "not-an-array") + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when credential_identifiers missing", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + }, + }) + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when credential_identifiers is empty", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + "credential_identifiers": []interface{}{}, + }, + }) + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when identifier is not a string", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + "credential_identifiers": []interface{}{42}, + }, + }) + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when entry is not a map", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{"not-a-map"}) + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) +} + +func TestResolveCredentialConfigIDByScope(t *testing.T) { + t.Run("ok - finds matching config", func(t *testing.T) { + metadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "Degree": {"format": "jwt_vc_json", "scope": "UniversityDegree"}, + "Other": {"format": "ldp_vc"}, + }, + } + configID, err := resolveCredentialConfigIDByScope("UniversityDegree", metadata) + require.NoError(t, err) + assert.Equal(t, "Degree", configID) + }) + t.Run("error - scope not found", func(t *testing.T) { + metadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "Other": {"format": "ldp_vc"}, + }, + } + _, err := resolveCredentialConfigIDByScope("unknown", metadata) + assert.EqualError(t, err, `scope "unknown" not found in issuer's credential configurations`) + }) + t.Run("error - no configurations at all", func(t *testing.T) { + metadata := &oauth.OpenIDCredentialIssuerMetadata{} + _, err := resolveCredentialConfigIDByScope("test", metadata) + assert.EqualError(t, err, `scope "test" not found in issuer's credential configurations`) + }) + t.Run("error - multiple scope values", func(t *testing.T) { + metadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "Degree": {"format": "jwt_vc_json", "scope": "UniversityDegree"}, + "License": {"format": "jwt_vc_json", "scope": "DriverLicense"}, + }, + } + _, err := resolveCredentialConfigIDByScope("UniversityDegree DriverLicense", metadata) + assert.EqualError(t, err, "invalid scope: exactly one scope value is supported, got 2") + }) + t.Run("error - empty scope", func(t *testing.T) { + metadata := &oauth.OpenIDCredentialIssuerMetadata{} + _, err := resolveCredentialConfigIDByScope("", metadata) + assert.EqualError(t, err, "invalid scope: exactly one scope value is supported, got 0") + }) } diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index 09ef6fcd9e..1fcebbdcc0 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -59,6 +59,8 @@ type OAuthSession struct { 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"` + // ProofSigningAlgValuesSupported: algorithms the issuer accepts for proof JWTs (v1.0 Appendix F.1) + ProofSigningAlgValuesSupported []string `json:"proof_signing_alg_values_supported,omitempty"` } // oauthClientFlow is used by a client to identify the flow a particular callback is part of diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index db9694e4f2..dd351ab485 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -21,6 +21,7 @@ package iam import ( "bytes" "context" + stdcrypto "crypto" "encoding/json" "errors" "fmt" @@ -282,7 +283,56 @@ func (hb HTTPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIs if err != nil { return nil, err } - return &metadata, err + if metadata.SignedMetadata != "" { + if err = hb.verifySignedMetadata(ctx, &metadata); err != nil { + return nil, fmt.Errorf("signed_metadata verification failed: %w", err) + } + } + return &metadata, nil +} + +// verifySignedMetadata verifies the signed_metadata JWT against the issuer's key (v1.0 Section 12.2.3). +// It validates the JWT signature, typ header, required claims (sub, iat), and compares +// key metadata claims (credential_issuer, credential_endpoint) against the unsigned metadata. +func (hb HTTPClient) verifySignedMetadata(ctx context.Context, metadata *oauth.OpenIDCredentialIssuerMetadata) error { + // Verify typ header to prevent JWT type confusion attacks + typ, err := crypto.JWTTyp(metadata.SignedMetadata) + if err != nil { + return fmt.Errorf("invalid JWT: %w", err) + } + if typ != "openidvci-issuer-metadata+jwt" { + return fmt.Errorf("typ header must be openidvci-issuer-metadata+jwt, got %q", typ) + } + // Parse, verify signature, and validate standard claims using shared infrastructure + token, err := crypto.ParseJWT(metadata.SignedMetadata, func(kid string) (stdcrypto.PublicKey, error) { + return hb.keyResolver.ResolveKeyByID(kid, nil, resolver.AssertionMethod) + }, jwt.WithValidate(true), jwt.WithAcceptableSkew(5*time.Second)) + if err != nil { + return fmt.Errorf("invalid JWT: %w", err) + } + // sub is REQUIRED, must match credential_issuer. iss is OPTIONAL per spec. + if token.Subject() != metadata.CredentialIssuer { + return fmt.Errorf("sub %q does not match credential_issuer %q", token.Subject(), metadata.CredentialIssuer) + } + if token.IssuedAt().IsZero() { + return fmt.Errorf("iat claim is required") + } + // Compare metadata claims from JWT payload against unsigned metadata + claims, err := token.AsMap(ctx) + if err != nil { + return fmt.Errorf("failed to extract claims: %w", err) + } + if ci, _ := claims["credential_issuer"].(string); ci != metadata.CredentialIssuer { + return fmt.Errorf("credential_issuer claim %q does not match metadata %q", ci, metadata.CredentialIssuer) + } + ce, _ := claims["credential_endpoint"].(string) + if ce == "" { + return fmt.Errorf("credential_endpoint claim is required in signed metadata") + } + if ce != metadata.CredentialEndpoint { + return fmt.Errorf("credential_endpoint claim %q does not match metadata %q", ce, metadata.CredentialEndpoint) + } + return nil } func (hb HTTPClient) OpenIDConfiguration(ctx context.Context, issuerURL string) (*oauth.OpenIDConfiguration, error) { @@ -338,7 +388,39 @@ func (hb HTTPClient) KeyProvider() jws.KeyProviderFunc { } } -func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJwt string) (*openid4vci.CredentialResponse, error) { +func (hb HTTPClient) PushedAuthorizationRequest(ctx context.Context, parEndpoint string, params url.Values) (*PARResponse, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parEndpoint, strings.NewReader(params.Encode())) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := hb.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("PAR request failed: %w", err) + } + defer response.Body.Close() + data, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("unable to read PAR response: %w", err) + } + if response.StatusCode != http.StatusCreated { + bodySnippet := string(data) + if len(bodySnippet) > core.HttpResponseBodyLogClipAt { + bodySnippet = bodySnippet[:core.HttpResponseBodyLogClipAt] + "...(clipped)" + } + return nil, fmt.Errorf("PAR endpoint returned HTTP %d (expected: 201): %s", response.StatusCode, bodySnippet) + } + var parResponse PARResponse + if err = json.Unmarshal(data, &parResponse); err != nil { + return nil, fmt.Errorf("unable to unmarshal PAR response: %w", err) + } + if !strings.HasPrefix(parResponse.RequestURI, "urn:ietf:params:oauth:request_uri:") { + return nil, fmt.Errorf("PAR response contains invalid request_uri: %q", parResponse.RequestURI) + } + return &parResponse, nil +} + +func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, credentialIdentifier string, proofJwt string) (*openid4vci.CredentialResponse, error) { credentialEndpointURL, err := url.Parse(credentialEndpoint) if err != nil { return nil, err @@ -346,6 +428,7 @@ func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoi credentialRequest := openid4vci.CredentialRequest{ CredentialConfigurationID: credentialConfigID, + CredentialIdentifier: credentialIdentifier, Proofs: &openid4vci.CredentialRequestProofs{ Jwt: []string{proofJwt}, }, diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 5d55262aa7..1e757e9537 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -20,6 +20,7 @@ package iam import ( "context" + "net/url" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" @@ -27,6 +28,12 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/pe" ) +// PARResponse holds the response from a Pushed Authorization Request (RFC 9126). +type PARResponse struct { + RequestURI string `json:"request_uri"` + ExpiresIn int `json:"expires_in"` +} + // Client defines OpenID4VP client methods using the IAM OpenAPI Spec. type Client interface { // AccessToken requests an access token at the oauth2 token endpoint. @@ -57,7 +64,10 @@ type Client interface { // RequestNonce requests a fresh c_nonce from the issuer's Nonce Endpoint (v1.0 Section 7). RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) // VerifiableCredentials requests Verifiable Credentials from the issuer at the given endpoint. - VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJWT string) (*openid4vci.CredentialResponse, error) + // Either credentialConfigID or credentialIdentifier must be non-empty (mutually exclusive per v1.0 Section 8.2). + VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, credentialIdentifier string, proofJWT string) (*openid4vci.CredentialResponse, error) + // PushedAuthorizationRequest sends a Pushed Authorization Request (RFC 9126) to the given endpoint. + PushedAuthorizationRequest(ctx context.Context, parEndpoint string, params url.Values) (*PARResponse, 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 d8a3e4192e..6d6111ace1 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -11,6 +11,7 @@ package iam import ( context "context" + url "net/url" reflect "reflect" vc "github.com/nuts-foundation/go-did/vc" @@ -163,6 +164,21 @@ func (mr *MockClientMockRecorder) PresentationDefinition(ctx, endpoint any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinition", reflect.TypeOf((*MockClient)(nil).PresentationDefinition), ctx, endpoint) } +// PushedAuthorizationRequest mocks base method. +func (m *MockClient) PushedAuthorizationRequest(ctx context.Context, parEndpoint string, params url.Values) (*PARResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PushedAuthorizationRequest", ctx, parEndpoint, params) + ret0, _ := ret[0].(*PARResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PushedAuthorizationRequest indicates an expected call of PushedAuthorizationRequest. +func (mr *MockClientMockRecorder) PushedAuthorizationRequest(ctx, parEndpoint, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PushedAuthorizationRequest", reflect.TypeOf((*MockClient)(nil).PushedAuthorizationRequest), ctx, parEndpoint, params) +} + // RequestNonce mocks base method. func (m *MockClient) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { m.ctrl.T.Helper() @@ -224,16 +240,16 @@ func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjec } // VerifiableCredentials mocks base method. -func (m *MockClient) VerifiableCredentials(ctx context.Context, credentialEndpoint, accessToken, credentialConfigID, proofJWT string) (*openid4vci.CredentialResponse, error) { +func (m *MockClient) VerifiableCredentials(ctx context.Context, credentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, proofJWT string) (*openid4vci.CredentialResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "VerifiableCredentials", ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) + ret := m.ctrl.Call(m, "VerifiableCredentials", ctx, credentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, proofJWT) ret0, _ := ret[0].(*openid4vci.CredentialResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // VerifiableCredentials indicates an expected call of VerifiableCredentials. -func (mr *MockClientMockRecorder) VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT any) *gomock.Call { +func (mr *MockClientMockRecorder) VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, 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, credentialConfigID, proofJWT) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifiableCredentials", reflect.TypeOf((*MockClient)(nil).VerifiableCredentials), ctx, credentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, proofJWT) } diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index a3d56cb2dc..747b8be668 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -360,9 +360,17 @@ func (c *OpenID4VPClient) RequestNonce(ctx context.Context, nonceEndpoint string return c.httpClient.RequestNonce(ctx, nonceEndpoint) } -func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJWT string) (*openid4vci.CredentialResponse, error) { +func (c *OpenID4VPClient) PushedAuthorizationRequest(ctx context.Context, parEndpoint string, params url.Values) (*PARResponse, error) { + parsedURL, err := core.ParsePublicURL(parEndpoint, c.strictMode) + if err != nil { + return nil, fmt.Errorf("invalid PAR endpoint: %w", err) + } + return c.httpClient.PushedAuthorizationRequest(ctx, parsedURL.String(), params) +} + +func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, credentialIdentifier string, proofJWT string) (*openid4vci.CredentialResponse, error) { iamClient := c.httpClient - rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) + rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, proofJWT) if err != nil { return nil, err } diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 7422661bef..9f58a9746f 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -20,10 +20,14 @@ package iam import ( "context" + "crypto/ecdsa" "crypto/tls" "encoding/json" "errors" "fmt" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/nuts-node/http/client" test2 "github.com/nuts-foundation/nuts-node/test" "github.com/nuts-foundation/nuts-node/vcr/credential" @@ -40,6 +44,7 @@ import ( "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/crypto" + cryptoTest "github.com/nuts-foundation/nuts-node/crypto/test" http2 "github.com/nuts-foundation/nuts-node/test/http" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" @@ -486,8 +491,9 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon wallet: wallet, subjectManager: subjectManager, httpClient: HTTPClient{ - strictMode: false, - httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig), + strictMode: false, + httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig), + keyResolver: keyResolver, }, jwtSigner: jwtSigner, keyResolver: keyResolver, @@ -527,6 +533,7 @@ type clientServerTestContext struct { nonce func(writer http.ResponseWriter) credentials func(writer http.ResponseWriter) requestObjectJWT func(writer http.ResponseWriter) + par func(writer http.ResponseWriter, request *http.Request) } func createClientServerTestContext(t *testing.T) *clientServerTestContext { @@ -650,6 +657,11 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { ctx.requestObjectJWT(writer) return } + case "/par": + if ctx.par != nil { + ctx.par(writer, request) + return + } } writer.WriteHeader(http.StatusNotFound) } @@ -691,6 +703,264 @@ func TestIAMClient_OpenIdCredentialIssuerMetadata(t *testing.T) { assert.Nil(t, response) assert.EqualError(t, err, "failed to retrieve Openid credential issuer metadata: server returned HTTP 404 (expected: 200)") }) + t.Run("ok - signed_metadata is verified", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWT(t, ecKey, kid, map[string]interface{}{ + "credential_issuer": "https://issuer.example.com", + "credential_endpoint": "https://issuer.example.com/credential", + }) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.openIDCredentialIssuerMetadata = issuerMetadata + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, "https://issuer.example.com", metadata.CredentialIssuer) + }) + t.Run("error - signed_metadata JWT signature invalid", func(t *testing.T) { + ctx := createClientServerTestContext(t) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: "invalid.jwt.token", + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "signed_metadata verification failed") + }) + t.Run("error - signed_metadata sub mismatch", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWT(t, ecKey, kid, map[string]interface{}{ + "credential_issuer": "https://other-issuer.example.com", + "credential_endpoint": "https://issuer.example.com/credential", + }) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "sub") + assert.ErrorContains(t, err, "does not match credential_issuer") + }) + t.Run("error - signed_metadata credential_endpoint mismatch", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWT(t, ecKey, kid, map[string]interface{}{ + "credential_issuer": "https://issuer.example.com", + "credential_endpoint": "https://evil.example.com/credential", + }) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "credential_endpoint claim") + assert.ErrorContains(t, err, "does not match metadata") + }) + t.Run("error - signed_metadata wrong typ header", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWTCustom(t, ecKey, kid, "jwt", map[string]interface{}{ + "iss": "https://issuer.example.com", + "sub": "https://issuer.example.com", + "credential_issuer": "https://issuer.example.com", + "credential_endpoint": "https://issuer.example.com/credential", + }, true, true) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "typ header must be openidvci-issuer-metadata+jwt") + }) + t.Run("error - signed_metadata missing sub", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWTCustom(t, ecKey, kid, "openidvci-issuer-metadata+jwt", map[string]interface{}{ + "iss": "https://issuer.example.com", + "credential_issuer": "https://issuer.example.com", + "credential_endpoint": "https://issuer.example.com/credential", + }, true, true) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "sub") + assert.ErrorContains(t, err, "does not match credential_issuer") + }) + t.Run("error - signed_metadata missing iat", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWTCustom(t, ecKey, kid, "openidvci-issuer-metadata+jwt", map[string]interface{}{ + "sub": "https://issuer.example.com", + "credential_issuer": "https://issuer.example.com", + "credential_endpoint": "https://issuer.example.com/credential", + }, false, false) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "iat claim is required") + }) + t.Run("error - signed_metadata missing credential_endpoint", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWT(t, ecKey, kid, map[string]interface{}{ + "credential_issuer": "https://issuer.example.com", + }) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "credential_endpoint claim is required in signed metadata") + }) +} + +func createSignedMetadataJWT(t *testing.T, key *ecdsa.PrivateKey, kid string, claims map[string]interface{}) string { + t.Helper() + token := jwt.New() + for k, v := range claims { + require.NoError(t, token.Set(k, v)) + } + if iss, ok := claims["credential_issuer"].(string); ok { + require.NoError(t, token.Set(jwt.IssuerKey, iss)) + require.NoError(t, token.Set(jwt.SubjectKey, iss)) + } + require.NoError(t, token.Set(jwt.IssuedAtKey, time.Now().Unix())) + require.NoError(t, token.Set(jwt.ExpirationKey, time.Now().Add(time.Hour).Unix())) + hdrs := jws.NewHeaders() + require.NoError(t, hdrs.Set(jws.KeyIDKey, kid)) + require.NoError(t, hdrs.Set(jws.TypeKey, "openidvci-issuer-metadata+jwt")) + signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, key, jws.WithProtectedHeaders(hdrs))) + require.NoError(t, err) + return string(signed) +} + +// createSignedMetadataJWTCustom allows overriding specific JWT fields for negative tests. +func createSignedMetadataJWTCustom(t *testing.T, key *ecdsa.PrivateKey, kid string, typ string, claims map[string]interface{}, setIat bool, setExp bool) string { + t.Helper() + token := jwt.New() + for k, v := range claims { + require.NoError(t, token.Set(k, v)) + } + if setIat { + require.NoError(t, token.Set(jwt.IssuedAtKey, time.Now().Unix())) + } + if setExp { + require.NoError(t, token.Set(jwt.ExpirationKey, time.Now().Add(time.Hour).Unix())) + } + hdrs := jws.NewHeaders() + require.NoError(t, hdrs.Set(jws.KeyIDKey, kid)) + if typ != "" { + require.NoError(t, hdrs.Set(jws.TypeKey, typ)) + } + signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, key, jws.WithProtectedHeaders(hdrs))) + require.NoError(t, err) + return string(signed) } func TestIAMClient_RequestNonce(t *testing.T) { @@ -715,6 +985,56 @@ func TestIAMClient_RequestNonce(t *testing.T) { }) } +func TestIAMClient_PushedAuthorizationRequest(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.par = func(writer http.ResponseWriter, request *http.Request) { + assert.Equal(t, http.MethodPost, request.Method) + assert.Equal(t, "application/x-www-form-urlencoded", request.Header.Get("Content-Type")) + assert.NoError(t, request.ParseForm()) + assert.Equal(t, "value1", request.PostFormValue("key1")) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusCreated) + _, _ = writer.Write([]byte(`{"request_uri":"urn:ietf:params:oauth:request_uri:abc123","expires_in":60}`)) + } + + params := url.Values{"key1": {"value1"}} + response, err := ctx.client.PushedAuthorizationRequest(context.Background(), ctx.tlsServer.URL+"/par", params) + + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, "urn:ietf:params:oauth:request_uri:abc123", response.RequestURI) + assert.Equal(t, 60, response.ExpiresIn) + }) + t.Run("error - server returns error status", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.par = func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusBadRequest) + _, _ = writer.Write([]byte(`{"error":"invalid_request"}`)) + } + + response, err := ctx.client.PushedAuthorizationRequest(context.Background(), ctx.tlsServer.URL+"/par", url.Values{}) + + assert.Error(t, err) + assert.Nil(t, response) + assert.ErrorContains(t, err, "PAR endpoint returned HTTP 400 (expected: 201)") + }) + t.Run("error - response has invalid request_uri", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.par = func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusCreated) + _, _ = writer.Write([]byte(`{"request_uri":"https://evil.com/steal","expires_in":60}`)) + } + + response, err := ctx.client.PushedAuthorizationRequest(context.Background(), ctx.tlsServer.URL+"/par", url.Values{}) + + assert.Error(t, err) + assert.Nil(t, response) + assert.ErrorContains(t, err, "PAR response contains invalid request_uri") + }) +} + func TestIAMClient_VerifiableCredentials(t *testing.T) { accessToken := "code" proofJWT := "top secret" @@ -723,7 +1043,7 @@ func TestIAMClient_VerifiableCredentials(t *testing.T) { t.Run("ok", func(t *testing.T) { ctx := createClientServerTestContext(t) - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, "", proofJWT) require.NoError(t, err) require.NotNil(t, response) @@ -738,7 +1058,7 @@ func TestIAMClient_VerifiableCredentials(t *testing.T) { _, _ = writer.Write([]byte(`{"credentials": [{"credential": {"@context": ["https://www.w3.org/2018/credentials/v1"], "type": ["VerifiableCredential"]}}]}`)) } - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, "", proofJWT) require.NoError(t, err) require.NotNil(t, response) @@ -749,7 +1069,7 @@ func TestIAMClient_VerifiableCredentials(t *testing.T) { ctx := createClientServerTestContext(t) ctx.credentials = nil - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, "", proofJWT) assert.Error(t, err) assert.Nil(t, response) @@ -762,7 +1082,7 @@ func TestIAMClient_VerifiableCredentials(t *testing.T) { _, _ = writer.Write([]byte(`{"error": "invalid_nonce"}`)) } - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, "", proofJWT) assert.Nil(t, response) require.Error(t, err) @@ -778,7 +1098,7 @@ func TestIAMClient_VerifiableCredentials(t *testing.T) { _, _ = writer.Write([]byte(`{"credentials": fail}`)) } - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, "", proofJWT) assert.Error(t, err) assert.Nil(t, response) diff --git a/auth/oauth/types.go b/auth/oauth/types.go index 4224c072ae..b92cf9630a 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -101,6 +101,15 @@ func (t *TokenResponse) With(key string, value interface{}) *TokenResponse { return t } +// GetRaw returns the raw value of an additional parameter. +// Returns nil if the key does not exist. +func (t TokenResponse) GetRaw(key string) interface{} { + if t.additionalParams == nil { + return nil + } + return t.additionalParams[key] +} + // Get returns the value of the additional parameter with the given key as a string. // If the key does not exist or the value is not a string, it returns an empty string. // It should not be used to get any of the base parameters (access_token, expires_in, token_type, scope). @@ -308,6 +317,9 @@ type AuthorizationServerMetadata struct { /* ******** JWT-Secured Authorization Request RFC9101 & OpenID Connect Core v1.0: ยง6. Passing Request Parameters as JWTs ******** */ + // PushedAuthorizationRequestEndpoint is the URL of the pushed authorization request endpoint (RFC 9126). + PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint,omitempty"` + // RequireSignedRequestObject specifies if the authorization server requires the use of signed request objects. RequireSignedRequestObject bool `json:"require_signed_request_object,omitempty"` @@ -405,11 +417,14 @@ type Redirect struct { // OpenIDCredentialIssuerMetadata represents the metadata of an OpenID credential issuer 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"` + CredentialIssuer string `json:"credential_issuer"` + CredentialEndpoint string `json:"credential_endpoint"` + NonceEndpoint string `json:"nonce_endpoint,omitempty"` + AuthorizationServers []string `json:"authorization_servers,omitempty"` + CredentialConfigurationsSupported map[string]map[string]interface{} `json:"credential_configurations_supported,omitempty"` + Display []map[string]string `json:"display,omitempty"` + // SignedMetadata is a JWT containing signed issuer metadata for trust verification (v1.0 Section 12.2.3). + SignedMetadata string `json:"signed_metadata,omitempty"` } // OpenIDConfiguration represents the OpenID configuration diff --git a/auth/oauth/types_test.go b/auth/oauth/types_test.go index e0932a7991..dddf1e69d7 100644 --- a/auth/oauth/types_test.go +++ b/auth/oauth/types_test.go @@ -20,10 +20,11 @@ package oauth import ( "encoding/json" + "testing" + "github.com/nuts-foundation/nuts-node/core/to" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "testing" ) func TestIssuerIdToWellKnown(t *testing.T) { @@ -93,6 +94,26 @@ func TestTokenResponse_Get(t *testing.T) { }) } +func TestTokenResponse_GetRaw(t *testing.T) { + t.Run("nil map", func(t *testing.T) { + var tr TokenResponse + assert.Nil(t, tr.GetRaw("key")) + }) + t.Run("returns stored value", func(t *testing.T) { + tr := TokenResponse{} + expected := []interface{}{"a", "b"} + tr.With("details", expected) + + assert.Equal(t, expected, tr.GetRaw("details")) + }) + t.Run("returns nil for missing key", func(t *testing.T) { + tr := TokenResponse{} + tr.With("other", "value") + + assert.Nil(t, tr.GetRaw("missing")) + }) +} + func TestAuthorizationServerMetadata_SupportsClientIDScheme(t *testing.T) { m := AuthorizationServerMetadata{ ClientIdSchemesSupported: []string{"did"}, diff --git a/crypto/jwx.go b/crypto/jwx.go index e9bd81fcd1..3670a4a000 100644 --- a/crypto/jwx.go +++ b/crypto/jwx.go @@ -165,6 +165,18 @@ func JWTKidAlg(tokenString string) (string, jwa.SignatureAlgorithm, error) { return hdrs.KeyID(), hdrs.Algorithm(), nil } +// JWTTyp parses a JWT without validation and returns the 'typ' header. +func JWTTyp(tokenString string) (string, error) { + j, err := jws.ParseString(tokenString) + if err != nil { + return "", err + } + if len(j.Signatures()) != 1 { + return "", errors.New("incorrect number of signatures in JWT") + } + return j.Signatures()[0].ProtectedHeaders().Type(), nil +} + // PublicKeyFunc defines a function that resolves a public key based on a kid type PublicKeyFunc func(kid string) (crypto.PublicKey, error) diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index c032a1ff64..78e6d9436c 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -129,7 +129,6 @@ paths: schema: required: - issuer - - authorization_details - redirect_uri - wallet_did properties: @@ -157,6 +156,13 @@ paths: "credential_configuration_id": "UniversityDegreeCredential" } ] + scope: + type: string + description: | + OAuth2 scope value mapped to a credential configuration in the issuer's metadata (v1.0 Section 5.1.2). + The issuer's credential_configurations_supported must contain an entry with a matching 'scope' field. + Can be used together with authorization_details; the issuer interprets them individually. + example: UniversityDegree redirect_uri: type: string description: | diff --git a/vcr/holder/openid.go b/vcr/holder/openid.go index 3ae8b5a327..c4b23bf6c2 100644 --- a/vcr/holder/openid.go +++ b/vcr/holder/openid.go @@ -250,10 +250,27 @@ func (h *openidHandler) resolveCredentialConfiguration(metadata openid4vci.Crede } func (h *openidHandler) retrieveCredential(ctx context.Context, issuerClient openid4vci.IssuerAPIClient, credentialConfigID string, tokenResponse *oauth.TokenResponse) (*vc.VerifiableCredential, error) { - keyID, _, err := h.resolver.ResolveKey(h.did, nil, resolver.NutsSigningKeyType) + keyID, pubKey, err := h.resolver.ResolveKey(h.did, nil, resolver.NutsSigningKeyType) if err != nil { return nil, err } + if credentialConfigID != "" { + if config, exists := issuerClient.Metadata().CredentialConfigurationsSupported[credentialConfigID]; exists { + supportedAlgs, algErr := openid4vci.ProofSigningAlgValues(config) + if algErr != nil { + return nil, algErr + } + if len(supportedAlgs) > 0 { + alg, algErr := crypto.SignatureAlgorithm(pubKey) + if algErr != nil { + return nil, fmt.Errorf("failed to determine signing algorithm: %w", algErr) + } + if err = openid4vci.ValidateProofSigningAlg(alg.String(), supportedAlgs); err != nil { + return nil, err + } + } + } + } const maxAttempts = 2 for attempt := range maxAttempts { diff --git a/vcr/holder/openid_test.go b/vcr/holder/openid_test.go index c3e749bd99..d14256ee0a 100644 --- a/vcr/holder/openid_test.go +++ b/vcr/holder/openid_test.go @@ -20,6 +20,9 @@ package holder import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "errors" "net/http" "testing" @@ -568,6 +571,117 @@ func Test_wallet_RetrieveCredentialWithNonceEndpoint(t *testing.T) { }) } +func Test_wallet_ProofSigningAlgValidation(t *testing.T) { + credentialOffer := openid4vci.CredentialOffer{ + CredentialIssuer: issuerDID.String(), + CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, + Grants: &openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: "code", + }, + }, + } + t.Run("error - signing algorithm not supported by issuer", func(t *testing.T) { + ctrl := gomock.NewController(t) + metadataAlgRestricted := openid4vci.CredentialIssuerMetadata{ + CredentialIssuer: issuerDID.String(), + CredentialEndpoint: "credential-endpoint", + NonceEndpoint: "https://issuer.example/nonce", + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "ExampleCredential_ldp_vc": { + "format": "ldp_vc", + "proof_types_supported": map[string]interface{}{ + "jwt": map[string]interface{}{ + "proof_signing_alg_values_supported": []interface{}{"ES384"}, + }, + }, + "credential_definition": map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1", "https://example.com/credentials/v1"}, + "type": []interface{}{"VerifiableCredential", "ExampleCredential"}, + }, + }, + }, + } + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadataAlgRestricted).AnyTimes() + tokenResponse := &oauth.TokenResponse{AccessToken: "access-token", TokenType: "bearer"} + issuerAPIClient.EXPECT().RequestAccessToken("urn:ietf:params:oauth:grant-type:pre-authorized_code", map[string]string{ + "pre-authorized_code": "code", + }).Return(tokenResponse, nil) + + p256Key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", &p256Key.PublicKey, nil) + + w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, crypto.NewMockJWTSigner(ctrl), keyResolver).(*openidHandler) + w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { + return issuerAPIClient, nil + } + + err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) + + require.EqualError(t, err, "server_error - unable to retrieve credential: signing algorithm ES256 is not supported by issuer (supported: ES384)") + }) + t.Run("ok - algorithm validation skipped when proof_types_supported absent", func(t *testing.T) { + ctrl := gomock.NewController(t) + metadataNoProofTypes := openid4vci.CredentialIssuerMetadata{ + CredentialIssuer: issuerDID.String(), + CredentialEndpoint: "credential-endpoint", + NonceEndpoint: "https://issuer.example/nonce", + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "ExampleCredential_ldp_vc": { + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1", "https://example.com/credentials/v1"}, + "type": []interface{}{"VerifiableCredential", "ExampleCredential"}, + }, + }, + }, + } + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadataNoProofTypes).AnyTimes() + nonce := "nonce-from-endpoint" + issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(&openid4vci.NonceResponse{CNonce: nonce}, nil) + tokenResponse := &oauth.TokenResponse{AccessToken: "access-token", TokenType: "bearer"} + issuerAPIClient.EXPECT().RequestAccessToken("urn:ietf:params:oauth:grant-type:pre-authorized_code", map[string]string{ + "pre-authorized_code": "code", + }).Return(tokenResponse, nil) + expectedRequest := openid4vci.CredentialRequest{ + CredentialConfigurationID: "ExampleCredential_ldp_vc", + Proofs: &openid4vci.CredentialRequestProofs{Jwt: []string{"signed-jwt"}}, + } + issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), expectedRequest, "access-token"). + Return(&vc.VerifiableCredential{ + Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), ssi.MustParseURI("https://example.com/credentials/v1")}, + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("ExampleCredential")}, + Issuer: issuerDID.URI()}, nil) + + credentialStore := types.NewMockWriter(ctrl) + jwtSigner := crypto.NewMockJWTSigner(ctrl) + nowFunc = func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) } + t.Cleanup(func() { nowFunc = time.Now }) + jwtSigner.EXPECT().SignJWT(gomock.Any(), map[string]interface{}{ + "iss": holderDID.String(), + "aud": issuerDID.String(), + "iat": int64(1767225600), + "nonce": nonce, + }, gomock.Any(), "key-id").Return("signed-jwt", nil) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", nil, nil) + + w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, credentialStore, jwtSigner, keyResolver).(*openidHandler) + w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { + return issuerAPIClient, nil + } + + credentialStore.EXPECT().StoreCredential(gomock.Any(), nil).Return(nil) + + err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) + + require.NoError(t, err) + }) +} + // offeredCredential returns a resolved credential configuration for testing. func offeredCredential() []openid4vci.OfferedCredential { return []openid4vci.OfferedCredential{{ diff --git a/vcr/openid4vci/issuer_client.go b/vcr/openid4vci/issuer_client.go index d7f91dc484..a807a45b54 100644 --- a/vcr/openid4vci/issuer_client.go +++ b/vcr/openid4vci/issuer_client.go @@ -102,6 +102,10 @@ func (h defaultIssuerAPIClient) RequestCredential(ctx context.Context, request C if err != nil { return nil, err } + if credentialResponse.TransactionID != "" { + log.Logger().Warnf("Issuer returned deferred credential response (transaction_id: %s)", credentialResponse.TransactionID) + return nil, errors.New("deferred credential issuance is not supported") + } if len(credentialResponse.Credentials) == 0 { return nil, errors.New("credential response does not contain any credentials") } diff --git a/vcr/openid4vci/issuer_client_test.go b/vcr/openid4vci/issuer_client_test.go index 8f4b07c7bc..cf56691c4e 100644 --- a/vcr/openid4vci/issuer_client_test.go +++ b/vcr/openid4vci/issuer_client_test.go @@ -101,6 +101,17 @@ func Test_httpIssuerClient_RequestCredential(t *testing.T) { require.NoError(t, err) require.NotNil(t, credential) }) + t.Run("error - deferred issuance not supported", func(t *testing.T) { + setup := setupClientTest(t) + setup.credentialHandler = setup.httpPostHandler(CredentialResponse{TransactionID: "txn-123"}) + client, err := NewIssuerAPIClient(ctx, httpClient, setup.issuerMetadata.CredentialIssuer) + require.NoError(t, err) + + credential, err := client.RequestCredential(ctx, credentialRequest, "token") + + require.EqualError(t, err, "deferred credential issuance is not supported") + require.Nil(t, credential) + }) t.Run("error - no credentials in response", func(t *testing.T) { setup := setupClientTest(t) setup.credentialHandler = setup.httpPostHandler(CredentialResponse{}) diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index d2afe68753..c7150e57b8 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -22,8 +22,12 @@ package openid4vci import ( "encoding/json" - ssi "github.com/nuts-foundation/go-did" + "fmt" + "slices" + "strings" "time" + + ssi "github.com/nuts-foundation/go-did" ) // PreAuthorizedCodeGrant is the grant type used for pre-authorized code grant from the OpenID4VCI specification. @@ -152,7 +156,11 @@ type CredentialOfferResponse struct { // Per v1.0 Section 8.2, the request identifies the credential using credential_configuration_id. type CredentialRequest struct { // CredentialConfigurationID references a credential configuration from issuer metadata. + // Mutually exclusive with CredentialIdentifier. CredentialConfigurationID string `json:"credential_configuration_id,omitempty"` + // CredentialIdentifier references a specific credential from the token response's authorization_details. + // Mutually exclusive with CredentialConfigurationID. See v1.0 Section 8.2. + CredentialIdentifier string `json:"credential_identifier,omitempty"` // Proofs contains the proof(s) of possession of the key material. Proofs *CredentialRequestProofs `json:"proofs,omitempty"` } @@ -168,8 +176,10 @@ type CredentialRequestProofs struct { // Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-response // In v1.0, when proofs (plural) is used in the request, the response uses `credentials` (array of wrapper objects). // Each element contains a `credential` key holding the actual issued credential. +// When deferred issuance is used (Section 8.3), the response contains a `transaction_id` instead of credentials. type CredentialResponse struct { - Credentials []CredentialResponseEntry `json:"credentials,omitempty"` + Credentials []CredentialResponseEntry `json:"credentials,omitempty"` + TransactionID string `json:"transaction_id,omitempty"` } // CredentialResponseEntry is a single entry in the credentials array of a CredentialResponse. @@ -178,6 +188,44 @@ type CredentialResponseEntry struct { Credential json.RawMessage `json:"credential"` } +// ProofSigningAlgValues extracts proof_signing_alg_values_supported from a credential configuration's +// proof_types_supported.jwt section (v1.0 Appendix F.1). +// Returns nil if proof_types_supported is absent or does not contain a jwt key. +// Returns an error if jwt is present but proof_signing_alg_values_supported is missing (spec violation). +func ProofSigningAlgValues(config map[string]interface{}) ([]string, error) { + proofTypes, ok := config["proof_types_supported"].(map[string]interface{}) + if !ok { + return nil, nil + } + jwtConfig, ok := proofTypes["jwt"].(map[string]interface{}) + if !ok { + return nil, nil + } + algValues, ok := jwtConfig["proof_signing_alg_values_supported"].([]interface{}) + if !ok { + return nil, fmt.Errorf("issuer metadata has proof_types_supported.jwt but is missing proof_signing_alg_values_supported") + } + result := make([]string, 0, len(algValues)) + for _, v := range algValues { + if s, ok := v.(string); ok { + result = append(result, s) + } + } + return result, nil +} + +// ValidateProofSigningAlg checks that the given algorithm is in the issuer's supported list. +// If supportedAlgs is empty, validation is skipped (issuer imposes no constraint). +func ValidateProofSigningAlg(alg string, supportedAlgs []string) error { + if len(supportedAlgs) == 0 { + return nil + } + if !slices.Contains(supportedAlgs, alg) { + return fmt.Errorf("signing algorithm %s is not supported by issuer (supported: %s)", alg, strings.Join(supportedAlgs, ", ")) + } + return nil +} + // Config holds the config for the OpenID4VCI credential issuer and wallet type Config struct { // DefinitionsDIR defines the directory where the additional credential definitions are stored diff --git a/vcr/openid4vci/types_test.go b/vcr/openid4vci/types_test.go index aad0d70586..9b702a06f2 100644 --- a/vcr/openid4vci/types_test.go +++ b/vcr/openid4vci/types_test.go @@ -214,6 +214,32 @@ func TestCredentialResponse_V1Spec(t *testing.T) { assert.NotNil(t, entry["credential"], "each entry must have a credential key") }) + t.Run("deferred response with transaction_id", func(t *testing.T) { + responseJSON := `{"transaction_id": "txn-abc"}` + + var response CredentialResponse + err := json.Unmarshal([]byte(responseJSON), &response) + require.NoError(t, err) + + assert.Equal(t, "txn-abc", response.TransactionID) + assert.Empty(t, response.Credentials) + }) + t.Run("transaction_id omitted when empty", func(t *testing.T) { + credJSON, _ := json.Marshal(map[string]interface{}{"issuer": "did:nuts:issuer"}) + response := CredentialResponse{ + Credentials: []CredentialResponseEntry{{Credential: credJSON}}, + } + + jsonBytes, err := json.Marshal(response) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(jsonBytes, &parsed) + require.NoError(t, err) + + _, hasTransactionID := parsed["transaction_id"] + assert.False(t, hasTransactionID, "transaction_id must be absent when empty") + }) t.Run("response does not contain c_nonce fields", func(t *testing.T) { credJSON, _ := json.Marshal(map[string]interface{}{"issuer": "did:nuts:issuer"}) response := CredentialResponse{ @@ -232,6 +258,72 @@ func TestCredentialResponse_V1Spec(t *testing.T) { }) } +func TestProofSigningAlgValues(t *testing.T) { + t.Run("returns values when present", func(t *testing.T) { + config := map[string]interface{}{ + "proof_types_supported": map[string]interface{}{ + "jwt": map[string]interface{}{ + "proof_signing_alg_values_supported": []interface{}{"ES256", "ES384"}, + }, + }, + } + result, err := ProofSigningAlgValues(config) + require.NoError(t, err) + assert.Equal(t, []string{"ES256", "ES384"}, result) + }) + t.Run("returns nil when proof_types_supported absent", func(t *testing.T) { + config := map[string]interface{}{"format": "ldp_vc"} + result, err := ProofSigningAlgValues(config) + require.NoError(t, err) + assert.Nil(t, result) + }) + t.Run("returns nil when jwt absent in proof_types_supported", func(t *testing.T) { + config := map[string]interface{}{ + "proof_types_supported": map[string]interface{}{ + "cwt": map[string]interface{}{}, + }, + } + result, err := ProofSigningAlgValues(config) + require.NoError(t, err) + assert.Nil(t, result) + }) + t.Run("error - jwt present but proof_signing_alg_values_supported absent", func(t *testing.T) { + config := map[string]interface{}{ + "proof_types_supported": map[string]interface{}{ + "jwt": map[string]interface{}{}, + }, + } + result, err := ProofSigningAlgValues(config) + assert.Nil(t, result) + assert.EqualError(t, err, "issuer metadata has proof_types_supported.jwt but is missing proof_signing_alg_values_supported") + }) + t.Run("skips non-string values in algorithm array", func(t *testing.T) { + config := map[string]interface{}{ + "proof_types_supported": map[string]interface{}{ + "jwt": map[string]interface{}{ + "proof_signing_alg_values_supported": []interface{}{"ES256", 42, "ES384"}, + }, + }, + } + result, err := ProofSigningAlgValues(config) + require.NoError(t, err) + assert.Equal(t, []string{"ES256", "ES384"}, result) + }) +} + +func TestValidateProofSigningAlg(t *testing.T) { + t.Run("ok - algorithm is supported", func(t *testing.T) { + assert.NoError(t, ValidateProofSigningAlg("ES256", []string{"ES256", "ES384"})) + }) + t.Run("ok - no constraint when supportedAlgs is empty", func(t *testing.T) { + assert.NoError(t, ValidateProofSigningAlg("ES256", nil)) + }) + t.Run("error - algorithm not supported", func(t *testing.T) { + err := ValidateProofSigningAlg("ES256", []string{"ES384", "ES512"}) + assert.EqualError(t, err, "signing algorithm ES256 is not supported by issuer (supported: ES384, ES512)") + }) +} + // TestCredentialDefinition_Validation tests credential definition validation func TestCredentialDefinition_Validation(t *testing.T) { t.Run("valid definition", func(t *testing.T) {