diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 021c1b7463..e9b3eb5daf 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -34,15 +34,17 @@ import ( "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" nutsHttp "github.com/nuts-foundation/nuts-node/http" + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) var timeFunc = time.Now -// jwtTypeOpenID4VCIProof defines the OpenID4VCI JWT-subtype (used as typ claim in the JWT). -const jwtTypeOpenID4VCIProof = "openid4vci-proof+jwt" - func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, request RequestOpenid4VCICredentialIssuanceRequestObject) (RequestOpenid4VCICredentialIssuanceResponseObject, error) { + if request.Body == nil { + // why did oapi-codegen generate a pointer for the body?? + return nil, core.InvalidInputError("missing request body") + } walletDID, err := did.ParseDID(request.Body.WalletDid) if err != nil { return nil, core.InvalidInputError("invalid wallet DID") @@ -52,11 +54,6 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques } else if !owned { return nil, core.InvalidInputError("wallet DID does not belong to the subject") } - - if request.Body == nil { - // why did oapi-codegen generate a pointer for the body?? - return nil, core.InvalidInputError("missing request body") - } // Parse the issuer issuer := request.Body.Issuer if issuer == "" { @@ -81,8 +78,12 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques // Read and parse the authorization details authorizationDetails := []byte("[]") + var credentialConfigID string if len(request.Body.AuthorizationDetails) > 0 { authorizationDetails, _ = json.Marshal(request.Body.AuthorizationDetails) + if id, ok := request.Body.AuthorizationDetails[0]["credential_configuration_id"].(string); ok { + credentialConfigID = id + } } // Generate the state and PKCE state := crypto.GenerateNonce() @@ -100,9 +101,11 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques PKCEParams: pkceParams, // OpenID4VCI issuers may use multiple Authorization Servers // We must use the token_endpoint that corresponds to the same Authorization Server used for the authorization_endpoint - TokenEndpoint: authzServerMetadata.TokenEndpoint, - IssuerURL: authzServerMetadata.Issuer, - IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, + TokenEndpoint: authzServerMetadata.TokenEndpoint, + IssuerURL: authzServerMetadata.Issuer, + IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, + IssuerNonceEndpoint: credentialIssuerMetadata.NonceEndpoint, + IssuerCredentialConfigurationID: credentialConfigID, }) if err != nil { return nil, fmt.Errorf("failed to store session: %w", err) @@ -129,8 +132,6 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques } func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode string, oauthSession *OAuthSession) (CallbackResponseObject, error) { - // extract callback URI at calling app from OAuthSession - // this is the URI where the user-agent will be redirected to appCallbackURI := oauthSession.redirectURI() baseURL := r.subjectToBaseURL(*oauthSession.OwnSubject) @@ -142,31 +143,49 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode } // use code to request access token from remote token endpoint - response, err := r.auth.IAMClient().AccessToken(ctx, authorizationCode, oauthSession.TokenEndpoint, checkURL.String(), *oauthSession.OwnSubject, clientID, oauthSession.PKCEParams.Verifier, false) + tokenResponse, err := r.auth.IAMClient().AccessToken(ctx, authorizationCode, oauthSession.TokenEndpoint, checkURL.String(), *oauthSession.OwnSubject, clientID, oauthSession.PKCEParams.Verifier, false) if err != nil { return nil, withCallbackURI(oauthError(oauth.AccessDenied, fmt.Sprintf("error while fetching the access_token from endpoint: %s, error: %s", oauthSession.TokenEndpoint, err.Error())), appCallbackURI) } - // make proof and collect credential - proofJWT, err := r.openid4vciProof(ctx, *oauthSession.OwnDID, oauthSession.IssuerURL, response.Get(oauth.CNonceParam)) - if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error building proof to fetch the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI) + // fetch nonce from the Nonce Endpoint (v1.0 Section 7) + var nonce string + if oauthSession.IssuerNonceEndpoint != "" { + nonce, err = r.auth.IAMClient().RequestNonce(ctx, oauthSession.IssuerNonceEndpoint) + if err != nil { + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error fetching nonce from %s: %s", oauthSession.IssuerNonceEndpoint, err.Error())), appCallbackURI) + } } - credentials, err := r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, response.AccessToken, proofJWT) + + // build proof and request credential + credentialResponse, err := r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, nonce) if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while fetching the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI) - } - // validate credential - // TODO: check that issued credential is bound to DID that requested it (OwnDID)??? - credential, err := vc.ParseVerifiableCredential(credentials.Credential) + // on invalid_nonce: fetch a fresh nonce and retry once + var oidcErr openid4vci.Error + if errors.As(err, &oidcErr) && oidcErr.Code == openid4vci.InvalidNonce && oauthSession.IssuerNonceEndpoint != "" { + nonce, err = r.auth.IAMClient().RequestNonce(ctx, oauthSession.IssuerNonceEndpoint) + if err != nil { + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error fetching nonce for retry from %s: %s", oauthSession.IssuerNonceEndpoint, err.Error())), appCallbackURI) + } + credentialResponse, err = r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, nonce) + } + if err != nil { + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while fetching the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI) + } + } + if len(credentialResponse.Credentials) == 0 { + return nil, withCallbackURI(oauthError(oauth.ServerError, "credential response does not contain any credentials"), appCallbackURI) + } + + credentialJSON := string(credentialResponse.Credentials[0].Credential) + credential, err := vc.ParseVerifiableCredential(credentialJSON) if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while parsing the credential: %s, error: %s", credentials.Credential, err.Error())), appCallbackURI) + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while parsing the credential: %s, error: %s", credentialJSON, err.Error())), appCallbackURI) } err = r.vcr.Verifier().Verify(*credential, true, true, nil) if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while verifying the credential from issuer: %s, error: %s", credential.Issuer.String(), err.Error())), appCallbackURI) } - // store credential in wallet err = r.vcr.Wallet().Put(ctx, *credential) if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while storing credential with id: %s, error: %s", credential.ID, err.Error())), appCallbackURI) @@ -176,18 +195,22 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode }, nil } +func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *OAuthSession, accessToken string, nonce string) (*openid4vci.CredentialResponse, error) { + proofJWT, err := r.openid4vciProof(ctx, *oauthSession.OwnDID, oauthSession.IssuerURL, nonce) + if err != nil { + return nil, fmt.Errorf("error building proof: %w", err) + } + return r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, accessToken, oauthSession.IssuerCredentialConfigurationID, proofJWT) +} + func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audience string, nonce string) (string, error) { kid, _, err := r.keyResolver.ResolveKey(holderDid, nil, resolver.AssertionMethod) if err != nil { return "", fmt.Errorf("failed to resolve key for did (%s): %w", holderDid.String(), err) } headers := map[string]interface{}{ - "typ": jwtTypeOpenID4VCIProof, // MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725]. - "kid": kid, // JOSE Header containing the key ID. If the Credential shall be bound to a DID, the kid refers to a DID URL which identifies a particular key in the DID Document that the Credential shall be bound to. - } - if err != nil { - // can't fail or would have failed before - return "", err + "typ": openid4vci.JWTTypeOpenID4VCIProof, // MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725]. + "kid": kid, // JOSE Header containing the key ID. If the Credential shall be bound to a DID, the kid refers to a DID URL which identifies a particular key in the DID Document that the Credential shall be bound to. } claims := map[string]interface{}{ jwt.IssuerKey: holderDid.String(), diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 957595cbc8..7cdf016dfa 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -20,6 +20,7 @@ package iam import ( "context" + "encoding/json" "errors" "net/url" "testing" @@ -27,9 +28,9 @@ import ( "github.com/nuts-foundation/nuts-node/core/to" - "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -76,7 +77,6 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { assert.Equal(t, "S256", redirectUri.Query().Get("code_challenge_method")) assert.Equal(t, "code", redirectUri.Query().Get("response_type")) assert.Equal(t, `[{"format":"vc+sd-jwt","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) - println(redirectUri.String()) }) t.Run("openid4vciMetadata", func(t *testing.T) { t.Run("ok - fallback to issuerDID on empty AuthorizationServers", func(t *testing.T) { @@ -176,6 +176,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { redirectURI := "https://example.com/oauth2/holder/callback" authServer := "https://auth.server" tokenEndpoint := authServer + "/token" + nonceEndpoint := authServer + "/nonce" cNonce := crypto.GenerateNonce() credEndpoint := authServer + "/credz" pkceParams := generatePKCEParams() @@ -185,30 +186,37 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { verifiableCredential := createIssuerCredential(issuerDID, holderDID) redirectUrl := "https://client.service/issuance_is_done" + credentialConfigID := "NutsOrganizationCredential_ldp_vc" session := OAuthSession{ AuthorizationServerMetadata: &oauth.AuthorizationServerMetadata{ ClientIdSchemesSupported: clientIdSchemesSupported, }, - ClientFlow: "openid4vci_credential_request", - OwnSubject: &holderSubjectID, - OwnDID: &holderDID, - RedirectURI: redirectUrl, - PKCEParams: pkceParams, - TokenEndpoint: tokenEndpoint, - IssuerURL: issuerClientID, - IssuerCredentialEndpoint: credEndpoint, + ClientFlow: "openid4vci_credential_request", + OwnSubject: &holderSubjectID, + OwnDID: &holderDID, + RedirectURI: redirectUrl, + PKCEParams: pkceParams, + TokenEndpoint: tokenEndpoint, + IssuerURL: issuerClientID, + IssuerCredentialEndpoint: credEndpoint, + IssuerNonceEndpoint: nonceEndpoint, + IssuerCredentialConfigurationID: credentialConfigID, } - tokenResponse := (&oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"}).With("c_nonce", cNonce) - credentialResponse := iam.CredentialResponse{ - Credential: verifiableCredential.Raw(), + sessionWithoutNonce := session + sessionWithoutNonce.IssuerNonceEndpoint = "" + + tokenResponse := &oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"} + credentialResponse := openid4vci.CredentialResponse{ + Credentials: []openid4vci.CredentialResponseEntry{{Credential: json.RawMessage(verifiableCredential.Raw())}}, } now := time.Now() timeFunc = func() time.Time { return now } defer func() { timeFunc = time.Now }() - t.Run("ok", func(t *testing.T) { + t.Run("ok - with nonce endpoint", func(t *testing.T) { ctx := newTestClient(t) require.NoError(t, ctx.client.oauthClientStateStore().Put(state, &session)) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").DoAndReturn(func(_ context.Context, claims map[string]interface{}, headers map[string]interface{}, key interface{}) (string, error) { assert.Equal(t, map[string]interface{}{"typ": "openid4vci-proof+jwt", "kid": "kid"}, headers) @@ -221,7 +229,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Equal(t, expectedClaims, claims) return "signed-proof", nil }) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) ctx.wallet.EXPECT().Put(nil, *verifiableCredential) @@ -238,6 +246,93 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { actual := callback.(Callback302Response) assert.Equal(t, redirectUrl, actual.Headers.Location) }) + t.Run("ok - no nonce endpoint", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").DoAndReturn(func(_ context.Context, claims map[string]interface{}, headers map[string]interface{}, key interface{}) (string, error) { + _, hasNonce := claims["nonce"] + assert.False(t, hasNonce, "nonce should not be set when no nonce endpoint is configured") + return "signed-proof", nil + }) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionWithoutNonce) + + require.NoError(t, err) + assert.NotNil(t, callback) + }) + t.Run("ok - invalid_nonce retry succeeds", func(t *testing.T) { + ctx := newTestClient(t) + freshNonce := "fresh-nonce" + invalidNonceErr := 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) + // 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) + // retry with fresh nonce + ctx.iamClient.EXPECT().RequestNonce(nil, 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.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + require.NoError(t, err) + assert.NotNil(t, callback) + }) + t.Run("error - invalid_nonce retry also fails", func(t *testing.T) { + ctx := newTestClient(t) + invalidNonceErr := 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.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) + // retry also fails + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("fresh-nonce", nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-2", nil) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof-2").Return(nil, errors.New("still failing")) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "error while fetching the credential from endpoint") + }) + t.Run("error - nonce endpoint fails during retry", func(t *testing.T) { + ctx := newTestClient(t) + invalidNonceErr := 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.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) + // retry nonce fetch fails + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("", errors.New("nonce endpoint down")) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "error fetching nonce for retry") + }) + t.Run("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")) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "error fetching nonce from") + }) t.Run("fail_access_token", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(nil, errors.New("FAIL")) @@ -251,9 +346,10 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("fail_credential_response", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(nil, errors.New("FAIL")) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(nil, errors.New("FAIL")) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) @@ -263,23 +359,25 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("err - invalid credential", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&iam.CredentialResponse{ - Credential: "super invalid", + ctx.iamClient.EXPECT().VerifiableCredentials(nil, 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) assert.Nil(t, callback) - assert.EqualError(t, err, "server_error - error while parsing the credential: super invalid, error: invalid JWT") + assert.ErrorContains(t, err, "error while parsing the credential") }) t.Run("fail_verify", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, 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) @@ -290,6 +388,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("error - key not found", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("", nil, resolver.ErrKeyNotFound) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) @@ -300,6 +399,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("error - signature failure", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("", errors.New("signature failed")) @@ -308,4 +408,29 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Nil(t, callback) assert.ErrorContains(t, err, "failed to sign the JWT with kid (kid): signature failed") }) + t.Run("error - nil OwnDID in session", func(t *testing.T) { + ctx := newTestClient(t) + sessionNilDID := session + sessionNilDID.OwnDID = nil + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionNilDID) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "missing wallet DID in session") + }) + t.Run("error - empty credentials array", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ + Credentials: []openid4vci.CredentialResponseEntry{}, + }, nil) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "credential response does not contain any credentials") + }) } diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index 2f4626429b..09ef6fcd9e 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -55,6 +55,10 @@ type OAuthSession struct { UseDPoP bool `json:"use_dpop,omitempty"` // IssuerCredentialEndpoint: endpoint to exchange the access_token for a credential in the OpenID4VCI flow IssuerCredentialEndpoint string `json:"issuer_credential_endpoint,omitempty"` + // IssuerNonceEndpoint: endpoint to request a fresh c_nonce in the OpenID4VCI flow (v1.0 Section 7) + IssuerNonceEndpoint string `json:"issuer_nonce_endpoint,omitempty"` + // IssuerCredentialConfigurationID: the credential_configuration_id for the credential request in the OpenID4VCI flow + IssuerCredentialConfigurationID string `json:"issuer_credential_configuration_id,omitempty"` } // 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 0020fea550..db9694e4f2 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -38,6 +38,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth/log" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vcr/pe" ) @@ -242,6 +243,35 @@ func (hb HTTPClient) PostAuthorizationResponse(ctx context.Context, vp vc.Verifi return hb.postFormExpectRedirect(ctx, data, verifierResponseURI) } +func (hb HTTPClient) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, nonceEndpoint, http.NoBody) + if err != nil { + return "", err + } + response, err := hb.httpClient.Do(request) + if err != nil { + return "", fmt.Errorf("nonce request failed: %w", err) + } + defer response.Body.Close() + data, err := io.ReadAll(response.Body) + if err != nil { + return "", fmt.Errorf("unable to read nonce response: %w", err) + } + if response.StatusCode < 200 || response.StatusCode > 299 { + return "", fmt.Errorf("nonce endpoint returned status %d", response.StatusCode) + } + var nonceResponse struct { + CNonce string `json:"c_nonce"` + } + if err = json.Unmarshal(data, &nonceResponse); err != nil { + return "", fmt.Errorf("unable to unmarshal nonce response: %w", err) + } + if nonceResponse.CNonce == "" { + return "", errors.New("nonce endpoint returned empty c_nonce") + } + return nonceResponse.CNonce, nil +} + func (hb HTTPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) { metadataURL, err := oauth.IssuerIdToWellKnown(oauthIssuerURI, oauth.OpenIdCredIssuerWellKnown, hb.strictMode) if err != nil { @@ -308,34 +338,16 @@ func (hb HTTPClient) KeyProvider() jws.KeyProviderFunc { } } -// CredentialRequest represents ths request to fetch a credential, the JSON object holds the proof as -// CredentialRequestProof. -type CredentialRequest struct { - Proof CredentialRequestProof `json:"proof"` -} - -// CredentialRequestProof holds the ProofType and Jwt for a credential request -type CredentialRequestProof struct { - ProofType string `json:"proof_type"` - Jwt string `json:"jwt"` -} - -// CredentialResponse represents the response of a verifiable credential request. -// It contains the Format and the actual Credential in JSON format. -type CredentialResponse struct { - Credential string `json:"credential"` -} - -func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, proofJwt string) (*CredentialResponse, error) { +func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJwt string) (*openid4vci.CredentialResponse, error) { credentialEndpointURL, err := url.Parse(credentialEndpoint) if err != nil { return nil, err } - credentialRequest := CredentialRequest{ - Proof: CredentialRequestProof{ - ProofType: "jwt", - Jwt: proofJwt, + credentialRequest := openid4vci.CredentialRequest{ + CredentialConfigurationID: credentialConfigID, + Proofs: &openid4vci.CredentialRequestProofs{ + Jwt: []string{proofJwt}, }, } jsonBody, _ := json.Marshal(credentialRequest) @@ -357,15 +369,23 @@ func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoi log.Logger().WithError(err).Warn("Trouble closing reader") } }(response.Body) - if err = core.TestResponseCode(http.StatusOK, response); err != nil { - return nil, err + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) } - var credential CredentialResponse - if err = json.NewDecoder(response.Body).Decode(&credential); err != nil { + if response.StatusCode < 200 || response.StatusCode > 299 { + var oidcError openid4vci.Error + if json.Unmarshal(responseBody, &oidcError) == nil && oidcError.Code != "" { + oidcError.StatusCode = response.StatusCode + return nil, oidcError + } + return nil, fmt.Errorf("credential request failed (status %d)", response.StatusCode) + } + var credentialResponse openid4vci.CredentialResponse + if err = json.Unmarshal(responseBody, &credentialResponse); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } - return &credential, nil - + return &credentialResponse, nil } func (hb HTTPClient) postFormExpectRedirect(ctx context.Context, form url.Values, redirectURL url.URL) (string, error) { request, err := http.NewRequestWithContext(ctx, http.MethodPost, redirectURL.String(), strings.NewReader(form.Encode())) diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 5016708d9d..5d55262aa7 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -20,8 +20,10 @@ package iam import ( "context" + "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vcr/pe" ) @@ -52,8 +54,10 @@ type Client interface { OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) // OpenIDConfiguration returns the OpenID Configuration of the remote wallet. OpenIDConfiguration(ctx context.Context, issuer string) (*oauth.OpenIDConfiguration, error) + // 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, proofJWT string) (*CredentialResponse, error) + VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJWT string) (*openid4vci.CredentialResponse, error) // RequestObjectByGet retrieves the RequestObjectByGet from the authorization request's 'request_uri' endpoint using a GET method as defined in RFC9101/OpenID4VP. // This method is used when there is no 'request_uri_method', or its value is 'get'. RequestObjectByGet(ctx context.Context, requestURI string) (string, error) diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index add1a059cd..d8a3e4192e 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -15,6 +15,7 @@ import ( vc "github.com/nuts-foundation/go-did/vc" oauth "github.com/nuts-foundation/nuts-node/auth/oauth" + openid4vci "github.com/nuts-foundation/nuts-node/vcr/openid4vci" pe "github.com/nuts-foundation/nuts-node/vcr/pe" gomock "go.uber.org/mock/gomock" ) @@ -23,7 +24,6 @@ import ( type MockClient struct { ctrl *gomock.Controller recorder *MockClientMockRecorder - isgomock struct{} } // MockClientMockRecorder is the mock recorder for MockClient. @@ -163,6 +163,21 @@ func (mr *MockClientMockRecorder) PresentationDefinition(ctx, endpoint any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinition", reflect.TypeOf((*MockClient)(nil).PresentationDefinition), ctx, endpoint) } +// RequestNonce mocks base method. +func (m *MockClient) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequestNonce", ctx, nonceEndpoint) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequestNonce indicates an expected call of RequestNonce. +func (mr *MockClientMockRecorder) RequestNonce(ctx, nonceEndpoint any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestNonce", reflect.TypeOf((*MockClient)(nil).RequestNonce), ctx, nonceEndpoint) +} + // RequestObjectByGet mocks base method. func (m *MockClient) RequestObjectByGet(ctx context.Context, requestURI string) (string, error) { m.ctrl.T.Helper() @@ -209,16 +224,16 @@ func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjec } // VerifiableCredentials mocks base method. -func (m *MockClient) VerifiableCredentials(ctx context.Context, credentialEndpoint, accessToken, proofJWT string) (*CredentialResponse, error) { +func (m *MockClient) VerifiableCredentials(ctx context.Context, credentialEndpoint, accessToken, credentialConfigID, proofJWT string) (*openid4vci.CredentialResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "VerifiableCredentials", ctx, credentialEndpoint, accessToken, proofJWT) - ret0, _ := ret[0].(*CredentialResponse) + ret := m.ctrl.Call(m, "VerifiableCredentials", ctx, credentialEndpoint, accessToken, credentialConfigID, 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, proofJWT any) *gomock.Call { +func (mr *MockClientMockRecorder) VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifiableCredentials", reflect.TypeOf((*MockClient)(nil).VerifiableCredentials), ctx, credentialEndpoint, accessToken, proofJWT) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifiableCredentials", reflect.TypeOf((*MockClient)(nil).VerifiableCredentials), ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) } diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 0f9e370601..a3d56cb2dc 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -43,6 +43,7 @@ import ( "github.com/nuts-foundation/nuts-node/crypto/dpop" nutsHttp "github.com/nuts-foundation/nuts-node/http" "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -355,11 +356,15 @@ func (c *OpenID4VPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oa return rsp, nil } -func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, proofJWT string) (*CredentialResponse, error) { +func (c *OpenID4VPClient) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { + return c.httpClient.RequestNonce(ctx, nonceEndpoint) +} + +func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJWT string) (*openid4vci.CredentialResponse, error) { iamClient := c.httpClient - rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, proofJWT) + rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) if err != nil { - return nil, fmt.Errorf("remote server: failed to retrieve credentials: %w", err) + return nil, err } return rsp, nil } diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index e5c4ef6840..7422661bef 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -42,6 +42,7 @@ import ( "github.com/nuts-foundation/nuts-node/crypto" 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" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/didweb" "github.com/nuts-foundation/nuts-node/vdr/resolver" @@ -523,6 +524,7 @@ type clientServerTestContext struct { presentationDefinition func(writer http.ResponseWriter) response func(writer http.ResponseWriter) token func(writer http.ResponseWriter) + nonce func(writer http.ResponseWriter) credentials func(writer http.ResponseWriter) requestObjectJWT func(writer http.ResponseWriter) } @@ -577,10 +579,16 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { _, _ = writer.Write([]byte(`{"access_token": "token", "token_type": "bearer"}`)) return }, + nonce: func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte(`{"c_nonce": "server-nonce"}`)) + return + }, credentials: func(writer http.ResponseWriter) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) - _, _ = writer.Write([]byte(`{"format": "format", "credential": "credential"}`)) + _, _ = writer.Write([]byte(`{"credentials": [{"credential": {"type": "VerifiableCredential"}}]}`)) return }, requestObjectJWT: func(writer http.ResponseWriter) { @@ -627,6 +635,11 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { ctx.token(writer) return } + case "/nonce": + if ctx.nonce != nil { + ctx.nonce(writer) + return + } case "/credentials": if ctx.credentials != nil { ctx.credentials(writer) @@ -680,40 +693,92 @@ func TestIAMClient_OpenIdCredentialIssuerMetadata(t *testing.T) { }) } +func TestIAMClient_RequestNonce(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctx := createClientServerTestContext(t) + nonceEndpoint := ctx.tlsServer.URL + "/nonce" + + nonce, err := ctx.client.RequestNonce(context.Background(), nonceEndpoint) + + require.NoError(t, err) + assert.Equal(t, "server-nonce", nonce) + }) + t.Run("error - endpoint not found", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.nonce = nil + nonceEndpoint := ctx.tlsServer.URL + "/nonce" + + nonce, err := ctx.client.RequestNonce(context.Background(), nonceEndpoint) + + assert.Error(t, err) + assert.Empty(t, nonce) + }) +} + func TestIAMClient_VerifiableCredentials(t *testing.T) { accessToken := "code" - proowJWT := "top secret" + proofJWT := "top secret" + credentialConfigID := "NutsOrganizationCredential_ldp_vc" t.Run("ok", func(t *testing.T) { ctx := createClientServerTestContext(t) - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, proowJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) require.NoError(t, err) require.NotNil(t, response) - assert.Equal(t, "credential", response.Credential) + require.Len(t, response.Credentials, 1) + assert.JSONEq(t, `{"type": "VerifiableCredential"}`, string(response.Credentials[0].Credential)) }) - t.Run("error - failed to get access token", func(t *testing.T) { + t.Run("ok - json object credential (ldp_vc)", func(t *testing.T) { ctx := createClientServerTestContext(t) + ctx.credentials = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte(`{"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) + require.NoError(t, err) + require.NotNil(t, response) + require.Len(t, response.Credentials, 1) + assert.Contains(t, string(response.Credentials[0].Credential), "VerifiableCredential") + }) + t.Run("error - credential endpoint returns 404", func(t *testing.T) { + ctx := createClientServerTestContext(t) ctx.credentials = nil - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, proowJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) - assert.EqualError(t, err, "remote server: failed to retrieve credentials: server returned HTTP 404 (expected: 200)") + assert.Error(t, err) assert.Nil(t, response) }) - t.Run("error - invalid access token", func(t *testing.T) { + t.Run("error - structured error on 400", func(t *testing.T) { ctx := createClientServerTestContext(t) + ctx.credentials = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusBadRequest) + _, _ = writer.Write([]byte(`{"error": "invalid_nonce"}`)) + } + + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) + assert.Nil(t, response) + require.Error(t, err) + var oidcErr openid4vci.Error + require.ErrorAs(t, err, &oidcErr) + assert.Equal(t, openid4vci.InvalidNonce, oidcErr.Code) + }) + t.Run("error - invalid response body", func(t *testing.T) { + ctx := createClientServerTestContext(t) ctx.credentials = func(writer http.ResponseWriter) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) - _, _ = writer.Write([]byte(`{"format": "format", "credential": fail}`)) - return + _, _ = writer.Write([]byte(`{"credentials": fail}`)) } - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, proowJWT) + 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 c0a6d769d2..4224c072ae 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -405,14 +405,11 @@ type Redirect struct { // OpenIDCredentialIssuerMetadata represents the metadata of an OpenID credential issuer type OpenIDCredentialIssuerMetadata struct { - // - CredentialIssuer: an url representing the credential issuer - CredentialIssuer string `json:"credential_issuer"` - // - CredentialEndpoint: an url representing the credential endpoint - CredentialEndpoint string `json:"credential_endpoint"` - // - AuthorizationServers: a slice of urls representing the authorization servers (optional) - AuthorizationServers []string `json:"authorization_servers,omitempty"` - // - Display: a slice of maps where each map represents the display information (optional) - Display []map[string]string `json:"display,omitempty"` + 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"` } // OpenIDConfiguration represents the OpenID configuration diff --git a/codegen/configs/vcr_openid4vci_v0.yaml b/codegen/configs/vcr_openid4vci_v0.yaml index 2185dbd020..dd7adee1d8 100644 --- a/codegen/configs/vcr_openid4vci_v0.yaml +++ b/codegen/configs/vcr_openid4vci_v0.yaml @@ -13,4 +13,5 @@ output-options: - CredentialRequest - CredentialResponse - TokenResponse - - ErrorResponse \ No newline at end of file + - ErrorResponse + - NonceResponse \ No newline at end of file diff --git a/docs/_static/vcr/openid4vci_v0.yaml b/docs/_static/vcr/openid4vci_v0.yaml index 39c93571b8..e9a8b1821c 100644 --- a/docs/_static/vcr/openid4vci_v0.yaml +++ b/docs/_static/vcr/openid4vci_v0.yaml @@ -3,8 +3,7 @@ info: title: OpenID4VCI Issuer API version: 0.0.0 description: > - This API implements OpenID 4 Verifiable Credential Issuance. - The specification is in draft and may change, thus this API might change as well. + This API implements OpenID 4 Verifiable Credential Issuance (v1.0). servers: - url: http://localhost:8081 description: For internal-facing endpoints. @@ -201,8 +200,10 @@ paths: "$ref": "#/components/schemas/ErrorResponse" "400": description: > - Invalid request. Code can be "invalid_request", "unsupported_credential_type", "unsupported_credential_format" or "invalid_proof". - Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-error-response + Invalid request. Code can be "invalid_credential_request", "unknown_credential_configuration", + "unknown_credential_identifier", "invalid_proof", "invalid_nonce", "invalid_encryption_parameters", + or "credential_request_denied". + Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#section-8.3.1.2 content: application/json: schema: @@ -215,10 +216,37 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" - "403": - description: > - Insufficient privileges. Code will be "insufficient_scope". - Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-error-response + "/n2n/identity/{did}/openid4vci/nonce": + post: + tags: + - Issuer + summary: Request a fresh c_nonce value + description: > + Nonce Endpoint per OpenID4VCI v1.0 Section 7. + A Credential Issuer that requires c_nonce values MUST offer this endpoint. + The request has an empty body (Content-Length: 0) and requires no authentication. + operationId: requestNonce + parameters: + - name: did + in: path + required: true + schema: + type: string + example: did:nuts:123 + responses: + "200": + description: OK + headers: + Cache-Control: + schema: + type: string + example: no-store + content: + application/json: + schema: + "$ref": "#/components/schemas/NonceResponse" + "404": + description: Unknown issuer content: application/json: schema: @@ -269,7 +297,7 @@ components: required: - credential_issuer - credential_endpoint - - credentials_supported + - credential_configurations_supported properties: credential_issuer: type: string @@ -278,24 +306,34 @@ components: credential_endpoint: type: string example: "https://issuer.example/credential" - credentials_supported: - type: array + nonce_endpoint: + type: string + description: > + URL of the Nonce Endpoint where wallets can request a fresh c_nonce. + Per v1.0 Section 7, a Credential Issuer that requires c_nonce values MUST offer this endpoint. + example: "https://issuer.example/nonce" + credential_configurations_supported: + type: object description: | - A JSON array containing a list of JSON objects, each of them representing metadata about a separate credential type that the Credential Issuer can issue. - items: + A JSON object containing credential configurations supported by the Credential Issuer. + The keys are credential_configuration_ids that can be referenced in credential offers. + additionalProperties: type: object - example: + example: + NutsAuthorizationCredential_ldp_vc: { "format": "ldp_vc", - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://nuts.nl/credentials/v1" - ], - "type": [ - "VerifiableCredential", - "NutsAuthorizationCredential" - ], - "cryptographic_binding_methods_supported": "did:nuts" + "credential_definition": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://nuts.nl/credentials/v1" + ], + "type": [ + "VerifiableCredential", + "NutsAuthorizationCredential" + ] + }, + "cryptographic_binding_methods_supported": ["did:nuts"] } OAuth2ClientMetadata: @@ -328,6 +366,12 @@ components: description: | URL of the authorization server's token endpoint [RFC6749]. example: https://issuer.example.com/token + pre-authorized_grant_anonymous_access_supported: + type: boolean + description: | + Indicates whether anonymous access (requests without client_id) is supported + for pre-authorized code grant flows. + example: true TokenResponse: type: object @@ -352,73 +396,55 @@ components: description: | The lifetime in seconds of the access token. example: 3600 - c_nonce: - type: string - description: | - JSON string containing a nonce to be used to create a proof of possession of key material when requesting a Credential. When received, the Wallet MUST use this nonce value for its subsequent credential requests until the Credential Issuer provides a fresh nonce. - example: "tZignsnFbp" example: { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6Ikp..sHQ", "token_type": "bearer", - "expires_in": 3600, - "c_nonce": "tZignsnFbp" + "expires_in": 3600 } CredentialRequest: type: object required: - - format + - credential_configuration_id + description: | + Per OpenID4VCI v1.0 Section 8.2, the request identifies the credential using credential_configuration_id. properties: - format: + credential_configuration_id: type: string description: | - The format of the credential request. This MUST be one of the values specified in the "credentials_supported" array in the Credential Issuer Metadata. - example: "ldp_vc" - credential_definition: + References a credential configuration from the issuer's credential_configurations_supported metadata. + example: "NutsAuthorizationCredential_ldp_vc" + proofs: type: object - description: JSON-LD object describing the requested credential. - proof: - type: object - required: - - proof_type - - jwt + description: | + Object providing one or more proof of possessions of the cryptographic key material. + The key is the proof type (e.g., "jwt") and the value is an array of proofs. properties: - proof_type: - type: string - example: "jwt" jwt: - type: string + type: array + items: + type: string description: | - String with a JWS [RFC7515] as proof of possession. - - The fields of the JWT may look like this: - + Array of JWS [RFC7515] strings as proof of possession. + + The fields of each JWT may look like this: + { "typ": "openid4vci-proof+jwt", "alg": "ES256", "kid": "did:nuts:ebfeb1f712ebc6f1c276e12ec21#keys-1" }. { + "iss": "did:nuts:ebfeb1f712ebc6f1c276e12ec21", "aud": "https://credential-issuer.example.com", "iat": 1659145924, "nonce": "tZignsnFbp" } example: { - "format": "ldp_vc", - "credential_definition": { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://nuts.nl/credentials/v1" - ], - "type": [ - "VerifiableCredential", - "NutsAuthorizationCredential" - ], - }, - "proof": { - "proof_type": "jwt", - "jwt": "eyJraWQiOiJkaWQ6ZXhhbXBsZ...KPxgihac0aW9EkL1nOzM" + "credential_configuration_id": "NutsAuthorizationCredential_ldp_vc", + "proofs": { + "jwt": ["eyJraWQiOiJkaWQ6ZXhhbXBsZ...KPxgihac0aW9EkL1nOzM"] } } ErrorResponse: @@ -430,88 +456,87 @@ components: type: string description: Code identifying the error that occurred. example: "invalid_request" - c_nonce: - type: string - description: a string containing a new nonce value to be used for subsequent requests. - example: "tZignsnFbp" - c_nonce_expires_in: - type: integer - description: The lifetime in seconds of the nonce value. - example: 900 CredentialResponse: type: object - required: - - format + description: | + Per OpenID4VCI v1.0 Section 8.3, the response contains a credentials array where each entry + is a wrapper object with a credential key holding the issued credential. properties: - format: - type: string - example: "ldp_vc" - credential: - type: object - c_nonce: - type: string - example: "fGFF7UkhLa" + credentials: + type: array + items: + type: object + required: + - credential + properties: + credential: + type: object + description: Contains one issued Credential. example: { - "format": "ldp_vc", - "credential": { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://nuts.nl/credentials/v1" - ], - "id": "did:nuts:#123", - "type": [ - "VerifiableCredential", - "NutsAuthorizationCredential" - ], - "issuer": "did:nuts:", - "issuanceDate": "2010-01-01T00:00:00Z", - "credentialSubject": { - "id": "did:nuts:", - "patient": "bsn:999992", - "purposeOfUse": "careviewer" - }, - "proof": { - "type": "Ed25519Signature2020", - "created": "2022-02-25T14:58:43Z", - "verificationMethod": "did:nuts:#key-1", - "proofPurpose": "assertionMethod", - "proofValue": "zeEdUoM7m9cY8ZyTpey83yBKeBcmcvbyrEQzJ19rD2UXArU2U1jPGoEtrRvGYppdiK37GU4NBeoPakxpWhAvsVSt" + "credentials": [ + { + "credential": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://nuts.nl/credentials/v1" + ], + "id": "did:nuts:#123", + "type": [ + "VerifiableCredential", + "NutsAuthorizationCredential" + ], + "issuer": "did:nuts:", + "issuanceDate": "2010-01-01T00:00:00Z", + "credentialSubject": { + "id": "did:nuts:", + "patient": "bsn:999992", + "purposeOfUse": "careviewer" + }, + "proof": { + "type": "Ed25519Signature2020", + "created": "2022-02-25T14:58:43Z", + "verificationMethod": "did:nuts:#key-1", + "proofPurpose": "assertionMethod", + "proofValue": "zeEdUoM7m9cY8ZyTpey83yBKeBcmcvbyrEQzJ19rD2UXArU2U1jPGoEtrRvGYppdiK37GU4NBeoPakxpWhAvsVSt" + } + } } - }, - "c_nonce": "fGFF7UkhLa" + ] } CredentialOffer: type: object required: - credential_issuer - - credentials - - grants # TODO: This should be optional according to https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-offer-parameters + - credential_configuration_ids properties: credential_issuer: type: string example: "https://issuer.example" - credentials: + credential_configuration_ids: type: array + description: | + Array of credential configuration IDs that reference entries in the issuer's credential_configurations_supported metadata. + items: + type: string grants: type: object + description: | + Grant types the issuer offers for this credential. Currently only pre-authorized code is supported. + properties: + "urn:ietf:params:oauth:grant-type:pre-authorized_code": + type: object + required: + - pre-authorized_code + properties: + pre-authorized_code: + type: string + description: The pre-authorized code for the credential offer. example: { "credential_issuer": "https://issuer.example", - "credentials": [ - { - "format": "ldp_vc", - "credential_definition": { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://nuts.nl/credentials/v1" - ], - "type": [ - "VerifiableCredential", - "NutsAuthorizationCredential" - ] - } - } + "credential_configuration_ids": [ + "NutsAuthorizationCredential_ldp_vc" ], "grants": { "urn:ietf:params:oauth:grant-type:pre-authorized_code": { @@ -519,6 +544,18 @@ components: } } } + NonceResponse: + type: object + description: | + Response from the Nonce Endpoint per OpenID4VCI v1.0 Section 7. + required: + - c_nonce + properties: + c_nonce: + type: string + description: | + A fresh nonce value to be used in the proof of possession. + example: "wKI4LT17ac15ES9bw8ac4" CredentialOfferResponse: type: object description: | diff --git a/vcr/api/openid4vci/v0/api.go b/vcr/api/openid4vci/v0/api.go index 0297535816..94da1cdbd6 100644 --- a/vcr/api/openid4vci/v0/api.go +++ b/vcr/api/openid4vci/v0/api.go @@ -54,6 +54,9 @@ type CredentialResponse = openid4vci.CredentialResponse // OAuth2ClientMetadata is the metadata of the OAuth2 client type OAuth2ClientMetadata = openid4vci.OAuth2ClientMetadata +// NonceResponse is the response of the Nonce Endpoint +type NonceResponse = openid4vci.NonceResponse + type ErrorResponse = openid4vci.Error var _ core.ErrorWriter = (*protocolErrorWriter)(nil) diff --git a/vcr/api/openid4vci/v0/generated.go b/vcr/api/openid4vci/v0/generated.go index 19426c73b4..f70faf2669 100644 --- a/vcr/api/openid4vci/v0/generated.go +++ b/vcr/api/openid4vci/v0/generated.go @@ -57,6 +57,9 @@ type ServerInterface interface { // Used by the issuer to offer credentials to the wallet // (GET /n2n/identity/{did}/openid4vci/credential_offer) HandleCredentialOffer(ctx echo.Context, did string, params HandleCredentialOfferParams) error + // Request a fresh c_nonce value + // (POST /n2n/identity/{did}/openid4vci/nonce) + RequestNonce(ctx echo.Context, did string) error // Used by the wallet to request an access token // (POST /n2n/identity/{did}/token) RequestAccessToken(ctx echo.Context, did string) error @@ -192,6 +195,22 @@ func (w *ServerInterfaceWrapper) HandleCredentialOffer(ctx echo.Context) error { return err } +// RequestNonce converts echo context to params. +func (w *ServerInterfaceWrapper) RequestNonce(ctx echo.Context) error { + var err error + // ------------- Path parameter "did" ------------- + var did string + + err = runtime.BindStyledParameterWithOptions("simple", "did", ctx.Param("did"), &did, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter did: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.RequestNonce(ctx, did) + return err +} + // RequestAccessToken converts echo context to params. func (w *ServerInterfaceWrapper) RequestAccessToken(ctx echo.Context) error { var err error @@ -242,6 +261,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/n2n/identity/:did/.well-known/openid-credential-wallet", wrapper.GetOAuth2ClientMetadata) router.POST(baseURL+"/n2n/identity/:did/openid4vci/credential", wrapper.RequestCredential) router.GET(baseURL+"/n2n/identity/:did/openid4vci/credential_offer", wrapper.HandleCredentialOffer) + router.POST(baseURL+"/n2n/identity/:did/openid4vci/nonce", wrapper.RequestNonce) router.POST(baseURL+"/n2n/identity/:did/token", wrapper.RequestAccessToken) } @@ -385,15 +405,6 @@ func (response RequestCredential401JSONResponse) VisitRequestCredentialResponse( return json.NewEncoder(w).Encode(response) } -type RequestCredential403JSONResponse ErrorResponse - -func (response RequestCredential403JSONResponse) VisitRequestCredentialResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(403) - - return json.NewEncoder(w).Encode(response) -} - type RequestCredential404JSONResponse ErrorResponse func (response RequestCredential404JSONResponse) VisitRequestCredentialResponse(w http.ResponseWriter) error { @@ -439,6 +450,40 @@ func (response HandleCredentialOffer404JSONResponse) VisitHandleCredentialOfferR return json.NewEncoder(w).Encode(response) } +type RequestNonceRequestObject struct { + Did string `json:"did"` +} + +type RequestNonceResponseObject interface { + VisitRequestNonceResponse(w http.ResponseWriter) error +} + +type RequestNonce200ResponseHeaders struct { + CacheControl string +} + +type RequestNonce200JSONResponse struct { + Body NonceResponse + Headers RequestNonce200ResponseHeaders +} + +func (response RequestNonce200JSONResponse) VisitRequestNonceResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", fmt.Sprint(response.Headers.CacheControl)) + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response.Body) +} + +type RequestNonce404JSONResponse ErrorResponse + +func (response RequestNonce404JSONResponse) VisitRequestNonceResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + type RequestAccessTokenRequestObject struct { Did string `json:"did"` Body *RequestAccessTokenFormdataRequestBody @@ -495,6 +540,9 @@ type StrictServerInterface interface { // Used by the issuer to offer credentials to the wallet // (GET /n2n/identity/{did}/openid4vci/credential_offer) HandleCredentialOffer(ctx context.Context, request HandleCredentialOfferRequestObject) (HandleCredentialOfferResponseObject, error) + // Request a fresh c_nonce value + // (POST /n2n/identity/{did}/openid4vci/nonce) + RequestNonce(ctx context.Context, request RequestNonceRequestObject) (RequestNonceResponseObject, error) // Used by the wallet to request an access token // (POST /n2n/identity/{did}/token) RequestAccessToken(ctx context.Context, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) @@ -670,6 +718,31 @@ func (sh *strictHandler) HandleCredentialOffer(ctx echo.Context, did string, par return nil } +// RequestNonce operation middleware +func (sh *strictHandler) RequestNonce(ctx echo.Context, did string) error { + var request RequestNonceRequestObject + + request.Did = did + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.RequestNonce(ctx.Request().Context(), request.(RequestNonceRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "RequestNonce") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(RequestNonceResponseObject); ok { + return validResponse.VisitRequestNonceResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // RequestAccessToken operation middleware func (sh *strictHandler) RequestAccessToken(ctx echo.Context, did string) error { var request RequestAccessTokenRequestObject diff --git a/vcr/api/openid4vci/v0/holder_test.go b/vcr/api/openid4vci/v0/holder_test.go index 1601839982..557aa60f2a 100644 --- a/vcr/api/openid4vci/v0/holder_test.go +++ b/vcr/api/openid4vci/v0/holder_test.go @@ -21,9 +21,7 @@ package v0 import ( "context" "encoding/json" - ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" - "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" @@ -88,19 +86,11 @@ func TestWrapper_HandleCredentialOffer(t *testing.T) { api := Wrapper{VCR: service, VDR: vdr} credentialOffer := openid4vci.CredentialOffer{ - CredentialIssuer: issuerDID.String(), - Credentials: []openid4vci.OfferedCredential{ - { - Format: vc.JSONLDCredentialProofFormat, - CredentialDefinition: &openid4vci.CredentialDefinition{ - Context: []ssi.URI{ssi.MustParseURI("a"), ssi.MustParseURI("b")}, - Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("HumanCredential")}, - }, - }, - }, - Grants: map[string]interface{}{ - "urn:ietf:params:oauth:grant-type:pre-authorized_code": map[string]interface{}{ - "pre-authorized_code": "code", + CredentialIssuer: issuerDID.String(), + CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, + Grants: &openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: "code", }, }, } diff --git a/vcr/api/openid4vci/v0/issuer.go b/vcr/api/openid4vci/v0/issuer.go index 19d5325a0c..0e501a92e9 100644 --- a/vcr/api/openid4vci/v0/issuer.go +++ b/vcr/api/openid4vci/v0/issuer.go @@ -20,11 +20,8 @@ package v0 import ( "context" - "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/go-did/vc" - "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "net/http" @@ -102,15 +99,25 @@ func (w Wrapper) RequestCredential(ctx context.Context, request RequestCredentia return nil, err } credentialJSON, _ := credential.MarshalJSON() - credentialMap := make(map[string]interface{}) - err = json.Unmarshal(credentialJSON, &credentialMap) + return RequestCredential200JSONResponse(CredentialResponse{ + Credentials: []openid4vci.CredentialResponseEntry{{Credential: credentialJSON}}, + }), nil +} + +// RequestNonce handles a request to the Nonce Endpoint. +func (w Wrapper) RequestNonce(ctx context.Context, request RequestNonceRequestObject) (RequestNonceResponseObject, error) { + issuerHandler, err := w.getIssuerHandler(ctx, request.Did) if err != nil { return nil, err } - return RequestCredential200JSONResponse(CredentialResponse{ - Credential: &credentialMap, - Format: vc.JSONLDCredentialProofFormat, - }), nil + nonce, err := issuerHandler.HandleNonceRequest(ctx) + if err != nil { + return nil, err + } + return RequestNonce200JSONResponse{ + Body: NonceResponse{CNonce: nonce}, + Headers: RequestNonce200ResponseHeaders{CacheControl: "no-store"}, + }, nil } // RequestAccessToken requests an OAuth2 access token from the given DID. @@ -127,14 +134,14 @@ func (w Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTo StatusCode: http.StatusBadRequest, } } - accessToken, cNonce, err := issuerHandler.HandleAccessTokenRequest(ctx, request.Body.PreAuthorizedCode) + accessToken, err := issuerHandler.HandleAccessTokenRequest(ctx, request.Body.PreAuthorizedCode) if err != nil { return nil, err } expiresIn := int(issuer.TokenTTL.Seconds()) - return RequestAccessToken200JSONResponse(*(&TokenResponse{ + return RequestAccessToken200JSONResponse(TokenResponse{ AccessToken: accessToken, ExpiresIn: &expiresIn, TokenType: "bearer", - }).With(oauth.CNonceParam, cNonce)), nil + }), nil } diff --git a/vcr/api/openid4vci/v0/issuer_test.go b/vcr/api/openid4vci/v0/issuer_test.go index e1ee52f15d..3b82fda1ce 100644 --- a/vcr/api/openid4vci/v0/issuer_test.go +++ b/vcr/api/openid4vci/v0/issuer_test.go @@ -22,7 +22,6 @@ import ( "context" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" - oauth2 "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" @@ -109,7 +108,7 @@ func TestWrapper_RequestAccessToken(t *testing.T) { t.Run("ok", func(t *testing.T) { ctrl := gomock.NewController(t) oidcIssuer := issuer.NewMockOpenIDHandler(ctrl) - oidcIssuer.EXPECT().HandleAccessTokenRequest(gomock.Any(), "code").Return("access-token", "c_nonce", nil) + oidcIssuer.EXPECT().HandleAccessTokenRequest(gomock.Any(), "code").Return("access-token", nil) documentOwner := didsubject.NewMockDocumentOwner(ctrl) documentOwner.EXPECT().IsOwner(gomock.Any(), gomock.Any()).Return(true, nil) vdr := vdr.NewMockVDR(ctrl) @@ -127,7 +126,6 @@ func TestWrapper_RequestAccessToken(t *testing.T) { require.NoError(t, err) assert.Equal(t, "access-token", response.(RequestAccessToken200JSONResponse).AccessToken) - assert.Equal(t, "c_nonce", oauth2.TokenResponse(response.(RequestAccessToken200JSONResponse)).Get("c_nonce")) }) t.Run("unknown tenant", func(t *testing.T) { ctrl := gomock.NewController(t) @@ -169,6 +167,40 @@ func TestWrapper_RequestAccessToken(t *testing.T) { }) } +func TestWrapper_RequestNonce(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctrl := gomock.NewController(t) + oidcIssuer := issuer.NewMockOpenIDHandler(ctrl) + oidcIssuer.EXPECT().HandleNonceRequest(gomock.Any()).Return("test-nonce-value", nil) + documentOwner := didsubject.NewMockDocumentOwner(ctrl) + documentOwner.EXPECT().IsOwner(gomock.Any(), gomock.Any()).Return(true, nil) + vdr := vdr.NewMockVDR(ctrl) + vdr.EXPECT().DocumentOwner().Return(documentOwner).AnyTimes() + service := vcr.NewMockVCR(ctrl) + service.EXPECT().GetOpenIDIssuer(gomock.Any(), issuerDID).Return(oidcIssuer, nil) + api := Wrapper{VCR: service, VDR: vdr} + + response, err := api.RequestNonce(context.Background(), RequestNonceRequestObject{Did: issuerDID.String()}) + + require.NoError(t, err) + jsonResponse := response.(RequestNonce200JSONResponse) + assert.Equal(t, "test-nonce-value", jsonResponse.Body.CNonce) + assert.Equal(t, "no-store", jsonResponse.Headers.CacheControl) + }) + t.Run("unknown tenant", func(t *testing.T) { + ctrl := gomock.NewController(t) + documentOwner := didsubject.NewMockDocumentOwner(ctrl) + documentOwner.EXPECT().IsOwner(gomock.Any(), gomock.Any()).Return(false, nil) + vdr := vdr.NewMockVDR(ctrl) + vdr.EXPECT().DocumentOwner().Return(documentOwner).AnyTimes() + api := Wrapper{VDR: vdr} + + _, err := api.RequestNonce(context.Background(), RequestNonceRequestObject{Did: issuerDID.String()}) + + require.EqualError(t, err, "invalid_request - DID is not owned by this node") + }) +} + func TestWrapper_RequestCredential(t *testing.T) { t.Run("ok", func(t *testing.T) { ctrl := gomock.NewController(t) @@ -189,14 +221,12 @@ func TestWrapper_RequestCredential(t *testing.T) { Authorization: &authz, }, Body: &RequestCredentialJSONRequestBody{ - Format: "ldp_vc", - CredentialDefinition: &openid4vci.CredentialDefinition{}, - Proof: nil, + CredentialConfigurationID: "NutsOrganizationCredential_ldp_vc", }, }) require.NoError(t, err) - assert.NotNil(t, response.(RequestCredential200JSONResponse).Credential) + assert.NotEmpty(t, response.(RequestCredential200JSONResponse).Credentials) }) t.Run("unknown tenant", func(t *testing.T) { ctrl := gomock.NewController(t) diff --git a/vcr/holder/openid.go b/vcr/holder/openid.go index fd975152d5..3ae8b5a327 100644 --- a/vcr/holder/openid.go +++ b/vcr/holder/openid.go @@ -22,13 +22,14 @@ import ( "context" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/auth/oauth" "net/http" "time" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/vcr/log" @@ -82,27 +83,12 @@ func (h *openidHandler) Metadata() openid4vci.OAuth2ClientMetadata { // Error responses on the Credential Offer Endpoint are not defined in the OpenID4VCI spec, // so these are inferred of whatever makes sense. func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4vci.CredentialOffer) error { - // TODO: This check is too simplistic, there can be multiple credential offers, - // but the issuer should only request the one it's interested in. + // TODO: This check is too simplistic, there can be multiple credential_configuration_ids, + // but we only support one at a time. // See https://github.com/nuts-foundation/nuts-node/issues/2049 - if len(offer.Credentials) != 1 { + if len(offer.CredentialConfigurationIDs) != 1 { return openid4vci.Error{ - Err: errors.New("there must be exactly 1 credential in credential offer"), - Code: openid4vci.InvalidRequest, - StatusCode: http.StatusBadRequest, - } - } - offeredCredential := offer.Credentials[0] - if offeredCredential.Format != vc.JSONLDCredentialProofFormat { - return openid4vci.Error{ - Err: fmt.Errorf("credential offer: unsupported format '%s'", offeredCredential.Format), - Code: openid4vci.UnsupportedCredentialType, - StatusCode: http.StatusBadRequest, - } - } - if err := offeredCredential.CredentialDefinition.Validate(true); err != nil { - return openid4vci.Error{ - Err: fmt.Errorf("credential offer: %w", err), + Err: errors.New("there must be exactly 1 credential_configuration_id in credential offer"), Code: openid4vci.InvalidRequest, StatusCode: http.StatusBadRequest, } @@ -126,13 +112,38 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 } } + // Resolve the credential configuration from the issuer metadata + credentialConfigID := offer.CredentialConfigurationIDs[0] + offeredCredential, err := h.resolveCredentialConfiguration(issuerClient.Metadata(), credentialConfigID) + if err != nil { + return openid4vci.Error{ + Err: fmt.Errorf("unable to resolve credential configuration: %w", err), + Code: openid4vci.InvalidRequest, + StatusCode: http.StatusBadRequest, + } + } + if offeredCredential.Format != vc.JSONLDCredentialProofFormat { + return openid4vci.Error{ + Err: fmt.Errorf("credential offer: unsupported format '%s'", offeredCredential.Format), + Code: openid4vci.InvalidRequest, + StatusCode: http.StatusBadRequest, + } + } + if err := offeredCredential.CredentialDefinition.Validate(false); err != nil { + return openid4vci.Error{ + Err: fmt.Errorf("credential offer: %w", err), + Code: openid4vci.InvalidRequest, + StatusCode: http.StatusBadRequest, + } + } + accessTokenResponse, err := issuerClient.RequestAccessToken(openid4vci.PreAuthorizedCodeGrant, map[string]string{ "pre-authorized_code": preAuthorizedCode, }) if err != nil { return openid4vci.Error{ Err: fmt.Errorf("unable to request access token: %w", err), - Code: openid4vci.InvalidToken, + Code: openid4vci.ServerError, StatusCode: http.StatusInternalServerError, } } @@ -140,21 +151,13 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 if accessTokenResponse.AccessToken == "" { return openid4vci.Error{ Err: errors.New("access_token is missing"), - Code: openid4vci.InvalidToken, - StatusCode: http.StatusInternalServerError, - } - } - - if accessTokenResponse.Get(oauth.CNonceParam) == "" { - return openid4vci.Error{ - Err: fmt.Errorf("%s is missing", oauth.CNonceParam), - Code: openid4vci.InvalidToken, + Code: openid4vci.ServerError, StatusCode: http.StatusInternalServerError, } } retrieveCtx := audit.Context(ctx, "app-openid4vci", "VCR/OpenID4VCI", "RetrieveCredential") - credential, err := h.retrieveCredential(retrieveCtx, issuerClient, offeredCredential.CredentialDefinition, accessTokenResponse) + credential, err := h.retrieveCredential(retrieveCtx, issuerClient, credentialConfigID, accessTokenResponse) if err != nil { return openid4vci.Error{ Err: fmt.Errorf("unable to retrieve credential: %w", err), @@ -180,44 +183,121 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 } func getPreAuthorizedCodeFromOffer(offer openid4vci.CredentialOffer) string { - params, ok := offer.Grants[openid4vci.PreAuthorizedCodeGrant].(map[string]interface{}) - if !ok { + if offer.Grants == nil || offer.Grants.PreAuthorizedCode == nil { return "" } - preAuthorizedCode, ok := params["pre-authorized_code"].(string) + return offer.Grants.PreAuthorizedCode.PreAuthorizedCode +} + +// resolveCredentialConfiguration resolves a credential_configuration_id to an OfferedCredential +// by looking it up in the issuer metadata. +func (h *openidHandler) resolveCredentialConfiguration(metadata openid4vci.CredentialIssuerMetadata, configID string) (*openid4vci.OfferedCredential, error) { + config, ok := metadata.CredentialConfigurationsSupported[configID] if !ok { - return "" + return nil, fmt.Errorf("credential_configuration_id '%s' not found in issuer metadata", configID) + } + + format, ok := config["format"].(string) + if !ok || format == "" { + return nil, fmt.Errorf("credential configuration '%s' is missing 'format' field", configID) + } + credDefMap, _ := config["credential_definition"].(map[string]interface{}) + + var credentialDef *openid4vci.CredentialDefinition + if credDefMap != nil { + credentialDef = &openid4vci.CredentialDefinition{} + + // Parse @context + if contextRaw, ok := credDefMap["@context"].([]interface{}); ok { + for _, c := range contextRaw { + cStr, ok := c.(string) + if !ok { + return nil, fmt.Errorf("invalid @context entry: expected string, got %T", c) + } + u, err := ssi.ParseURI(cStr) + if err != nil { + return nil, fmt.Errorf("invalid @context URI %q: %w", cStr, err) + } + credentialDef.Context = append(credentialDef.Context, *u) + } + } + + // Parse type + if typeRaw, ok := credDefMap["type"].([]interface{}); ok { + for _, t := range typeRaw { + tStr, ok := t.(string) + if !ok { + return nil, fmt.Errorf("invalid type entry: expected string, got %T", t) + } + u, err := ssi.ParseURI(tStr) + if err != nil { + return nil, fmt.Errorf("invalid type URI %q: %w", tStr, err) + } + credentialDef.Type = append(credentialDef.Type, *u) + } + } + + // Parse credentialSubject (optional in v1.0 metadata) + if credSubject, ok := credDefMap["credentialSubject"].(map[string]interface{}); ok { + credentialDef.CredentialSubject = credSubject + } } - return preAuthorizedCode + + return &openid4vci.OfferedCredential{ + Format: format, + CredentialDefinition: credentialDef, + }, nil } -func (h *openidHandler) retrieveCredential(ctx context.Context, issuerClient openid4vci.IssuerAPIClient, offer *openid4vci.CredentialDefinition, tokenResponse *oauth.TokenResponse) (*vc.VerifiableCredential, error) { +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) if err != nil { return nil, 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": keyID, // 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{}{ - "aud": issuerClient.Metadata().CredentialIssuer, - "iat": nowFunc().Unix(), - "nonce": tokenResponse.Get(oauth.CNonceParam), - } - proof, err := h.signer.SignJWT(ctx, claims, headers, keyID) - if err != nil { - return nil, fmt.Errorf("unable to sign request proof: %w", err) - } + const maxAttempts = 2 + for attempt := range maxAttempts { + headers := map[string]interface{}{ + "typ": openid4vci.JWTTypeOpenID4VCIProof, + "kid": keyID, + } + claims := map[string]interface{}{ + "iss": h.did.String(), + "aud": issuerClient.Metadata().CredentialIssuer, + "iat": nowFunc().Unix(), + } + + // Per v1.0 Section 7, fetch nonce from Nonce Endpoint when advertised + if issuerClient.Metadata().NonceEndpoint != "" { + nonceResponse, nonceErr := issuerClient.RequestNonce(ctx) + if nonceErr != nil { + return nil, fmt.Errorf("unable to request nonce: %w", nonceErr) + } + claims["nonce"] = nonceResponse.CNonce + } - credentialRequest := openid4vci.CredentialRequest{ - CredentialDefinition: offer, - Format: vc.JSONLDCredentialProofFormat, - Proof: &openid4vci.CredentialRequestProof{ - Jwt: proof, - ProofType: "jwt", - }, + proof, signErr := h.signer.SignJWT(ctx, claims, headers, keyID) + if signErr != nil { + return nil, fmt.Errorf("unable to sign request proof: %w", signErr) + } + + credentialRequest := openid4vci.CredentialRequest{ + CredentialConfigurationID: credentialConfigID, + Proofs: &openid4vci.CredentialRequestProofs{ + Jwt: []string{proof}, + }, + } + credential, reqErr := issuerClient.RequestCredential(ctx, credentialRequest, tokenResponse.AccessToken) + if reqErr != nil { + // On invalid_nonce, fetch a fresh nonce and retry once (v1.0 Section 8.3.1.2) + var protocolErr openid4vci.Error + if attempt == 0 && errors.As(reqErr, &protocolErr) && protocolErr.Code == openid4vci.InvalidNonce { + log.Logger().Debug("Received invalid_nonce, retrying with fresh nonce") + continue + } + return nil, reqErr + } + return credential, nil } - return issuerClient.RequestCredential(ctx, credentialRequest, tokenResponse.AccessToken) + return nil, errors.New("credential request failed after nonce retry") } diff --git a/vcr/holder/openid_test.go b/vcr/holder/openid_test.go index a8a76ebe7c..c3e749bd99 100644 --- a/vcr/holder/openid_test.go +++ b/vcr/holder/openid_test.go @@ -21,6 +21,10 @@ package holder import ( "context" "errors" + "net/http" + "testing" + "time" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -34,9 +38,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "net/http" - "testing" - "time" ) var holderDID = did.MustParseDID("did:nuts:holder") @@ -59,40 +60,60 @@ func Test_wallet_Metadata(t *testing.T) { func Test_wallet_HandleCredentialOffer(t *testing.T) { credentialOffer := openid4vci.CredentialOffer{ - CredentialIssuer: issuerDID.String(), - Credentials: offeredCredential(), - Grants: map[string]interface{}{ - "some-other-grant": map[string]interface{}{}, - "urn:ietf:params:oauth:grant-type:pre-authorized_code": map[string]interface{}{ - "pre-authorized_code": "code", + CredentialIssuer: issuerDID.String(), + CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, + Grants: &openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: "code", }, }, } metadata := openid4vci.CredentialIssuerMetadata{ CredentialIssuer: issuerDID.String(), CredentialEndpoint: "credential-endpoint", + 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", + }, + }, + }, + }, } - nonce := "nonsens" t.Run("ok", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().Metadata().Return(metadata) - tokenResponse := (&oauth.TokenResponse{AccessToken: "access-token", TokenType: "bearer"}).With("c_nonce", nonce) + issuerAPIClient.EXPECT().Metadata().Return(metadata).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) - issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), "access-token"). + // Verify that the holder sends credential_configuration_id in the credential request + 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("http://example.org/credentials/V1")}, - Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("HumanCredential")}, + 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) jwtSigner.EXPECT().SignJWT(gomock.Any(), map[string]interface{}{ - "aud": issuerDID.String(), - "iat": int64(1735689600), - "nonce": nonce, + "iss": holderDID.String(), + "aud": issuerDID.String(), + "iat": int64(1735689600), }, gomock.Any(), "key-id").Return("signed-jwt", nil) keyResolver := resolver.NewMockKeyResolver(ctrl) keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", nil, nil) @@ -100,6 +121,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { nowFunc = func() time.Time { return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) } + t.Cleanup(func() { nowFunc = time.Now }) w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, credentialStore, jwtSigner, keyResolver).(*openidHandler) w.issuerClientCreator = func(_ context.Context, httpClient core.HTTPRequestDoer, credentialIssuerIdentifier string) (openid4vci.IssuerAPIClient, error) { @@ -115,26 +137,24 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { t.Run("pre-authorized code grant", func(t *testing.T) { w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) t.Run("no grants", func(t *testing.T) { - offer := openid4vci.CredentialOffer{Credentials: offeredCredential()} + offer := openid4vci.CredentialOffer{CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}} err := w.HandleCredentialOffer(audit.TestContext(), offer) require.EqualError(t, err, "invalid_grant - couldn't find (valid) pre-authorized code grant in credential offer") }) t.Run("no pre-authorized grant", func(t *testing.T) { offer := openid4vci.CredentialOffer{ - Credentials: offeredCredential(), - Grants: map[string]interface{}{ - "some-other-grant": nil, - }, + CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, + Grants: nil, } err := w.HandleCredentialOffer(audit.TestContext(), offer) require.EqualError(t, err, "invalid_grant - couldn't find (valid) pre-authorized code grant in credential offer") }) - t.Run("invalid pre-authorized grant", func(t *testing.T) { + t.Run("empty pre-authorized code", func(t *testing.T) { offer := openid4vci.CredentialOffer{ - Credentials: offeredCredential(), - Grants: map[string]interface{}{ - "urn:ietf:params:oauth:grant-type:pre-authorized_code": map[string]interface{}{ - "pre-authorized_code": nil, + CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, + Grants: &openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: "", }, }, } @@ -142,38 +162,67 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { require.EqualError(t, err, "invalid_grant - couldn't find (valid) pre-authorized code grant in credential offer") }) }) - t.Run("error - too many credentials in offer", func(t *testing.T) { + t.Run("error - too many credential_configuration_ids in offer", func(t *testing.T) { w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) offer := openid4vci.CredentialOffer{ - Credentials: []openid4vci.OfferedCredential{ - offeredCredential()[0], - offeredCredential()[0], - }, + CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc", "OtherCredential_ldp_vc"}, } err := w.HandleCredentialOffer(audit.TestContext(), offer).(openid4vci.Error) - assert.EqualError(t, err, "invalid_request - there must be exactly 1 credential in credential offer") + assert.EqualError(t, err, "invalid_request - there must be exactly 1 credential_configuration_id in credential offer") assert.Equal(t, http.StatusBadRequest, err.StatusCode) }) - t.Run("error - access token request fails", func(t *testing.T) { + t.Run("error - credential_configuration_id not found in metadata", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(nil, errors.New("request failed")) + emptyMetadata := openid4vci.CredentialIssuerMetadata{ + CredentialIssuer: issuerDID.String(), + CredentialEndpoint: "credential-endpoint", + CredentialConfigurationsSupported: map[string]map[string]interface{}{}, + } + issuerAPIClient.EXPECT().Metadata().Return(emptyMetadata).AnyTimes() w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) - w.issuerClientCreator = func(_ context.Context, httpClient core.HTTPRequestDoer, credentialIssuerIdentifier string) (openid4vci.IssuerAPIClient, error) { + w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { return issuerAPIClient, nil } err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) - require.EqualError(t, err, "invalid_token - unable to request access token: request failed") + require.ErrorContains(t, err, "credential_configuration_id 'ExampleCredential_ldp_vc' not found in issuer metadata") }) - t.Run("error - empty access token", func(t *testing.T) { + t.Run("error - credential configuration missing format", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{}, nil) + metadataNoFormat := openid4vci.CredentialIssuerMetadata{ + CredentialIssuer: issuerDID.String(), + CredentialEndpoint: "credential-endpoint", + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "ExampleCredential_ldp_vc": { + "credential_definition": map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1"}, + "type": []interface{}{"VerifiableCredential"}, + }, + }, + }, + } + issuerAPIClient.EXPECT().Metadata().Return(metadataNoFormat).AnyTimes() + + w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) + w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { + return issuerAPIClient, nil + } + + err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) + + require.ErrorContains(t, err, "credential configuration 'ExampleCredential_ldp_vc' is missing 'format' field") + }) + t.Run("error - access token request fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadata).AnyTimes() + issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(nil, errors.New("request failed")) w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) w.issuerClientCreator = func(_ context.Context, httpClient core.HTTPRequestDoer, credentialIssuerIdentifier string) (openid4vci.IssuerAPIClient, error) { @@ -182,12 +231,13 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) - require.EqualError(t, err, "invalid_token - access_token is missing") + require.EqualError(t, err, "server_error - unable to request access token: request failed") }) - t.Run("error - empty c_nonce", func(t *testing.T) { + t.Run("error - empty access token", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{AccessToken: "foo"}, nil) + issuerAPIClient.EXPECT().Metadata().Return(metadata).AnyTimes() + issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{}, nil) w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) w.issuerClientCreator = func(_ context.Context, httpClient core.HTTPRequestDoer, credentialIssuerIdentifier string) (openid4vci.IssuerAPIClient, error) { @@ -196,25 +246,25 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) - require.EqualError(t, err, "invalid_token - c_nonce is missing") + require.EqualError(t, err, "server_error - access_token is missing") }) - t.Run("error - no credentials in offer", func(t *testing.T) { + t.Run("error - no credential_configuration_ids in offer", func(t *testing.T) { w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{}).(openid4vci.Error) - assert.EqualError(t, err, "invalid_request - there must be exactly 1 credential in credential offer") + assert.EqualError(t, err, "invalid_request - there must be exactly 1 credential_configuration_id in credential offer") assert.Equal(t, http.StatusBadRequest, err.StatusCode) }) t.Run("error - can't issuer client (metadata can't be loaded)", func(t *testing.T) { w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{ - CredentialIssuer: "http://localhost:87632", - Credentials: offeredCredential(), - Grants: map[string]interface{}{ - "urn:ietf:params:oauth:grant-type:pre-authorized_code": map[string]interface{}{ - "pre-authorized_code": "foo", + CredentialIssuer: "http://localhost:87632", + CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, + Grants: &openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: "foo", }, }, }) @@ -226,8 +276,8 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { offer := offeredCredential()[0] ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().Metadata().Return(metadata) - issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return((&oauth.TokenResponse{AccessToken: "access-token"}).With("c_nonce", nonce), nil) + issuerAPIClient.EXPECT().Metadata().Return(metadata).AnyTimes() + issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{AccessToken: "access-token"}, nil) issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), gomock.Any()).Return(&vc.VerifiableCredential{ Context: offer.CredentialDefinition.Context, Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}, @@ -247,39 +297,289 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { require.EqualError(t, err, "invalid_request - received credential does not match offer: credential does not match credential_definition: type mismatch") }) t.Run("error - unsupported format", func(t *testing.T) { - w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) + ctrl := gomock.NewController(t) + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(openid4vci.CredentialIssuerMetadata{ + CredentialIssuer: issuerDID.String(), + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "TestCredential_unsupported": { + "format": "not supported", + }, + }, + }) + + w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) + w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { + return issuerAPIClient, nil + } err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{ - Credentials: []openid4vci.OfferedCredential{{Format: "not supported"}}, + CredentialConfigurationIDs: []string{"TestCredential_unsupported"}, + Grants: &openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: "foo", + }, + }, }).(openid4vci.Error) - assert.EqualError(t, err, "unsupported_credential_type - credential offer: unsupported format 'not supported'") + assert.EqualError(t, err, "invalid_request - credential offer: unsupported format 'not supported'") assert.Equal(t, http.StatusBadRequest, err.StatusCode) }) - t.Run("error - credentialSubject not allowed in offer", func(t *testing.T) { - w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil) - credentials := offeredCredential() - credentials[0].CredentialDefinition.CredentialSubject = new(map[string]interface{}) + t.Run("credentialSubject in metadata does not block offer processing", func(t *testing.T) { + // v1.0 Appendix A.1.2: credentialSubject is allowed in metadata credential_configurations_supported + ctrl := gomock.NewController(t) + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + metadataWithSubject := openid4vci.CredentialIssuerMetadata{ + CredentialIssuer: issuerDID.String(), + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "TestCredential_ldp_vc": { + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1"}, + "type": []interface{}{"VerifiableCredential"}, + "credentialSubject": map[string]interface{}{}, + }, + }, + }, + } + issuerAPIClient.EXPECT().Metadata().Return(metadataWithSubject).AnyTimes() + issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{AccessToken: "access-token"}, nil) + issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), gomock.Any()).Return(&vc.VerifiableCredential{ + Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1")}, + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}, + Issuer: issuerDID.URI(), + }, nil) + jwtSigner := crypto.NewMockJWTSigner(ctrl) + jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-jwt", nil) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", nil, nil) + credentialStore := types.NewMockWriter(ctrl) + credentialStore.EXPECT().StoreCredential(gomock.Any(), nil).Return(nil) - err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{Credentials: credentials}).(openid4vci.Error) + 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 + } - assert.EqualError(t, err, "invalid_request - credential offer: invalid credential_definition: credentialSubject not allowed in offer") - assert.Equal(t, http.StatusBadRequest, err.StatusCode) + err := w.HandleCredentialOffer(audit.TestContext(), openid4vci.CredentialOffer{ + CredentialConfigurationIDs: []string{"TestCredential_ldp_vc"}, + Grants: &openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: "foo", + }, + }, + }) + + assert.NoError(t, err) + }) +} + +func Test_wallet_RetrieveCredentialWithNonceEndpoint(t *testing.T) { + credentialOffer := openid4vci.CredentialOffer{ + CredentialIssuer: issuerDID.String(), + CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, + Grants: &openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: "code", + }, + }, + } + nonce := "nonce-from-endpoint" + metadataWithNonce := 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", + }, + }, + }, + }, + } + + t.Run("uses Nonce Endpoint when advertised", func(t *testing.T) { + ctrl := gomock.NewController(t) + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadataWithNonce).AnyTimes() + 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, httpClient core.HTTPRequestDoer, credentialIssuerIdentifier 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) + }) + t.Run("retries on invalid_nonce", func(t *testing.T) { + ctrl := gomock.NewController(t) + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadataWithNonce).AnyTimes() + // First nonce request → used in first attempt (which fails with invalid_nonce) + // Second nonce request → used in retry (which succeeds) + first := issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(&openid4vci.NonceResponse{CNonce: "stale-nonce"}, nil) + issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(&openid4vci.NonceResponse{CNonce: nonce}, nil).After(first) + 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) + // First credential request (with stale nonce) fails with invalid_nonce + firstCredReq := openid4vci.CredentialRequest{ + CredentialConfigurationID: "ExampleCredential_ldp_vc", + Proofs: &openid4vci.CredentialRequestProofs{Jwt: []string{"signed-jwt-1"}}, + } + issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), firstCredReq, "access-token"). + Return(nil, openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: http.StatusBadRequest}) + // Retry with fresh nonce succeeds + retryCredReq := openid4vci.CredentialRequest{ + CredentialConfigurationID: "ExampleCredential_ldp_vc", + Proofs: &openid4vci.CredentialRequestProofs{Jwt: []string{"signed-jwt-2"}}, + } + issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), retryCredReq, "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 }) + // First attempt uses the stale nonce + firstSign := jwtSigner.EXPECT().SignJWT(gomock.Any(), map[string]interface{}{ + "iss": holderDID.String(), + "aud": issuerDID.String(), + "iat": int64(1767225600), + "nonce": "stale-nonce", + }, gomock.Any(), "key-id").Return("signed-jwt-1", nil) + // Retry uses the fresh nonce + 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-2", nil).After(firstSign) + 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) + }) + t.Run("error - invalid_nonce retry also fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadataWithNonce).AnyTimes() + first := issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(&openid4vci.NonceResponse{CNonce: "stale-nonce"}, nil) + issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(&openid4vci.NonceResponse{CNonce: "also-stale"}, nil).After(first) + 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) + // Both credential requests fail + issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), "access-token"). + Return(nil, openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: http.StatusBadRequest}) + issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), "access-token"). + Return(nil, openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: http.StatusBadRequest}) + + jwtSigner := crypto.NewMockJWTSigner(ctrl) + jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "key-id").Return("signed-jwt", nil).Times(2) + 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{}, nil, jwtSigner, 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: invalid_nonce") + }) + t.Run("error - nonce endpoint request fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadataWithNonce).AnyTimes() + issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(nil, errors.New("nonce request failed")) + issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{AccessToken: "access-token"}, nil) + jwtSigner := crypto.NewMockJWTSigner(ctrl) + 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{}, nil, jwtSigner, 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: unable to request nonce: nonce request failed") }) } -// offeredCredential returns a structure that can be used as CredentialOffer.Credentials, +// offeredCredential returns a resolved credential configuration for testing. func offeredCredential() []openid4vci.OfferedCredential { return []openid4vci.OfferedCredential{{ Format: vc.JSONLDCredentialProofFormat, CredentialDefinition: &openid4vci.CredentialDefinition{ Context: []ssi.URI{ ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), - ssi.MustParseURI("http://example.org/credentials/V1"), + ssi.MustParseURI("https://example.com/credentials/v1"), }, Type: []ssi.URI{ ssi.MustParseURI("VerifiableCredential"), - ssi.MustParseURI("HumanCredential"), + ssi.MustParseURI("ExampleCredential"), }, }, }} diff --git a/vcr/issuer/assets/definitions/NutsAuthorizationCredential.json b/vcr/issuer/assets/definitions/NutsAuthorizationCredential.json index 5ffa686f32..735330e19a 100644 --- a/vcr/issuer/assets/definitions/NutsAuthorizationCredential.json +++ b/vcr/issuer/assets/definitions/NutsAuthorizationCredential.json @@ -1,12 +1,17 @@ { "format": "ldp_vc", + "proof_types_supported": { + "jwt": { + "proof_signing_alg_values_supported": ["ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"] + } + }, "cryptographic_binding_methods_supported": [ "did:nuts" ], "credential_definition": { "@context": [ "https://www.w3.org/2018/credentials/v1", - "https://www.nuts.nl/credentials/v1" + "https://nuts.nl/credentials/v1" ], "type": [ "VerifiableCredential", diff --git a/vcr/issuer/assets/definitions/NutsOrganizationCredential.json b/vcr/issuer/assets/definitions/NutsOrganizationCredential.json index f2482124bc..6bec97eaad 100644 --- a/vcr/issuer/assets/definitions/NutsOrganizationCredential.json +++ b/vcr/issuer/assets/definitions/NutsOrganizationCredential.json @@ -1,12 +1,17 @@ { "format": "ldp_vc", + "proof_types_supported": { + "jwt": { + "proof_signing_alg_values_supported": ["ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"] + } + }, "cryptographic_binding_methods_supported": [ "did:nuts" ], "credential_definition": { "@context": [ "https://www.w3.org/2018/credentials/v1", - "https://www.nuts.nl/credentials/v1" + "https://nuts.nl/credentials/v1" ], "type": [ "VerifiableCredential", diff --git a/vcr/issuer/openid.go b/vcr/issuer/openid.go index 423cb0cf03..8dc6935313 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -73,21 +73,23 @@ const TokenTTL = 15 * time.Minute const preAuthCodeRefType = "preauthcode" const accessTokenRefType = "accesstoken" -const cNonceRefType = "c_nonce" // OpenIDHandler defines the interface for handling OpenID4VCI issuer operations. type OpenIDHandler interface { // ProviderMetadata returns the OpenID Connect provider metadata. ProviderMetadata() openid4vci.ProviderMetadata // HandleAccessTokenRequest handles an OAuth2 access token request for the given issuer and pre-authorized code. - // It returns the access token and a c_nonce. - HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, string, error) + // It returns the access token. + HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, error) // Metadata returns the OpenID4VCI credential issuer metadata for the given issuer. Metadata() openid4vci.CredentialIssuerMetadata // OfferCredential sends a credential offer to the specified wallet. It derives the issuer from the credential. OfferCredential(ctx context.Context, credential vc.VerifiableCredential, walletIdentifier string) error // HandleCredentialRequest requests a credential from the given issuer. HandleCredentialRequest(ctx context.Context, request openid4vci.CredentialRequest, accessToken string) (*vc.VerifiableCredential, error) + // HandleNonceRequest handles a request to the Nonce Endpoint (v1.0 Section 7). + // It generates a standalone nonce and returns it. + HandleNonceRequest(ctx context.Context) (string, error) } // NewOpenIDHandler creates a new OpenIDHandler instance. The identifier is the Credential Issuer Identifier, e.g. https://example.com/issuer/ @@ -107,24 +109,25 @@ func NewOpenIDHandler(issuerDID did.DID, issuerIdentifierURL string, definitions } type openidHandler struct { - issuerIdentifierURL string - issuerDID did.DID - definitionsDIR string - credentialsSupported []map[string]interface{} - keyResolver resolver.KeyResolver - store OpenIDStore - walletClientCreator func(ctx context.Context, httpClient core.HTTPRequestDoer, walletMetadataURL string) (openid4vci.WalletAPIClient, error) - httpClient core.HTTPRequestDoer + issuerIdentifierURL string + issuerDID did.DID + definitionsDIR string + credentialConfigurationsSupported map[string]map[string]interface{} + keyResolver resolver.KeyResolver + store OpenIDStore + walletClientCreator func(ctx context.Context, httpClient core.HTTPRequestDoer, walletMetadataURL string) (openid4vci.WalletAPIClient, error) + httpClient core.HTTPRequestDoer } func (i *openidHandler) Metadata() openid4vci.CredentialIssuerMetadata { metadata := openid4vci.CredentialIssuerMetadata{ CredentialIssuer: i.issuerIdentifierURL, CredentialEndpoint: core.JoinURLPaths(i.issuerIdentifierURL, "/openid4vci/credential"), + NonceEndpoint: core.JoinURLPaths(i.issuerIdentifierURL, "/openid4vci/nonce"), } - // deepcopy the i.credentialsSupported slice to prevent concurrent access to the slice. - metadata.CredentialsSupported = deepcopy(i.credentialsSupported) + // deepcopy the credentialConfigurationsSupported map to prevent concurrent access. + metadata.CredentialConfigurationsSupported = deepcopyMap(i.credentialConfigurationsSupported) return metadata } @@ -140,20 +143,20 @@ func (i *openidHandler) ProviderMetadata() openid4vci.ProviderMetadata { } } -func (i *openidHandler) HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, string, error) { +func (i *openidHandler) HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, error) { flow, err := i.store.FindByReference(ctx, preAuthCodeRefType, preAuthorizedCode) if err != nil { - return "", "", err + return "", err } if flow == nil { - return "", "", openid4vci.Error{ + return "", openid4vci.Error{ Err: errors.New("unknown pre-authorized code"), Code: openid4vci.InvalidGrant, StatusCode: http.StatusBadRequest, } } if flow.IssuerID != i.issuerDID.String() { - return "", "", openid4vci.Error{ + return "", openid4vci.Error{ Err: errors.New("pre-authorized code not issued by this issuer"), Code: openid4vci.InvalidGrant, StatusCode: http.StatusBadRequest, @@ -162,12 +165,7 @@ func (i *openidHandler) HandleAccessTokenRequest(ctx context.Context, preAuthori accessToken := crypto.GenerateNonce() err = i.store.StoreReference(ctx, flow.ID, accessTokenRefType, accessToken) if err != nil { - return "", "", err - } - cNonce := crypto.GenerateNonce() - err = i.store.StoreReference(ctx, flow.ID, cNonceRefType, cNonce) - if err != nil { - return "", "", err + return "", err } // PreAuthorizedCode is to be used just once @@ -179,7 +177,7 @@ func (i *openidHandler) HandleAccessTokenRequest(ctx context.Context, preAuthori // Just log it, nothing will break (since they'll be pruned after ttl anyway). log.Logger().WithError(err).Error("Failed to delete pre-authorized code") } - return accessToken, cNonce, nil + return accessToken, nil } func (i *openidHandler) OfferCredential(ctx context.Context, credential vc.VerifiableCredential, walletIdentifier string) error { @@ -207,20 +205,16 @@ func (i *openidHandler) OfferCredential(ctx context.Context, credential vc.Verif } func (i *openidHandler) HandleCredentialRequest(ctx context.Context, request openid4vci.CredentialRequest, accessToken string) (*vc.VerifiableCredential, error) { - if request.Format != vc.JSONLDCredentialProofFormat { - return nil, openid4vci.Error{ - Err: fmt.Errorf("credential request: unsupported format '%s'", request.Format), - Code: openid4vci.UnsupportedCredentialType, - StatusCode: http.StatusBadRequest, - } - } - if err := request.CredentialDefinition.Validate(false); err != nil { + // v1.0 Section 8.2 requires credential_configuration_id or credential_identifier (mutually exclusive). + // This implementation only accepts credential_configuration_id as a policy choice. + if request.CredentialConfigurationID == "" { return nil, openid4vci.Error{ - Err: fmt.Errorf("credential request: %w", err), - Code: openid4vci.InvalidRequest, + Err: errors.New("credential request must contain credential_configuration_id"), + Code: openid4vci.InvalidCredentialRequest, StatusCode: http.StatusBadRequest, } } + flow, err := i.store.FindByReference(ctx, accessTokenRefType, accessToken) if err != nil { return nil, err @@ -230,34 +224,42 @@ func (i *openidHandler) HandleCredentialRequest(ctx context.Context, request ope return nil, openid4vci.Error{ Err: errors.New("unknown access token"), Code: openid4vci.InvalidToken, - StatusCode: http.StatusBadRequest, + StatusCode: http.StatusUnauthorized, } } credential := flow.Credentials[0] // there's always just one (at least for now) subjectDID, _ := credential.SubjectDID() - // check credential.Issuer against given issuer if credential.Issuer.String() != i.issuerDID.String() { return nil, openid4vci.Error{ Err: errors.New("credential issuer does not match given issuer"), - Code: openid4vci.InvalidRequest, + Code: openid4vci.InvalidCredentialRequest, StatusCode: http.StatusBadRequest, } } - if err = i.validateProof(ctx, flow, request); err != nil { - return nil, err + // Validate the credential_configuration_id matches what was offered + expectedConfigID, err := i.findCredentialConfigID(credential) + if err != nil { + return nil, openid4vci.Error{ + Err: fmt.Errorf("credential has no matching configuration: %w", err), + Code: openid4vci.UnknownCredentialConfiguration, + StatusCode: http.StatusBadRequest, + } } - - if err = openid4vci.ValidateDefinitionWithCredential(credential, *request.CredentialDefinition); err != nil { + if request.CredentialConfigurationID != expectedConfigID { return nil, openid4vci.Error{ - Err: fmt.Errorf("requested credential does not match offer: %w", err), - Code: openid4vci.InvalidRequest, + Err: fmt.Errorf("credential_configuration_id '%s' does not match offered '%s'", request.CredentialConfigurationID, expectedConfigID), + Code: openid4vci.UnknownCredentialConfiguration, StatusCode: http.StatusBadRequest, } } + if err = i.validateProof(ctx, flow, request); err != nil { + return nil, err + } + // Important: since we (for now) create the VC even before the wallet requests it, we don't know if every VC is actually retrieved by the wallet. // This is a temporary shortcut, since changing that requires a lot of refactoring. // To make actually retrieved VC traceable, we log it to the audit log. @@ -270,59 +272,69 @@ func (i *openidHandler) HandleCredentialRequest(ctx context.Context, request ope return &credential, nil } +func (i *openidHandler) HandleNonceRequest(ctx context.Context) (string, error) { + nonce := crypto.GenerateNonce() + if err := i.store.StoreNonce(ctx, nonce); err != nil { + return "", err + } + return nonce, nil +} + // validateProof validates the proof of the credential request. Aside from checks as specified by the spec, // it verifies the proof signature, and whether the signer is the intended wallet. +// The validation is metadata-driven: proof is only required if the credential configuration +// includes proof_types_supported. Nonce is only required if the issuer advertises a nonce_endpoint. // See https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-proof-types func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request openid4vci.CredentialRequest) error { - credential := flow.Credentials[0] // there's always just one (at least for now) - wallet, _ := credential.SubjectDID() - - // augment invalid_proof errors according to §7.3.2 of openid4vci spec - generateProofError := func(err openid4vci.Error) error { - cnonce := crypto.GenerateNonce() - if err := i.store.StoreReference(ctx, flow.ID, cNonceRefType, cnonce); err != nil { - return err + // Check if the credential configuration requires proof + credConfig, ok := i.credentialConfigurationsSupported[request.CredentialConfigurationID] + if ok { + if _, hasProofTypes := credConfig["proof_types_supported"]; !hasProofTypes { + return nil // no proof required for this credential configuration } - expiry := int(TokenTTL.Seconds()) - err.CNonce = &cnonce - err.CNonceExpiresIn = &expiry - return err } - if request.Proof == nil { - return generateProofError(openid4vci.Error{ - Err: errors.New("missing proof"), - Code: openid4vci.InvalidProof, - StatusCode: http.StatusBadRequest, - }) - } - if request.Proof.ProofType != openid4vci.ProofTypeJWT { - return generateProofError(openid4vci.Error{ - Err: errors.New("proof type not supported"), + credential := flow.Credentials[0] // there's always just one (at least for now) + wallet, _ := credential.SubjectDID() + + if request.Proofs == nil || len(request.Proofs.Jwt) == 0 { + return openid4vci.Error{ + Err: errors.New("missing proofs"), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - }) + } } + // We only support single proof for now + proofJWT := request.Proofs.Jwt[0] var signingKeyID string - token, err := crypto.ParseJWT(request.Proof.Jwt, func(kid string) (crypt.PublicKey, error) { + token, err := crypto.ParseJWT(proofJWT, func(kid string) (crypt.PublicKey, error) { signingKeyID = kid return i.keyResolver.ResolveKeyByID(kid, nil, resolver.NutsSigningKeyType) }, jwt.WithAcceptableSkew(5*time.Second)) if err != nil { - return generateProofError(openid4vci.Error{ + return openid4vci.Error{ Err: err, Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - }) + } + } + + // Validate iss claim matches the expected wallet DID (v1.0 Appendix F.1) + if token.Issuer() != wallet.String() { + return openid4vci.Error{ + Err: fmt.Errorf("proof iss claim does not match expected wallet: %s", token.Issuer()), + Code: openid4vci.InvalidProof, + StatusCode: http.StatusBadRequest, + } } // Proof must be signed by wallet to which it was offered (proof signer == offer receiver) if signerDID, err := resolver.GetDIDFromURL(signingKeyID); err != nil || signerDID.String() != wallet.String() { - return generateProofError(openid4vci.Error{ + return openid4vci.Error{ Err: fmt.Errorf("credential offer was signed by other DID than intended wallet: %s", signingKeyID), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - }) + } } // Validate audience @@ -334,16 +346,16 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o } } if !audienceMatches { - return generateProofError(openid4vci.Error{ + return openid4vci.Error{ Err: fmt.Errorf("audience doesn't match credential issuer (aud=%s)", token.Audience()), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - }) + } } // Validate JWT type // jwt.Parse does not provide the JWS headers, we have to parse it again as JWS to access those - message, err := jws.ParseString(request.Proof.Jwt) + message, err := jws.ParseString(proofJWT) if err != nil { // Should not fail return err @@ -354,68 +366,70 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o } typ := message.Signatures()[0].ProtectedHeaders().Type() if typ == "" { - return generateProofError(openid4vci.Error{ + return openid4vci.Error{ Err: errors.New("missing typ header"), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - }) + } } if typ != openid4vci.JWTTypeOpenID4VCIProof { - return generateProofError(openid4vci.Error{ + return openid4vci.Error{ Err: fmt.Errorf("invalid typ claim (expected: %s): %s", openid4vci.JWTTypeOpenID4VCIProof, typ), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - }) + } + } + + // Nonce validation: only required if the issuer advertises a nonce_endpoint + metadata := i.Metadata() + if metadata.NonceEndpoint == "" { + return nil // no nonce required } // given the JWT typ, the nonce is in the 'nonce' claim nonce, ok := token.Get("nonce") if !ok { - return generateProofError(openid4vci.Error{ + return openid4vci.Error{ Err: errors.New("missing nonce claim"), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, - }) + } } - // check if the nonce matches the one we sent in the offer - flowFromNonce, err := i.store.FindByReference(ctx, cNonceRefType, nonce.(string)) - if err != nil { - return err - } - if flowFromNonce == nil { + nonceValue, ok := nonce.(string) + if !ok { return openid4vci.Error{ - Err: errors.New("unknown nonce"), + Err: errors.New("nonce claim is not a string"), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, } } - if flowFromNonce.ID != flow.ID { - return openid4vci.Error{ - Err: errors.New("nonce not valid for access token"), - Code: openid4vci.InvalidProof, - StatusCode: http.StatusBadRequest, - } + + // Validate nonce from Nonce Endpoint (v1.0 Section 7) + if i.store.ConsumeNonce(ctx, nonceValue) { + return nil } - return nil + return openid4vci.Error{ + Err: errors.New("invalid or expired nonce"), + Code: openid4vci.InvalidNonce, + StatusCode: http.StatusBadRequest, + } } func (i *openidHandler) createOffer(ctx context.Context, credential vc.VerifiableCredential, preAuthorizedCode string) (*openid4vci.CredentialOffer, error) { - grantParams := map[string]interface{}{ - "pre-authorized_code": preAuthorizedCode, + credentialConfigID, err := i.findCredentialConfigID(credential) + if err != nil { + return nil, fmt.Errorf("unable to create credential offer: %w", err) } + offer := openid4vci.CredentialOffer{ - CredentialIssuer: i.issuerIdentifierURL, - Credentials: []openid4vci.OfferedCredential{{ - Format: vc.JSONLDCredentialProofFormat, - CredentialDefinition: &openid4vci.CredentialDefinition{ - Context: credential.Context, - Type: credential.Type, + CredentialIssuer: i.issuerIdentifierURL, + CredentialConfigurationIDs: []string{credentialConfigID}, + Grants: &openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: preAuthorizedCode, }, - }}, - Grants: map[string]interface{}{ - openid4vci.PreAuthorizedCodeGrant: grantParams, }, } subjectDID, _ := credential.SubjectDID() // succeeded in previous step, can't fail @@ -427,12 +441,14 @@ func (i *openidHandler) createOffer(ctx context.Context, credential vc.Verifiabl Credentials: []vc.VerifiableCredential{credential}, Grants: []Grant{ { - Type: openid4vci.PreAuthorizedCodeGrant, - Params: grantParams, + Type: openid4vci.PreAuthorizedCodeGrant, + Params: map[string]interface{}{ + "pre-authorized_code": preAuthorizedCode, + }, }, }, } - err := i.store.Store(ctx, flow) + err = i.store.Store(ctx, flow) if err == nil { err = i.store.StoreReference(ctx, flow.ID, preAuthCodeRefType, preAuthorizedCode) } @@ -443,8 +459,20 @@ func (i *openidHandler) createOffer(ctx context.Context, credential vc.Verifiabl } func (i *openidHandler) loadCredentialDefinitions() error { + i.credentialConfigurationsSupported = make(map[string]map[string]interface{}) + + addDefinition := func(source string, definitionMap map[string]interface{}) error { + configID, err := generateCredentialConfigID(definitionMap) + if err != nil { + return fmt.Errorf("invalid credential definition from %s: %w", source, err) + } + if _, exists := i.credentialConfigurationsSupported[configID]; exists { + return fmt.Errorf("duplicate credential_configuration_id '%s' from %s", configID, source) + } + i.credentialConfigurationsSupported[configID] = definitionMap + return nil + } - // retrieve the definitions from assets and add to the list of CredentialsSupported definitionsDir, err := assets.FS.ReadDir("definitions") if err != nil { return err @@ -459,10 +487,11 @@ func (i *openidHandler) loadCredentialDefinitions() error { if err != nil { return err } - i.credentialsSupported = append(i.credentialsSupported, definitionMap) + if err := addDefinition("assets/"+definition.Name(), definitionMap); err != nil { + return err + } } - // now add all credential definition from config.DefinitionsDIR if i.definitionsDIR != "" { err = filepath.WalkDir(i.definitionsDIR, func(path string, d fs.DirEntry, err error) error { if err != nil { @@ -478,7 +507,9 @@ func (i *openidHandler) loadCredentialDefinitions() error { if err != nil { return fmt.Errorf("failed to parse credential definition from %s: %w", path, err) } - i.credentialsSupported = append(i.credentialsSupported, definitionMap) + if err := addDefinition(path, definitionMap); err != nil { + return err + } } return nil }) @@ -487,13 +518,123 @@ func (i *openidHandler) loadCredentialDefinitions() error { return err } -func deepcopy(src []map[string]interface{}) []map[string]interface{} { - dst := make([]map[string]interface{}, len(src)) - for i := range src { - dst[i] = make(map[string]interface{}) - for k, v := range src[i] { - dst[i][k] = v - } +func deepcopyMap(src map[string]map[string]interface{}) map[string]map[string]interface{} { + data, err := json.Marshal(src) + if err != nil { + panic("deepcopyMap: marshal failed: " + err.Error()) + } + var dst map[string]map[string]interface{} + if err = json.Unmarshal(data, &dst); err != nil { + panic("deepcopyMap: unmarshal failed: " + err.Error()) } return dst } + +// generateCredentialConfigID generates a credential_configuration_id from a credential definition. +// The ID is formed as "{MostSpecificType}_{format}" (e.g., "NutsOrganizationCredential_ldp_vc"). +// Returns an error if the definition is missing required fields to generate a unique ID. +func generateCredentialConfigID(definitionMap map[string]interface{}) (string, error) { + format, _ := definitionMap["format"].(string) + if format == "" { + return "", errors.New("credential definition missing 'format' field") + } + credDef, ok := definitionMap["credential_definition"].(map[string]interface{}) + if !ok { + return "", errors.New("credential definition missing 'credential_definition' field") + } + + types, ok := credDef["type"].([]interface{}) + if !ok || len(types) == 0 { + return "", errors.New("credential definition missing 'type' field") + } + + // Find the most specific type (typically the last one, excluding VerifiableCredential) + var specificType string + for _, t := range types { + if typeStr, ok := t.(string); ok && typeStr != "VerifiableCredential" { + specificType = typeStr + } + } + if specificType == "" { + specificType = "VerifiableCredential" + } + + return specificType + "_" + format, nil +} + +// findCredentialConfigID finds the credential configuration ID for the given credential +// by matching it against the loaded credential_configurations_supported. +// Returns an error if no matching configuration is found, since credential_configuration_ids +// in offers MUST reference entries in credential_configurations_supported (Section 4.1.1). +func (i *openidHandler) findCredentialConfigID(credential vc.VerifiableCredential) (string, error) { + for configID, config := range i.credentialConfigurationsSupported { + if matchesCredential(config, credential) { + return configID, nil + } + } + return "", fmt.Errorf("no matching credential configuration for type %s", credential.Type) +} + +// matchesCredential checks if a credential configuration matches the given credential +// by comparing format, type, and @context. +// Type matching is exact (count must be equal). Context matching is a subset check: +// all config contexts must appear in the credential, but the credential may have additional +// contexts (e.g., proof-related contexts added during signing). +func matchesCredential(config map[string]interface{}, credential vc.VerifiableCredential) bool { + format, _ := config["format"].(string) + if format != vc.JSONLDCredentialProofFormat { + return false + } + + credDef, ok := config["credential_definition"].(map[string]interface{}) + if !ok { + return false + } + + types, ok := credDef["type"].([]interface{}) + if !ok { + return false + } + if len(types) != len(credential.Type) { + return false + } + for _, configType := range types { + typeStr, ok := configType.(string) + if !ok { + return false + } + found := false + for _, credType := range credential.Type { + if credType.String() == typeStr { + found = true + break + } + } + if !found { + return false + } + } + + contexts, ok := credDef["@context"].([]interface{}) + if !ok { + return false + } + for _, configCtx := range contexts { + ctxStr, ok := configCtx.(string) + if !ok { + return false + } + found := false + for _, credCtx := range credential.Context { + if credCtx.String() == ctxStr { + found = true + break + } + } + if !found { + return false + } + } + + return true +} diff --git a/vcr/issuer/openid_mock.go b/vcr/issuer/openid_mock.go index 1eb709fca9..959eaa1e44 100644 --- a/vcr/issuer/openid_mock.go +++ b/vcr/issuer/openid_mock.go @@ -22,7 +22,6 @@ import ( type MockOpenIDHandler struct { ctrl *gomock.Controller recorder *MockOpenIDHandlerMockRecorder - isgomock struct{} } // MockOpenIDHandlerMockRecorder is the mock recorder for MockOpenIDHandler. @@ -43,13 +42,12 @@ func (m *MockOpenIDHandler) EXPECT() *MockOpenIDHandlerMockRecorder { } // HandleAccessTokenRequest mocks base method. -func (m *MockOpenIDHandler) HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, string, error) { +func (m *MockOpenIDHandler) HandleAccessTokenRequest(ctx context.Context, preAuthorizedCode string) (string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HandleAccessTokenRequest", ctx, preAuthorizedCode) ret0, _ := ret[0].(string) - ret1, _ := ret[1].(string) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 + ret1, _ := ret[1].(error) + return ret0, ret1 } // HandleAccessTokenRequest indicates an expected call of HandleAccessTokenRequest. @@ -73,6 +71,21 @@ func (mr *MockOpenIDHandlerMockRecorder) HandleCredentialRequest(ctx, request, a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleCredentialRequest", reflect.TypeOf((*MockOpenIDHandler)(nil).HandleCredentialRequest), ctx, request, accessToken) } +// HandleNonceRequest mocks base method. +func (m *MockOpenIDHandler) HandleNonceRequest(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HandleNonceRequest", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HandleNonceRequest indicates an expected call of HandleNonceRequest. +func (mr *MockOpenIDHandlerMockRecorder) HandleNonceRequest(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleNonceRequest", reflect.TypeOf((*MockOpenIDHandler)(nil).HandleNonceRequest), ctx) +} + // Metadata mocks base method. func (m *MockOpenIDHandler) Metadata() openid4vci.CredentialIssuerMetadata { m.ctrl.T.Helper() diff --git a/vcr/issuer/openid_store.go b/vcr/issuer/openid_store.go index 0471301164..1bb7df72dd 100644 --- a/vcr/issuer/openid_store.go +++ b/vcr/issuer/openid_store.go @@ -40,6 +40,12 @@ type OpenIDStore interface { // DeleteReference deletes the reference from the store. // It does not return an error if it doesn't exist anymore. DeleteReference(ctx context.Context, refType string, reference string) error + // StoreNonce stores a standalone nonce (not tied to a flow) with TTL. + // Used by the Nonce Endpoint (v1.0 Section 7). + StoreNonce(ctx context.Context, nonce string) error + // ConsumeNonce atomically checks whether a standalone nonce exists and deletes it (single-use). + // Returns true if the nonce was valid (existed and was consumed), false otherwise. + ConsumeNonce(ctx context.Context, nonce string) bool } var _ OpenIDStore = (*openidMemoryStore)(nil) @@ -101,3 +107,19 @@ func (o *openidMemoryStore) DeleteReference(_ context.Context, refType string, r refStore := o.sessionDatabase.GetStore(TokenTTL, "openid4vci", refType) return refStore.Delete(reference) } + +const standaloneNonceStoreKey = "standalone_nonce" + +func (o *openidMemoryStore) StoreNonce(_ context.Context, nonce string) error { + store := o.sessionDatabase.GetStore(TokenTTL, "openid4vci", standaloneNonceStoreKey) + return store.Put(nonce, true) +} + +func (o *openidMemoryStore) ConsumeNonce(_ context.Context, nonce string) bool { + store := o.sessionDatabase.GetStore(TokenTTL, "openid4vci", standaloneNonceStoreKey) + var value bool + if err := store.GetAndDelete(nonce, &value); err != nil { + return false + } + return value +} diff --git a/vcr/issuer/openid_store_test.go b/vcr/issuer/openid_store_test.go index 9fcbf80109..fe4d64f20c 100644 --- a/vcr/issuer/openid_store_test.go +++ b/vcr/issuer/openid_store_test.go @@ -119,6 +119,25 @@ func Test_memoryStore_Store(t *testing.T) { }) } +func Test_memoryStore_StandaloneNonce(t *testing.T) { + ctx := context.Background() + t.Run("store and validate", func(t *testing.T) { + store := createStore(t) + err := store.StoreNonce(ctx, "test-nonce") + assert.NoError(t, err) + + // First check should succeed and consume the nonce + assert.True(t, store.ConsumeNonce(ctx, "test-nonce")) + + // Second check should fail (single-use) + assert.False(t, store.ConsumeNonce(ctx, "test-nonce")) + }) + t.Run("unknown nonce", func(t *testing.T) { + store := createStore(t) + assert.False(t, store.ConsumeNonce(ctx, "unknown")) + }) +} + func createStore(t *testing.T) *openidMemoryStore { storageDatabase := storage.NewTestInMemorySessionDatabase(t) store := NewOpenIDMemoryStore(storageDatabase).(*openidMemoryStore) diff --git a/vcr/issuer/openid_test.go b/vcr/issuer/openid_test.go index f81e25baca..2f82464016 100644 --- a/vcr/issuer/openid_test.go +++ b/vcr/issuer/openid_test.go @@ -34,6 +34,8 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "net/http" + "os" + "path/filepath" "testing" "time" ) @@ -54,11 +56,11 @@ var issuedVC = vc.VerifiableCredential{ }, Context: []ssi.URI{ ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), - ssi.MustParseURI("http://example.org/credentials/V1"), + ssi.MustParseURI("https://example.com/credentials/v1"), }, Type: []ssi.URI{ ssi.MustParseURI("VerifiableCredential"), - ssi.MustParseURI("HumanCredential"), + ssi.MustParseURI("ExampleCredential"), }, } @@ -67,7 +69,7 @@ func TestNew(t *testing.T) { iss, err := NewOpenIDHandler(issuerDID, issuerIdentifier, "./test/valid", nil, nil, storage.NewTestInMemorySessionDatabase(t)) require.NoError(t, err) - assert.Len(t, iss.(*openidHandler).credentialsSupported, 3) + assert.Len(t, iss.(*openidHandler).credentialConfigurationsSupported, 3) }) t.Run("error - invalid json", func(t *testing.T) { @@ -93,15 +95,45 @@ func Test_memoryIssuer_Metadata(t *testing.T) { assert.Equal(t, "https://example.com/did:nuts:issuer", metadata.CredentialIssuer) assert.Equal(t, "https://example.com/did:nuts:issuer/openid4vci/credential", metadata.CredentialEndpoint) - require.Len(t, metadata.CredentialsSupported, 3) - assert.Equal(t, "ldp_vc", metadata.CredentialsSupported[0]["format"]) - require.Len(t, metadata.CredentialsSupported[0]["cryptographic_binding_methods_supported"], 1) - assert.Equal(t, metadata.CredentialsSupported[0]["credential_definition"], + assert.Equal(t, "https://example.com/did:nuts:issuer/openid4vci/nonce", metadata.NonceEndpoint) + require.Len(t, metadata.CredentialConfigurationsSupported, 3) + // Assert all 3 config IDs by name + for _, expectedID := range []string{ + "NutsAuthorizationCredential_ldp_vc", + "NutsOrganizationCredential_ldp_vc", + "ExampleCredential_ldp_vc", + } { + _, ok := metadata.CredentialConfigurationsSupported[expectedID] + assert.True(t, ok, "expected config ID %s to be present", expectedID) + } + // Spot-check NutsAuthorizationCredential details + authCredConfig := metadata.CredentialConfigurationsSupported["NutsAuthorizationCredential_ldp_vc"] + assert.Equal(t, "ldp_vc", authCredConfig["format"]) + require.Len(t, authCredConfig["cryptographic_binding_methods_supported"], 1) + assert.Equal(t, authCredConfig["credential_definition"], map[string]interface{}{ - "@context": []interface{}{"https://www.w3.org/2018/credentials/v1", "https://www.nuts.nl/credentials/v1"}, + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1", "https://nuts.nl/credentials/v1"}, "type": []interface{}{"VerifiableCredential", "NutsAuthorizationCredential"}, }) }) + t.Run("duplicate credential_configuration_id from external dir is rejected", func(t *testing.T) { + // Create a temp dir with a definition that duplicates a built-in config ID + tmpDir := t.TempDir() + duplicateDef := `{ + "format": "ldp_vc", + "cryptographic_binding_methods_supported": ["did:nuts"], + "credential_definition": { + "@context": ["https://www.w3.org/2018/credentials/v1", "https://nuts.nl/credentials/v1"], + "type": ["VerifiableCredential", "NutsOrganizationCredential"] + } + }` + err := os.WriteFile(filepath.Join(tmpDir, "duplicate.json"), []byte(duplicateDef), 0644) + require.NoError(t, err) + + _, err = NewOpenIDHandler(issuerDID, issuerIdentifier, tmpDir, &http.Client{}, nil, storage.NewTestInMemorySessionDatabase(t)) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate credential_configuration_id 'NutsOrganizationCredential_ldp_vc'") + }) } func Test_memoryIssuer_ProviderMetadata(t *testing.T) { @@ -130,41 +162,37 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { } createClaims := func(nonce string) map[string]interface{} { return map[string]interface{}{ + "iss": holderDID.String(), "aud": issuerIdentifier, "iat": time.Now().Unix(), "nonce": nonce, } } - createRequest := func(headers, claims map[string]interface{}) openid4vci.CredentialRequest { + createProofs := func(headers, claims map[string]interface{}) *openid4vci.CredentialRequestProofs { proof, err := keyStore.SignJWT(ctx, claims, headers, headers["kid"].(string)) require.NoError(t, err) + return &openid4vci.CredentialRequestProofs{ + Jwt: []string{proof}, + } + } + createRequest := func(headers, claims map[string]interface{}, configID string) openid4vci.CredentialRequest { return openid4vci.CredentialRequest{ - Format: vc.JSONLDCredentialProofFormat, - CredentialDefinition: &openid4vci.CredentialDefinition{ - Context: []ssi.URI{ - ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), - ssi.MustParseURI("http://example.org/credentials/V1"), - }, - Type: []ssi.URI{ - ssi.MustParseURI("VerifiableCredential"), - ssi.MustParseURI("HumanCredential"), - }, - }, - Proof: &openid4vci.CredentialRequestProof{ - Jwt: proof, - ProofType: openid4vci.ProofTypeJWT, - }, + CredentialConfigurationID: configID, + Proofs: createProofs(headers, claims), } } const preAuthCode = "some-secret-code" service := requireNewTestHandler(t, keyResolver) - _, err := service.createOffer(ctx, issuedVC, preAuthCode) + offer, err := service.createOffer(ctx, issuedVC, preAuthCode) require.NoError(t, err) - accessToken, cNonce, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) require.NoError(t, err) - validRequest := createRequest(createHeaders(), createClaims(cNonce)) + nonce, err := service.HandleNonceRequest(ctx) + require.NoError(t, err) + configID := offer.CredentialConfigurationIDs[0] + validRequest := createRequest(createHeaders(), createClaims(nonce), configID) t.Run("ok", func(t *testing.T) { auditLogs := audit.CaptureAuditLogs(t) @@ -175,62 +203,39 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { assert.Equal(t, issuerDID.URI(), response.Issuer) auditLogs.AssertContains(t, "VCR", "VerifiableCredentialRetrievedEvent", audit.TestActor, "VC retrieved by wallet over OpenID4VCI") }) - t.Run("unsupported format", func(t *testing.T) { - request := createRequest(createHeaders(), createClaims(cNonce)) - request.Format = "unsupported format" + t.Run("error - missing credential_configuration_id", func(t *testing.T) { + request := openid4vci.CredentialRequest{ + Proofs: createProofs(createHeaders(), createClaims(nonce)), + } response, err := service.HandleCredentialRequest(ctx, request, accessToken) assert.Nil(t, response) - assert.EqualError(t, err, "unsupported_credential_type - credential request: unsupported format 'unsupported format'") + assert.EqualError(t, err, "invalid_credential_request - credential request must contain credential_configuration_id") }) - t.Run("invalid credential_definition", func(t *testing.T) { - request := createRequest(createHeaders(), createClaims(cNonce)) - request.CredentialDefinition.Type = []ssi.URI{} + t.Run("error - unknown credential_configuration_id", func(t *testing.T) { + request := createRequest(createHeaders(), createClaims(nonce), "NonExistent_ldp_vc") response, err := service.HandleCredentialRequest(ctx, request, accessToken) assert.Nil(t, response) - assert.EqualError(t, err, "invalid_request - credential request: invalid credential_definition: missing type field") + require.ErrorAs(t, err, new(openid4vci.Error)) + assert.Equal(t, openid4vci.UnknownCredentialConfiguration, err.(openid4vci.Error).Code) }) t.Run("proof validation", func(t *testing.T) { - t.Run("unsupported proof type", func(t *testing.T) { - invalidRequest := createRequest(createHeaders(), createClaims("")) - invalidRequest.Proof.ProofType = "not-supported" + t.Run("missing proofs", func(t *testing.T) { + invalidRequest := createRequest(createHeaders(), createClaims(""), configID) + invalidRequest.Proofs = nil response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - proof type not supported") + assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - missing proofs") assert.Nil(t, response) }) t.Run("jwt", func(t *testing.T) { - t.Run("missing proof", func(t *testing.T) { - invalidRequest := createRequest(createHeaders(), createClaims("")) - invalidRequest.Proof = nil - - response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - - assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - missing proof") - assert.Nil(t, response) - }) - t.Run("missing proof returns error with new c_nonce", func(t *testing.T) { - invalidRequest := createRequest(createHeaders(), createClaims("")) - invalidRequest.Proof = nil - - _, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - - require.ErrorAs(t, err, new(openid4vci.Error)) - cNonce := err.(openid4vci.Error).CNonce - assert.NotNil(t, cNonce) - assert.NotNil(t, err.(openid4vci.Error).CNonceExpiresIn) - - flow, err := service.store.FindByReference(ctx, cNonceRefType, *cNonce) - require.NoError(t, err) - assert.NotNil(t, flow) - }) t.Run("invalid JWT", func(t *testing.T) { - invalidRequest := createRequest(createHeaders(), createClaims("")) - invalidRequest.Proof.Jwt = "not a JWT" + invalidRequest := createRequest(createHeaders(), createClaims(""), configID) + invalidRequest.Proofs.Jwt = []string{"not a JWT"} response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -239,7 +244,9 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { }) t.Run("not signed by intended wallet (DID differs)", func(t *testing.T) { otherIssuedVC := vc.VerifiableCredential{ - Issuer: issuerDID.URI(), + Issuer: issuerDID.URI(), + Context: issuedVC.Context, + Type: issuedVC.Type, CredentialSubject: []map[string]any{ { "id": "did:nuts:other-wallet", @@ -248,16 +255,37 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { } service := requireNewTestHandler(t, keyResolver) - _, err := service.createOffer(ctx, otherIssuedVC, preAuthCode) + otherOffer, err := service.createOffer(ctx, otherIssuedVC, preAuthCode) require.NoError(t, err) - accessToken, _, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) require.NoError(t, err) - invalidRequest := createRequest(createHeaders(), createClaims("")) + otherConfigID := otherOffer.CredentialConfigurationIDs[0] + invalidRequest := createRequest(createHeaders(), createClaims(""), otherConfigID) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - credential offer was signed by other DID than intended wallet: did:nuts:holder#1") + assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - proof iss claim does not match expected wallet: did:nuts:holder") + assert.Nil(t, response) + }) + t.Run("iss claim does not match wallet DID", func(t *testing.T) { + service := requireNewTestHandler(t, keyResolver) + _, err := service.createOffer(ctx, issuedVC, preAuthCode) + require.NoError(t, err) + accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + require.NoError(t, err) + + wrongIssClaims := map[string]interface{}{ + "iss": "did:nuts:wrong-issuer", + "aud": issuerIdentifier, + "iat": time.Now().Unix(), + "nonce": "", + } + invalidRequest := createRequest(createHeaders(), wrongIssClaims, configID) + + response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) + + assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - proof iss claim does not match expected wallet: did:nuts:wrong-issuer") assert.Nil(t, response) }) t.Run("signing key is unknown", func(t *testing.T) { @@ -266,10 +294,10 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { service := requireNewTestHandler(t, keyResolver) _, err := service.createOffer(ctx, issuedVC, preAuthCode) require.NoError(t, err) - accessToken, _, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) require.NoError(t, err) - invalidRequest := createRequest(createHeaders(), createClaims("")) + invalidRequest := createRequest(createHeaders(), createClaims(""), configID) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -279,7 +307,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { t.Run("typ header missing", func(t *testing.T) { headers := createHeaders() headers["typ"] = "" - invalidRequest := createRequest(headers, createClaims("")) + invalidRequest := createRequest(headers, createClaims(""), configID) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -289,7 +317,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { t.Run("typ header invalid", func(t *testing.T) { headers := createHeaders() delete(headers, "typ") // causes JWT library to set it to default ("JWT") - invalidRequest := createRequest(headers, createClaims("")) + invalidRequest := createRequest(headers, createClaims(""), configID) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -299,7 +327,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { t.Run("aud header doesn't match issuer identifier", func(t *testing.T) { claims := createClaims("") claims["aud"] = "https://example.com/someone-else" - invalidRequest := createRequest(createHeaders(), claims) + invalidRequest := createRequest(createHeaders(), claims, configID) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) @@ -308,44 +336,39 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { }) }) t.Run("unknown nonce", func(t *testing.T) { - invalidRequest := createRequest(createHeaders(), createClaims("other")) + invalidRequest := createRequest(createHeaders(), createClaims("other"), configID) response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - unknown nonce") + assertProtocolError(t, err, http.StatusBadRequest, "invalid_nonce - invalid or expired nonce") assert.Nil(t, response) }) - t.Run("wrong nonce", func(t *testing.T) { - _, err := service.createOffer(ctx, issuedVC, "other") - require.NoError(t, err) - _, cNonce, err := service.HandleAccessTokenRequest(ctx, "other") - require.NoError(t, err) - invalidRequest := createRequest(createHeaders(), createClaims(cNonce)) - - response, err := service.HandleCredentialRequest(ctx, invalidRequest, accessToken) - - assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - nonce not valid for access token") - assert.Nil(t, response) - }) - t.Run("request does not match offer", func(t *testing.T) { - request := createRequest(createHeaders(), createClaims(cNonce)) - request.CredentialDefinition.Type = []ssi.URI{ - ssi.MustParseURI("DifferentCredential"), - } - - response, err := service.HandleCredentialRequest(ctx, request, accessToken) - - assert.Nil(t, response) - assert.EqualError(t, err, "invalid_request - requested credential does not match offer: credential does not match credential_definition: type mismatch") - }) }) - t.Run("unknown access token", func(t *testing.T) { service := requireNewTestHandler(t, keyResolver) response, err := service.HandleCredentialRequest(ctx, validRequest, accessToken) - assertProtocolError(t, err, http.StatusBadRequest, "invalid_token - unknown access token") + assertProtocolError(t, err, http.StatusUnauthorized, "invalid_token - unknown access token") + assert.Nil(t, response) + }) + t.Run("credential issuer does not match", func(t *testing.T) { + store := storage.NewTestInMemorySessionDatabase(t) + service, err := NewOpenIDHandler(issuerDID, issuerIdentifier, definitionsDIR, &http.Client{}, keyResolver, store) + require.NoError(t, err) + _, err = service.(*openidHandler).createOffer(ctx, issuedVC, preAuthCode) + require.NoError(t, err) + accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + require.NoError(t, err) + nonce, err := service.HandleNonceRequest(ctx) + require.NoError(t, err) + request := createRequest(createHeaders(), createClaims(nonce), configID) + + otherService, err := NewOpenIDHandler(did.MustParseDID("did:nuts:other"), "http://example.com/other", definitionsDIR, &http.Client{}, keyResolver, store) + require.NoError(t, err) + response, err := otherService.HandleCredentialRequest(ctx, request, accessToken) + + assertProtocolError(t, err, http.StatusBadRequest, "invalid_credential_request - credential issuer does not match given issuer") assert.Nil(t, response) }) } @@ -387,7 +410,7 @@ func Test_memoryIssuer_HandleAccessTokenRequest(t *testing.T) { _, err := service.createOffer(ctx, issuedVC, "code") require.NoError(t, err) - accessToken, _, err := service.HandleAccessTokenRequest(audit.TestContext(), "code") + accessToken, err := service.HandleAccessTokenRequest(audit.TestContext(), "code") require.NoError(t, err) assert.NotEmpty(t, accessToken) @@ -401,7 +424,7 @@ func Test_memoryIssuer_HandleAccessTokenRequest(t *testing.T) { otherService, err := NewOpenIDHandler(did.MustParseDID("did:nuts:other"), "http://example.com/other", definitionsDIR, &http.Client{}, nil, store) require.NoError(t, err) - accessToken, _, err := otherService.HandleAccessTokenRequest(audit.TestContext(), "code") + accessToken, err := otherService.HandleAccessTokenRequest(audit.TestContext(), "code") var protocolError openid4vci.Error require.ErrorAs(t, err, &protocolError) @@ -414,7 +437,7 @@ func Test_memoryIssuer_HandleAccessTokenRequest(t *testing.T) { _, err := service.createOffer(ctx, issuedVC, "some-other-code") require.NoError(t, err) - accessToken, _, err := service.HandleAccessTokenRequest(audit.TestContext(), "code") + accessToken, err := service.HandleAccessTokenRequest(audit.TestContext(), "code") var protocolError openid4vci.Error require.ErrorAs(t, err, &protocolError) @@ -424,6 +447,149 @@ func Test_memoryIssuer_HandleAccessTokenRequest(t *testing.T) { }) } +func Test_memoryIssuer_HandleNonceRequest(t *testing.T) { + ctx := context.Background() + t.Run("ok", func(t *testing.T) { + service := requireNewTestHandler(t, nil) + + nonce, err := service.HandleNonceRequest(ctx) + + require.NoError(t, err) + assert.NotEmpty(t, nonce) + }) +} + +func Test_memoryIssuer_validateProof_metadataDriven(t *testing.T) { + keyStore := crypto.NewMemoryCryptoInstance(t) + ctx := audit.TestContext() + _, signerKey, _ := keyStore.New(ctx, crypto.StringNamingFunc(keyID)) + ctrl := gomock.NewController(t) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKeyByID(keyID, nil, resolver.NutsSigningKeyType).AnyTimes().Return(signerKey, nil) + + createHeaders := func() map[string]interface{} { + return map[string]interface{}{ + "typ": openid4vci.JWTTypeOpenID4VCIProof, + "kid": keyID, + } + } + createClaims := func(nonce string) map[string]interface{} { + return map[string]interface{}{ + "iss": holderDID.String(), + "aud": issuerIdentifier, + "iat": time.Now().Unix(), + "nonce": nonce, + } + } + createProofs := func(headers, claims map[string]interface{}) *openid4vci.CredentialRequestProofs { + proof, err := keyStore.SignJWT(ctx, claims, headers, headers["kid"].(string)) + require.NoError(t, err) + return &openid4vci.CredentialRequestProofs{ + Jwt: []string{proof}, + } + } + + const preAuthCode = "some-secret-code" + + t.Run("standalone nonce from Nonce Endpoint is accepted", func(t *testing.T) { + service := requireNewTestHandler(t, keyResolver) + _, err := service.createOffer(ctx, issuedVC, preAuthCode) + require.NoError(t, err) + accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + require.NoError(t, err) + + // Get a standalone nonce + standaloneNonce, err := service.HandleNonceRequest(ctx) + require.NoError(t, err) + + configID := "ExampleCredential_ldp_vc" + request := openid4vci.CredentialRequest{ + CredentialConfigurationID: configID, + Proofs: createProofs(createHeaders(), createClaims(standaloneNonce)), + } + + response, err := service.HandleCredentialRequest(ctx, request, accessToken) + + require.NoError(t, err) + require.NotNil(t, response) + }) + t.Run("proof skipped when credential config has no proof_types_supported", func(t *testing.T) { + // Create a handler with a credential config that lacks proof_types_supported + tmpDir := t.TempDir() + noProofDef := `{ + "format": "ldp_vc", + "cryptographic_binding_methods_supported": ["did:nuts"], + "credential_definition": { + "@context": ["https://www.w3.org/2018/credentials/v1", "https://example.com/credentials/v1"], + "type": ["VerifiableCredential", "NoProofCredential"] + } + }` + err := os.WriteFile(filepath.Join(tmpDir, "NoProofCredential.json"), []byte(noProofDef), 0644) + require.NoError(t, err) + + service, err := NewOpenIDHandler(issuerDID, issuerIdentifier, tmpDir, &http.Client{}, keyResolver, storage.NewTestInMemorySessionDatabase(t)) + require.NoError(t, err) + handler := service.(*openidHandler) + + noProofVC := vc.VerifiableCredential{ + Issuer: issuerDID.URI(), + CredentialSubject: []map[string]any{ + {"id": holderDID.String()}, + }, + 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("NoProofCredential"), + }, + } + + _, err = handler.createOffer(ctx, noProofVC, preAuthCode) + require.NoError(t, err) + accessToken, err := handler.HandleAccessTokenRequest(ctx, preAuthCode) + require.NoError(t, err) + + // Request without proof should succeed + request := openid4vci.CredentialRequest{ + CredentialConfigurationID: "NoProofCredential_ldp_vc", + } + + response, err := handler.HandleCredentialRequest(ctx, request, accessToken) + + require.NoError(t, err) + require.NotNil(t, response) + }) + t.Run("non-string nonce claim returns invalid_proof", func(t *testing.T) { + service := requireNewTestHandler(t, keyResolver) + _, err := service.createOffer(ctx, issuedVC, preAuthCode) + require.NoError(t, err) + accessToken, err := service.HandleAccessTokenRequest(ctx, preAuthCode) + require.NoError(t, err) + + // Get a standalone nonce but put a number in the claim instead + _, err = service.HandleNonceRequest(ctx) + require.NoError(t, err) + + configID := "ExampleCredential_ldp_vc" + claimsWithNumericNonce := map[string]interface{}{ + "iss": holderDID.String(), + "aud": issuerIdentifier, + "iat": time.Now().Unix(), + "nonce": 12345, // non-string + } + request := openid4vci.CredentialRequest{ + CredentialConfigurationID: configID, + Proofs: createProofs(createHeaders(), claimsWithNumericNonce), + } + + _, err = service.HandleCredentialRequest(ctx, request, accessToken) + + assertProtocolError(t, err, http.StatusBadRequest, "invalid_proof - nonce claim is not a string") + }) +} + func assertProtocolError(t *testing.T, err error, statusCode int, message string) { var protocolError openid4vci.Error require.ErrorAs(t, err, &protocolError) @@ -436,3 +602,130 @@ func requireNewTestHandler(t *testing.T, keyResolver resolver.KeyResolver) *open require.NoError(t, err) return service.(*openidHandler) } + +func Test_deepcopyMap(t *testing.T) { + t.Run("mutation of copy does not affect original", func(t *testing.T) { + src := map[string]map[string]interface{}{ + "config1": { + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "type": []interface{}{"VerifiableCredential"}, + }, + }, + } + + dst := deepcopyMap(src) + credDef := dst["config1"]["credential_definition"].(map[string]interface{}) + credDef["type"] = []interface{}{"Mutated"} + + srcCredDef := src["config1"]["credential_definition"].(map[string]interface{}) + assert.Equal(t, []interface{}{"VerifiableCredential"}, srcCredDef["type"]) + }) +} + +func Test_matchesCredential(t *testing.T) { + t.Run("matches on type and context", func(t *testing.T) { + config := map[string]interface{}{ + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1", "https://nuts.nl/credentials/v1"}, + "type": []interface{}{"VerifiableCredential", "NutsOrganizationCredential"}, + }, + } + cred := vc.VerifiableCredential{ + Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), ssi.MustParseURI("https://nuts.nl/credentials/v1")}, + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("NutsOrganizationCredential")}, + } + + assert.True(t, matchesCredential(config, cred)) + }) + t.Run("does not match on type mismatch", func(t *testing.T) { + config := map[string]interface{}{ + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1"}, + "type": []interface{}{"VerifiableCredential", "OtherCredential"}, + }, + } + cred := vc.VerifiableCredential{ + Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1")}, + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("NutsOrganizationCredential")}, + } + + assert.False(t, matchesCredential(config, cred)) + }) + t.Run("does not match on context mismatch", func(t *testing.T) { + config := map[string]interface{}{ + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1", "https://other.example.com/v1"}, + "type": []interface{}{"VerifiableCredential"}, + }, + } + cred := vc.VerifiableCredential{ + Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1")}, + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}, + } + + assert.False(t, matchesCredential(config, cred)) + }) +} + +func Test_generateCredentialConfigID(t *testing.T) { + t.Run("ok", func(t *testing.T) { + defMap := map[string]interface{}{ + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "type": []interface{}{"VerifiableCredential", "NutsOrganizationCredential"}, + }, + } + id, err := generateCredentialConfigID(defMap) + require.NoError(t, err) + assert.Equal(t, "NutsOrganizationCredential_ldp_vc", id) + }) + t.Run("missing format", func(t *testing.T) { + defMap := map[string]interface{}{ + "credential_definition": map[string]interface{}{ + "type": []interface{}{"VerifiableCredential"}, + }, + } + _, err := generateCredentialConfigID(defMap) + assert.EqualError(t, err, "credential definition missing 'format' field") + }) + t.Run("missing credential_definition", func(t *testing.T) { + defMap := map[string]interface{}{ + "format": "ldp_vc", + } + _, err := generateCredentialConfigID(defMap) + assert.EqualError(t, err, "credential definition missing 'credential_definition' field") + }) + t.Run("missing type", func(t *testing.T) { + defMap := map[string]interface{}{ + "format": "ldp_vc", + "credential_definition": map[string]interface{}{}, + } + _, err := generateCredentialConfigID(defMap) + assert.EqualError(t, err, "credential definition missing 'type' field") + }) + t.Run("empty type array", func(t *testing.T) { + defMap := map[string]interface{}{ + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "type": []interface{}{}, + }, + } + _, err := generateCredentialConfigID(defMap) + assert.EqualError(t, err, "credential definition missing 'type' field") + }) + t.Run("only VerifiableCredential type falls back", func(t *testing.T) { + defMap := map[string]interface{}{ + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "type": []interface{}{"VerifiableCredential"}, + }, + } + id, err := generateCredentialConfigID(defMap) + require.NoError(t, err) + assert.Equal(t, "VerifiableCredential_ldp_vc", id) + }) +} diff --git a/vcr/issuer/test/valid/ExampleCredential.json b/vcr/issuer/test/valid/ExampleCredential.json index 36f08d26d8..110a3a6948 100644 --- a/vcr/issuer/test/valid/ExampleCredential.json +++ b/vcr/issuer/test/valid/ExampleCredential.json @@ -1,12 +1,17 @@ { "format": "ldp_vc", + "proof_types_supported": { + "jwt": { + "proof_signing_alg_values_supported": ["ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"] + } + }, "cryptographic_binding_methods_supported": [ "did:nuts" ], "credential_definition": { "@context": [ "https://www.w3.org/2018/credentials/v1", - "https://www.nuts.nl/credentials/v1" + "https://example.com/credentials/v1" ], "type": [ "VerifiableCredential", diff --git a/vcr/openid4vci/error.go b/vcr/openid4vci/error.go index 6c61c3dbc1..78fa896305 100644 --- a/vcr/openid4vci/error.go +++ b/vcr/openid4vci/error.go @@ -41,23 +41,30 @@ const ( UnsupportedGrantType ErrorCode = "unsupported_grant_type" // ServerError is returned when the Authorization Server encounters an unexpected condition that prevents it from fulfilling the request. ServerError ErrorCode = "server_error" - // UnsupportedCredentialType is returned when the credential issuer does not support the requested credential type. - UnsupportedCredentialType ErrorCode = "unsupported_credential_type" - // UnsupportedCredentialFormat is returned when the credential issuer does not support the requested credential format. - UnsupportedCredentialFormat ErrorCode = "unsupported_credential_format" - // InvalidProof is returned when the Credential Request did not contain a proof, - // or proof was invalid, i.e. it was not bound to a Credential Issuer provided nonce + // InvalidCredentialRequest is returned when the Credential Request is missing a required parameter, + // includes an unsupported parameter or parameter value, or is otherwise malformed. + InvalidCredentialRequest ErrorCode = "invalid_credential_request" + // UnknownCredentialConfiguration is returned when the requested credential_configuration_id is unknown. + UnknownCredentialConfiguration ErrorCode = "unknown_credential_configuration" + // UnknownCredentialIdentifier is returned when the requested credential_identifier is unknown. + UnknownCredentialIdentifier ErrorCode = "unknown_credential_identifier" + // InvalidProof is returned when the proofs parameter is invalid: missing, one of the key proofs + // is invalid, or a key proof does not contain a c_nonce value. InvalidProof ErrorCode = "invalid_proof" + // InvalidNonce is returned when at least one of the key proofs contains an invalid c_nonce value. + // The wallet should retrieve a new c_nonce value from the Nonce Endpoint (Section 7). + InvalidNonce ErrorCode = "invalid_nonce" + // InvalidEncryptionParameters is returned when the encryption parameters in the Credential Request + // are either invalid or missing when the issuer requires encrypted responses. + InvalidEncryptionParameters ErrorCode = "invalid_encryption_parameters" + // CredentialRequestDenied is returned when the Credential Request has not been accepted by the + // issuer. The wallet SHOULD treat this as unrecoverable. + CredentialRequestDenied ErrorCode = "credential_request_denied" ) // Error is an error that signals the error was (probably) caused by the client (e.g. bad request), // or that the client can recover from the error (e.g. retry). Errors are specified by the OpenID4VCI specification. -// Invalid proof errors may also add a new c_nonce that the client must use in the next credential request. type Error struct { - // CNonce is a random string that the client must send in the next credential request. - CNonce *string `json:"c_nonce,omitempty"` - // CNonceExpiresIn is the number of seconds until the c_nonce expires. - CNonceExpiresIn *int `json:"c_nonce_expires_in,omitempty"` // Code is the error code as defined by the OpenID4VCI spec. Code ErrorCode `json:"error"` // Err is the underlying error, may be omitted. It is not intended to be returned to the client. diff --git a/vcr/openid4vci/issuer_client.go b/vcr/openid4vci/issuer_client.go index c355aa96d5..d7f91dc484 100644 --- a/vcr/openid4vci/issuer_client.go +++ b/vcr/openid4vci/issuer_client.go @@ -43,6 +43,8 @@ type IssuerAPIClient interface { Metadata() CredentialIssuerMetadata // RequestCredential requests a credential from the issuer. RequestCredential(ctx context.Context, request CredentialRequest, accessToken string) (*vc.VerifiableCredential, error) + // RequestNonce requests a fresh c_nonce from the issuer's Nonce Endpoint (v1.0 Section 7). + RequestNonce(ctx context.Context) (*NonceResponse, error) } // NewIssuerAPIClient resolves the Credential Issuer Metadata from the well-known endpoint @@ -93,28 +95,72 @@ type defaultIssuerAPIClient struct { func (h defaultIssuerAPIClient) RequestCredential(ctx context.Context, request CredentialRequest, accessToken string) (*vc.VerifiableCredential, error) { requestBody, _ := json.Marshal(request) - var credentialResponse CredentialResponse httpRequest, _ := http.NewRequestWithContext(ctx, "POST", h.metadata.CredentialEndpoint, bytes.NewReader(requestBody)) httpRequest.Header.Add("Authorization", "Bearer "+accessToken) httpRequest.Header.Add("Content-Type", "application/json") - err := httpDo(h.httpClient, httpRequest, &credentialResponse) + credentialResponse, err := doCredentialRequest(h.httpClient, httpRequest) if err != nil { - return nil, fmt.Errorf("get credential request failed: %w", err) + return nil, err } - // TODO: check format - // See https://github.com/nuts-foundation/nuts-node/issues/2037 - if credentialResponse.Credential == nil { - return nil, errors.New("credential response does not contain a credential") + if len(credentialResponse.Credentials) == 0 { + return nil, errors.New("credential response does not contain any credentials") } + // We only support single credential issuance for now var credential vc.VerifiableCredential - credentialJSON, _ := json.Marshal(*credentialResponse.Credential) - err = json.Unmarshal(credentialJSON, &credential) + err = json.Unmarshal(credentialResponse.Credentials[0].Credential, &credential) if err != nil { return nil, fmt.Errorf("unable to unmarshal received credential: %w", err) } return &credential, nil } +// doCredentialRequest performs the HTTP request to the credential endpoint. +// It returns structured OpenID4VCI errors when the server returns an error response, +// allowing callers to detect specific error codes like invalid_nonce. +func doCredentialRequest(httpClient core.HTTPRequestDoer, httpRequest *http.Request) (*CredentialResponse, error) { + if HttpClientTrace != nil { + httpRequest = httpRequest.WithContext(httptrace.WithClientTrace(httpRequest.Context(), HttpClientTrace)) + } + httpResponse, err := httpClient.Do(httpRequest) + if err != nil { + return nil, fmt.Errorf("credential request http error: %w", err) + } + defer httpResponse.Body.Close() + responseBody, err := io.ReadAll(httpResponse.Body) + if err != nil { + return nil, fmt.Errorf("credential request read error: %w", err) + } + if httpResponse.StatusCode < 200 || httpResponse.StatusCode > 299 { + var oidcError Error + if json.Unmarshal(responseBody, &oidcError) == nil && oidcError.Code != "" { + oidcError.StatusCode = httpResponse.StatusCode + return nil, oidcError + } + return nil, fmt.Errorf("credential request failed (status %d)", httpResponse.StatusCode) + } + var credentialResponse CredentialResponse + if err := json.Unmarshal(responseBody, &credentialResponse); err != nil { + return nil, fmt.Errorf("credential response unmarshal error: %w", err) + } + return &credentialResponse, nil +} + +func (h defaultIssuerAPIClient) RequestNonce(ctx context.Context) (*NonceResponse, error) { + if h.metadata.NonceEndpoint == "" { + return nil, errors.New("issuer does not advertise a nonce endpoint") + } + var nonceResponse NonceResponse + httpRequest, _ := http.NewRequestWithContext(ctx, "POST", h.metadata.NonceEndpoint, http.NoBody) + err := httpDo(h.httpClient, httpRequest, &nonceResponse) + if err != nil { + return nil, fmt.Errorf("nonce request failed: %w", err) + } + if nonceResponse.CNonce == "" { + return nil, errors.New("nonce endpoint returned empty c_nonce") + } + return &nonceResponse, nil +} + func (h defaultIssuerAPIClient) Metadata() CredentialIssuerMetadata { return h.metadata } diff --git a/vcr/openid4vci/issuer_client_mock.go b/vcr/openid4vci/issuer_client_mock.go index 370f86f84e..e6d7f49c45 100644 --- a/vcr/openid4vci/issuer_client_mock.go +++ b/vcr/openid4vci/issuer_client_mock.go @@ -22,7 +22,6 @@ import ( type MockIssuerAPIClient struct { ctrl *gomock.Controller recorder *MockIssuerAPIClientMockRecorder - isgomock struct{} } // MockIssuerAPIClientMockRecorder is the mock recorder for MockIssuerAPIClient. @@ -86,11 +85,25 @@ func (mr *MockIssuerAPIClientMockRecorder) RequestCredential(ctx, request, acces return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestCredential", reflect.TypeOf((*MockIssuerAPIClient)(nil).RequestCredential), ctx, request, accessToken) } +// RequestNonce mocks base method. +func (m *MockIssuerAPIClient) RequestNonce(ctx context.Context) (*NonceResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequestNonce", ctx) + ret0, _ := ret[0].(*NonceResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequestNonce indicates an expected call of RequestNonce. +func (mr *MockIssuerAPIClientMockRecorder) RequestNonce(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestNonce", reflect.TypeOf((*MockIssuerAPIClient)(nil).RequestNonce), ctx) +} + // MockOAuth2Client is a mock of OAuth2Client interface. type MockOAuth2Client struct { ctrl *gomock.Controller recorder *MockOAuth2ClientMockRecorder - isgomock struct{} } // MockOAuth2ClientMockRecorder is the mock recorder for MockOAuth2Client. diff --git a/vcr/openid4vci/issuer_client_test.go b/vcr/openid4vci/issuer_client_test.go index 72355f6d05..8f4b07c7bc 100644 --- a/vcr/openid4vci/issuer_client_test.go +++ b/vcr/openid4vci/issuer_client_test.go @@ -20,7 +20,7 @@ package openid4vci import ( "context" - "github.com/nuts-foundation/go-did/vc" + "encoding/json" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "net/http" @@ -89,8 +89,7 @@ func Test_httpIssuerClient_RequestCredential(t *testing.T) { ctx := context.Background() httpClient := &http.Client{} credentialRequest := CredentialRequest{ - CredentialDefinition: &CredentialDefinition{}, - Format: vc.JSONLDCredentialProofFormat, + CredentialConfigurationID: "NutsOrganizationCredential_ldp_vc", } t.Run("ok", func(t *testing.T) { setup := setupClientTest(t) @@ -110,13 +109,14 @@ func Test_httpIssuerClient_RequestCredential(t *testing.T) { credential, err := client.RequestCredential(ctx, credentialRequest, "token") - require.EqualError(t, err, "credential response does not contain a credential") + require.EqualError(t, err, "credential response does not contain any credentials") require.Nil(t, credential) }) t.Run("error - invalid credentials in response", func(t *testing.T) { setup := setupClientTest(t) - setup.credentialHandler = setup.httpPostHandler(CredentialResponse{Credential: &map[string]interface{}{ - "issuer": []string{"1", "2"}, // Invalid issuer + invalidCredJSON, _ := json.Marshal(map[string]interface{}{"issuer": []string{"1", "2"}}) + setup.credentialHandler = setup.httpPostHandler(CredentialResponse{Credentials: []CredentialResponseEntry{ + {Credential: invalidCredJSON}, // Invalid issuer }}) client, err := NewIssuerAPIClient(ctx, httpClient, setup.issuerMetadata.CredentialIssuer) require.NoError(t, err) @@ -128,6 +128,46 @@ func Test_httpIssuerClient_RequestCredential(t *testing.T) { }) } +func Test_httpIssuerClient_RequestNonce(t *testing.T) { + ctx := context.Background() + httpClient := &http.Client{} + t.Run("ok", func(t *testing.T) { + setup := setupClientTest(t) + client, err := NewIssuerAPIClient(ctx, httpClient, setup.issuerMetadata.CredentialIssuer) + require.NoError(t, err) + + nonceResponse, err := client.RequestNonce(ctx) + + require.NoError(t, err) + require.NotNil(t, nonceResponse) + assert.Equal(t, "test-nonce", nonceResponse.CNonce) + }) + t.Run("error - no nonce endpoint in metadata", func(t *testing.T) { + setup := setupClientTest(t) + setup.issuerMetadata.NonceEndpoint = "" + client, err := NewIssuerAPIClient(ctx, httpClient, setup.issuerMetadata.CredentialIssuer) + require.NoError(t, err) + + nonceResponse, err := client.RequestNonce(ctx) + + require.EqualError(t, err, "issuer does not advertise a nonce endpoint") + assert.Nil(t, nonceResponse) + }) + t.Run("error - nonce endpoint returns error", func(t *testing.T) { + setup := setupClientTest(t) + setup.nonceHandler = func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + } + client, err := NewIssuerAPIClient(ctx, httpClient, setup.issuerMetadata.CredentialIssuer) + require.NoError(t, err) + + nonceResponse, err := client.RequestNonce(ctx) + + require.ErrorContains(t, err, "nonce request failed") + assert.Nil(t, nonceResponse) + }) +} + func Test_httpOAuth2Client_RequestAccessToken(t *testing.T) { httpClient := &http.Client{} params := map[string]string{"some-param": "some-value"} diff --git a/vcr/openid4vci/test.go b/vcr/openid4vci/test.go index f105ff4674..128ee5151e 100644 --- a/vcr/openid4vci/test.go +++ b/vcr/openid4vci/test.go @@ -22,7 +22,6 @@ import ( "context" "encoding/json" "fmt" - "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/test" "net/http" @@ -35,14 +34,16 @@ func setupClientTest(t *testing.T) *oidcClientTestContext { issuerMetadata := new(CredentialIssuerMetadata) providerMetadata := new(ProviderMetadata) walletMetadata := new(OAuth2ClientMetadata) + credentialJSON, _ := json.Marshal(map[string]interface{}{ + "@context": []string{"https://www.w3.org/2018/credentials/v1"}, + "type": []string{"VerifiableCredential"}, + "issuer": "issuer", + "issuanceDate": time.Now().Format(time.RFC3339), + "credentialSubject": map[string]interface{}{"id": "id"}, + }) credentialResponse := CredentialResponse{ - Format: vc.JSONLDCredentialProofFormat, - Credential: &map[string]interface{}{ - "@context": []string{"https://www.w3.org/2018/credentials/v1"}, - "type": []string{"VerifiableCredential"}, - "issuer": "issuer", - "issuanceDate": time.Now().Format(time.RFC3339), - "credentialSubject": map[string]interface{}{"id": "id"}, + Credentials: []CredentialResponseEntry{ + {Credential: credentialJSON}, }, } clientTest := &oidcClientTestContext{ @@ -56,6 +57,7 @@ func setupClientTest(t *testing.T) *oidcClientTestContext { clientTest.tokenHandler = clientTest.httpPostHandler(oauth.TokenResponse{AccessToken: "secret"}) clientTest.walletMetadataHandler = clientTest.httpGetHandler(walletMetadata) clientTest.credentialOfferHandler = clientTest.httpGetHandler(CredentialOfferResponse{CredentialOfferStatusReceived}) + clientTest.nonceHandler = clientTest.httpPostHandler(NonceResponse{CNonce: "test-nonce"}) mux := http.NewServeMux() mux.HandleFunc("/issuer"+CredentialIssuerMetadataWellKnownPath, func(writer http.ResponseWriter, request *http.Request) { @@ -70,6 +72,9 @@ func setupClientTest(t *testing.T) *oidcClientTestContext { mux.HandleFunc("/issuer/token", func(writer http.ResponseWriter, request *http.Request) { clientTest.tokenHandler(writer, request) }) + mux.HandleFunc("/issuer/nonce", func(writer http.ResponseWriter, request *http.Request) { + clientTest.nonceHandler(writer, request) + }) mux.HandleFunc("/wallet/metadata", func(writer http.ResponseWriter, request *http.Request) { clientTest.walletMetadataHandler(writer, request) }) @@ -83,6 +88,7 @@ func setupClientTest(t *testing.T) *oidcClientTestContext { issuerIdentifier := serverURL + "/issuer" issuerMetadata.CredentialIssuer = issuerIdentifier issuerMetadata.CredentialEndpoint = issuerIdentifier + "/credential" + issuerMetadata.NonceEndpoint = issuerIdentifier + "/nonce" providerMetadata.Issuer = issuerIdentifier providerMetadata.TokenEndpoint = issuerIdentifier + "/token" return clientTest @@ -128,6 +134,7 @@ type oidcClientTestContext struct { credentialHandler http.HandlerFunc credentialOfferHandler http.HandlerFunc tokenHandler http.HandlerFunc + nonceHandler http.HandlerFunc walletMetadataHandler http.HandlerFunc requests []http.Request } diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index e5d030b005..d2afe68753 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -21,6 +21,7 @@ package openid4vci import ( + "encoding/json" ssi "github.com/nuts-foundation/go-did" "time" ) @@ -62,8 +63,19 @@ type CredentialIssuerMetadata struct { // CredentialEndpoint defines where the wallet can send a request to retrieve a credential. CredentialEndpoint string `json:"credential_endpoint"` - // CredentialsSupported defines metadata about which credential types the credential issuer can issue. - CredentialsSupported []map[string]interface{} `json:"credentials_supported"` + // NonceEndpoint defines the URL of the Nonce Endpoint where wallets can request a fresh c_nonce. + // Per v1.0 Section 7, a Credential Issuer that requires c_nonce values MUST offer a Nonce Endpoint. + NonceEndpoint string `json:"nonce_endpoint,omitempty"` + + // CredentialConfigurationsSupported defines metadata about which credential types the credential issuer can issue. + // The map is keyed by credential_configuration_id. + CredentialConfigurationsSupported map[string]map[string]interface{} `json:"credential_configurations_supported"` +} + +// NonceResponse defines the response from the Nonce Endpoint. +// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-nonce-endpoint +type NonceResponse struct { + CNonce string `json:"c_nonce"` } // OAuth2ClientMetadata defines the OAuth2 Client Metadata, extended with OpenID4VCI parameters. @@ -93,15 +105,26 @@ type ProviderMetadata struct { type CredentialOffer struct { // CredentialIssuer defines the identifier of the credential issuer. CredentialIssuer string `json:"credential_issuer"` - // Credentials defines the credentials offered by the issuer to the wallet. - Credentials []OfferedCredential `json:"credentials"` + // CredentialConfigurationIDs defines references to credential configurations offered by the issuer. + // These IDs reference entries in the credential_configurations_supported metadata. + CredentialConfigurationIDs []string `json:"credential_configuration_ids"` // Grants defines the grants offered by the issuer to the wallet. - Grants map[string]interface{} `json:"grants"` + Grants *CredentialOfferGrants `json:"grants,omitempty"` } -// OfferedCredential defines a single entry in the credentials array of a CredentialOffer. We currently do not support 'JSON string' offers. +// CredentialOfferGrants defines the grant types in a credential offer. // Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-offer-parameters -// and https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-vc-secured-using-data-integ +type CredentialOfferGrants struct { + PreAuthorizedCode *PreAuthorizedCodeParams `json:"urn:ietf:params:oauth:grant-type:pre-authorized_code,omitempty"` +} + +// PreAuthorizedCodeParams defines the parameters for the pre-authorized code grant. +type PreAuthorizedCodeParams struct { + PreAuthorizedCode string `json:"pre-authorized_code"` +} + +// OfferedCredential represents a resolved credential configuration from issuer metadata. +// It is used internally by the holder to validate offered credentials after resolving a credential_configuration_id. type OfferedCredential struct { // Format specifies the credential format. Format string `json:"format"` @@ -110,11 +133,11 @@ type OfferedCredential struct { } // CredentialDefinition defines the 'credential_definition' for Format VerifiableCredentialJSONLDFormat -// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-vc-secured-using-data-integ +// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html Appendix A.1.2 type CredentialDefinition struct { - Context []ssi.URI `json:"@context"` - Type []ssi.URI `json:"type"` - CredentialSubject *map[string]interface{} `json:"credentialSubject,omitempty"` // optional and currently not used + Context []ssi.URI `json:"@context"` + Type []ssi.URI `json:"type"` + CredentialSubject map[string]interface{} `json:"credentialSubject,omitempty"` // optional and currently not used } // CredentialOfferResponse defines the response for credential offer requests. @@ -125,26 +148,34 @@ type CredentialOfferResponse struct { } // CredentialRequest defines the credential request sent by the wallet to the issuer. -// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request. +// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request +// Per v1.0 Section 8.2, the request identifies the credential using credential_configuration_id. type CredentialRequest struct { - Format string `json:"format"` - CredentialDefinition *CredentialDefinition `json:"credential_definition,omitempty"` - Proof *CredentialRequestProof `json:"proof,omitempty"` + // CredentialConfigurationID references a credential configuration from issuer metadata. + CredentialConfigurationID string `json:"credential_configuration_id,omitempty"` + // Proofs contains the proof(s) of possession of the key material. + Proofs *CredentialRequestProofs `json:"proofs,omitempty"` } -// CredentialRequestProof defines the proof of possession of key material when requesting a Credential. +// CredentialRequestProofs defines the proof(s) of possession of key material when requesting a Credential. // Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-proof-types -type CredentialRequestProof struct { - Jwt string `json:"jwt"` - ProofType string `json:"proof_type"` +// The structure is: {"jwt": ["eyJ...", ...]} where the key is the proof type and the value is an array. +type CredentialRequestProofs struct { + Jwt []string `json:"jwt"` } // CredentialResponse defines the response for credential requests. // 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. type CredentialResponse struct { - Format string `json:"format,omitempty"` - Credential *map[string]interface{} `json:"credential,omitempty"` - CNonce *string `json:"c_nonce,omitempty"` + Credentials []CredentialResponseEntry `json:"credentials,omitempty"` +} + +// CredentialResponseEntry is a single entry in the credentials array of a CredentialResponse. +// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-response +type CredentialResponseEntry struct { + Credential json.RawMessage `json:"credential"` } // Config holds the config for the OpenID4VCI credential issuer and wallet diff --git a/vcr/openid4vci/types_test.go b/vcr/openid4vci/types_test.go new file mode 100644 index 0000000000..aad0d70586 --- /dev/null +++ b/vcr/openid4vci/types_test.go @@ -0,0 +1,269 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package openid4vci + +import ( + "encoding/json" + "testing" + + ssi "github.com/nuts-foundation/go-did" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCredentialRequest_V1Spec tests that CredentialRequest conforms to OpenID4VCI v1.0 Section 8.2 +func TestCredentialRequest_V1Spec(t *testing.T) { + t.Run("request with credential_configuration_id", func(t *testing.T) { + requestJSON := `{ + "credential_configuration_id": "NutsAuthorizationCredential_ldp_vc", + "proofs": { + "jwt": ["eyJ..."] + } + }` + + var request CredentialRequest + err := json.Unmarshal([]byte(requestJSON), &request) + require.NoError(t, err) + + assert.Equal(t, "NutsAuthorizationCredential_ldp_vc", request.CredentialConfigurationID) + assert.NotNil(t, request.Proofs) + }) + + t.Run("marshaling only includes non-empty fields", func(t *testing.T) { + request := CredentialRequest{ + CredentialConfigurationID: "NutsAuthorizationCredential_ldp_vc", + Proofs: &CredentialRequestProofs{ + Jwt: []string{"eyJ..."}, + }, + } + + jsonBytes, err := json.Marshal(request) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(jsonBytes, &parsed) + require.NoError(t, err) + + assert.Equal(t, "NutsAuthorizationCredential_ldp_vc", parsed["credential_configuration_id"]) + }) +} + +// TestCredentialOffer_V1Spec tests that CredentialOffer conforms to OpenID4VCI v1.0 Section 4.1.1 +func TestCredentialOffer_V1Spec(t *testing.T) { + t.Run("v1.0 format with credential_configuration_ids", func(t *testing.T) { + // Per v1.0 Section 4.1.1 + offerJSON := `{ + "credential_issuer": "https://issuer.example.com", + "credential_configuration_ids": ["NutsAuthorizationCredential_ldp_vc"], + "grants": { + "urn:ietf:params:oauth:grant-type:pre-authorized_code": { + "pre-authorized_code": "secret123" + } + } + }` + + var offer CredentialOffer + err := json.Unmarshal([]byte(offerJSON), &offer) + require.NoError(t, err) + + assert.Equal(t, "https://issuer.example.com", offer.CredentialIssuer) + assert.Equal(t, []string{"NutsAuthorizationCredential_ldp_vc"}, offer.CredentialConfigurationIDs) + require.NotNil(t, offer.Grants.PreAuthorizedCode) + assert.Equal(t, "secret123", offer.Grants.PreAuthorizedCode.PreAuthorizedCode) + }) + + t.Run("marshaling preserves v1.0 format", func(t *testing.T) { + offer := CredentialOffer{ + CredentialIssuer: "https://issuer.example.com", + CredentialConfigurationIDs: []string{"NutsAuthorizationCredential_ldp_vc"}, + Grants: &CredentialOfferGrants{ + PreAuthorizedCode: &PreAuthorizedCodeParams{ + PreAuthorizedCode: "secret123", + }, + }, + } + + jsonBytes, err := json.Marshal(offer) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(jsonBytes, &parsed) + require.NoError(t, err) + + // Must use credential_configuration_ids (v1.0), NOT credentials (old format) + _, hasOldField := parsed["credentials"] + assert.False(t, hasOldField, "should not have old 'credentials' field") + + configIds, ok := parsed["credential_configuration_ids"].([]interface{}) + require.True(t, ok, "must have credential_configuration_ids array") + assert.Len(t, configIds, 1) + assert.Equal(t, "NutsAuthorizationCredential_ldp_vc", configIds[0]) + + // Verify grants are serialized with the correct JSON key + grants, ok := parsed["grants"].(map[string]interface{}) + require.True(t, ok) + preAuth, ok := grants[PreAuthorizedCodeGrant].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "secret123", preAuth["pre-authorized_code"]) + }) +} + +// TestCredentialIssuerMetadata_V1Spec tests that metadata conforms to OpenID4VCI v1.0 Section 11.2.1 +func TestCredentialIssuerMetadata_V1Spec(t *testing.T) { + t.Run("v1.0 format with credential_configurations_supported map", func(t *testing.T) { + // Per v1.0 Section 11.2.1 + metadataJSON := `{ + "credential_issuer": "https://issuer.example.com", + "credential_endpoint": "https://issuer.example.com/credential", + "credential_configurations_supported": { + "NutsAuthorizationCredential_ldp_vc": { + "format": "ldp_vc", + "cryptographic_binding_methods_supported": ["did:nuts"], + "credential_definition": { + "@context": ["https://www.w3.org/2018/credentials/v1", "https://nuts.nl/credentials/v1"], + "type": ["VerifiableCredential", "NutsAuthorizationCredential"] + } + } + } + }` + + var metadata CredentialIssuerMetadata + err := json.Unmarshal([]byte(metadataJSON), &metadata) + require.NoError(t, err) + + assert.Equal(t, "https://issuer.example.com", metadata.CredentialIssuer) + assert.Equal(t, "https://issuer.example.com/credential", metadata.CredentialEndpoint) + + // Must be a map keyed by credential_configuration_id + require.Len(t, metadata.CredentialConfigurationsSupported, 1) + config, ok := metadata.CredentialConfigurationsSupported["NutsAuthorizationCredential_ldp_vc"] + require.True(t, ok) + assert.Equal(t, "ldp_vc", config["format"]) + }) + + t.Run("marshaling preserves v1.0 format", func(t *testing.T) { + metadata := CredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "NutsAuthorizationCredential_ldp_vc": { + "format": "ldp_vc", + }, + }, + } + + jsonBytes, err := json.Marshal(metadata) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(jsonBytes, &parsed) + require.NoError(t, err) + + // Must use credential_configurations_supported (v1.0), NOT credentials_supported (old format) + _, hasOldField := parsed["credentials_supported"] + assert.False(t, hasOldField, "should not have old 'credentials_supported' field") + + configs, ok := parsed["credential_configurations_supported"].(map[string]interface{}) + require.True(t, ok, "must have credential_configurations_supported object") + assert.Contains(t, configs, "NutsAuthorizationCredential_ldp_vc") + }) +} + +// TestCredentialResponse_V1Spec tests that CredentialResponse conforms to OpenID4VCI v1.0 Section 8.3 +// v1.0 uses `credentials` (array of wrapper objects with `credential` key) and c_nonce is no longer in the response. +func TestCredentialResponse_V1Spec(t *testing.T) { + t.Run("response uses credentials array with credential wrapper", 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) + + // Must use credentials (plural), not credential (singular) at top level + _, hasSingular := parsed["credential"] + assert.False(t, hasSingular, "must use credentials (plural) not credential (singular) at top level") + + // Each element in credentials must be a wrapper with a "credential" key + credentialsArr, ok := parsed["credentials"].([]interface{}) + require.True(t, ok, "credentials must be an array") + require.Len(t, credentialsArr, 1) + entry, ok := credentialsArr[0].(map[string]interface{}) + require.True(t, ok, "each credentials entry must be an object") + assert.NotNil(t, entry["credential"], "each entry must have a credential key") + }) + + 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{ + 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) + + _, hasExpiresIn := parsed["c_nonce_expires_in"] + assert.False(t, hasExpiresIn, "c_nonce_expires_in must be absent when not set") + }) +} + +// TestCredentialDefinition_Validation tests credential definition validation +func TestCredentialDefinition_Validation(t *testing.T) { + t.Run("valid definition", func(t *testing.T) { + def := &CredentialDefinition{ + Context: []ssi.URI{ + ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), + ssi.MustParseURI("https://nuts.nl/credentials/v1"), + }, + Type: []ssi.URI{ + ssi.MustParseURI("VerifiableCredential"), + ssi.MustParseURI("NutsAuthorizationCredential"), + }, + } + + err := def.Validate(true) + assert.NoError(t, err) + }) + + t.Run("credentialSubject not allowed in offer", func(t *testing.T) { + subject := map[string]interface{}{"id": "did:example:123"} + def := &CredentialDefinition{ + Context: []ssi.URI{ + ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), + }, + Type: []ssi.URI{ + ssi.MustParseURI("VerifiableCredential"), + }, + CredentialSubject: subject, + } + + err := def.Validate(true) + assert.Error(t, err) + assert.Contains(t, err.Error(), "credentialSubject not allowed") + }) +} diff --git a/vcr/openid4vci/validators.go b/vcr/openid4vci/validators.go index b9f854fbb0..011122432b 100644 --- a/vcr/openid4vci/validators.go +++ b/vcr/openid4vci/validators.go @@ -24,8 +24,11 @@ import ( "github.com/nuts-foundation/go-did/vc" ) -// Validate the CredentialDefinition according to the VerifiableCredentialJSONLDFormat format -func (cd *CredentialDefinition) Validate(isOffer bool) error { +// Validate the CredentialDefinition according to the VerifiableCredentialJSONLDFormat format. +// When rejectCredentialSubject is true, the presence of credentialSubject causes a validation error. +// This should be set to true when validating credential offers (Section 4.1.1) where credentialSubject is not allowed, +// and false when validating metadata (Appendix A.1.2) where it is permitted. +func (cd *CredentialDefinition) Validate(rejectCredentialSubject bool) error { if cd == nil { return errors.New("invalid credential_definition: missing") } @@ -36,7 +39,7 @@ func (cd *CredentialDefinition) Validate(isOffer bool) error { return errors.New("invalid credential_definition: missing type field") } if cd.CredentialSubject != nil { - if isOffer { + if rejectCredentialSubject { return errors.New("invalid credential_definition: credentialSubject not allowed in offer") } // TODO: Add credentialSubject validation. @@ -49,7 +52,7 @@ func (cd *CredentialDefinition) Validate(isOffer bool) error { // CredentialDefinition is assumed to be valid, see ValidateCredentialDefinition. func ValidateDefinitionWithCredential(credential vc.VerifiableCredential, definition CredentialDefinition) error { // From spec: When the format value is ldp_vc, ..., including credential_definition object, MUST NOT be processed using JSON-LD rules. - // https://openid.bitbucket.io/connect/editors-draft/openid-4-verifiable-credential-issuance-1_0.html#name-format-identifier-2 + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#appendix-A.1.2 // compare contexts. The credential may contain extra contexts for signatures or proofs if len(credential.Context) < len(definition.Context) || !isSubset(credential.Context, definition.Context) { diff --git a/vcr/openid4vci/validators_test.go b/vcr/openid4vci/validators_test.go index d5d3572bad..569f642b1e 100644 --- a/vcr/openid4vci/validators_test.go +++ b/vcr/openid4vci/validators_test.go @@ -50,7 +50,7 @@ func Test_ValidateCredentialDefinition(t *testing.T) { definition := &CredentialDefinition{ Context: []ssi.URI{ssi.MustParseURI("http://example.com")}, Type: []ssi.URI{ssi.MustParseURI("SomeCredentialType")}, - CredentialSubject: new(map[string]any), + CredentialSubject: map[string]any{}, } err := definition.Validate(true) diff --git a/vcr/openid4vci/wallet_client_test.go b/vcr/openid4vci/wallet_client_test.go index 5310eb4ec0..e8c5a5fabd 100644 --- a/vcr/openid4vci/wallet_client_test.go +++ b/vcr/openid4vci/wallet_client_test.go @@ -66,10 +66,12 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { require.NoError(t, err) err = client.OfferCredential(ctx, CredentialOffer{ - CredentialIssuer: setup.issuerMetadata.CredentialIssuer, - Credentials: []OfferedCredential{}, - Grants: map[string]interface{}{ - "grant_type": "pre-authorized_code", + CredentialIssuer: setup.issuerMetadata.CredentialIssuer, + CredentialConfigurationIDs: []string{}, + Grants: &CredentialOfferGrants{ + PreAuthorizedCode: &PreAuthorizedCodeParams{ + PreAuthorizedCode: "test-code", + }, }, }) @@ -84,8 +86,10 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { err = json.Unmarshal([]byte(credentialOfferJSON), &credentialOffer) require.NoError(t, err) require.Equal(t, setup.issuerMetadata.CredentialIssuer, credentialOffer["credential_issuer"]) - require.Equal(t, []interface{}{}, credentialOffer["credentials"]) - require.Equal(t, map[string]interface{}{"grant_type": "pre-authorized_code"}, credentialOffer["grants"]) + require.Equal(t, []interface{}{}, credentialOffer["credential_configuration_ids"]) + grants := credentialOffer["grants"].(map[string]interface{}) + preAuthGrant := grants[PreAuthorizedCodeGrant].(map[string]interface{}) + require.Equal(t, "test-code", preAuthGrant["pre-authorized_code"]) }) t.Run("error - invalid response from wallet", func(t *testing.T) { setup := setupClientTest(t) @@ -94,10 +98,12 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { require.NoError(t, err) err = client.OfferCredential(ctx, CredentialOffer{ - CredentialIssuer: setup.issuerMetadata.CredentialIssuer, - Credentials: []OfferedCredential{}, - Grants: map[string]interface{}{ - "grant_type": "pre-authorized_code", + CredentialIssuer: setup.issuerMetadata.CredentialIssuer, + CredentialConfigurationIDs: []string{}, + Grants: &CredentialOfferGrants{ + PreAuthorizedCode: &PreAuthorizedCodeParams{ + PreAuthorizedCode: "test-code", + }, }, }) @@ -112,10 +118,12 @@ func Test_httpWalletClient_OfferCredential(t *testing.T) { require.NoError(t, err) err = client.OfferCredential(ctx, CredentialOffer{ - CredentialIssuer: setup.issuerMetadata.CredentialIssuer, - Credentials: []OfferedCredential{}, - Grants: map[string]interface{}{ - "grant_type": "pre-authorized_code", + CredentialIssuer: setup.issuerMetadata.CredentialIssuer, + CredentialConfigurationIDs: []string{}, + Grants: &CredentialOfferGrants{ + PreAuthorizedCode: &PreAuthorizedCodeParams{ + PreAuthorizedCode: "test-code", + }, }, }) diff --git a/vcr/test/openid4vci_integration_test.go b/vcr/test/openid4vci_integration_test.go index 3e90ed0761..605e71efe4 100644 --- a/vcr/test/openid4vci_integration_test.go +++ b/vcr/test/openid4vci_integration_test.go @@ -21,13 +21,6 @@ package test import ( "bytes" "encoding/json" - "github.com/nuts-foundation/nuts-node/core" - "github.com/nuts-foundation/nuts-node/jsonld" - "github.com/nuts-foundation/nuts-node/vcr/issuer" - "github.com/nuts-foundation/nuts-node/vcr/openid4vci" - "github.com/nuts-foundation/nuts-node/vdr/didsubject" - "github.com/nuts-foundation/nuts-node/vdr/resolver" - "github.com/stretchr/testify/assert" "io" "net/http" "net/url" @@ -38,10 +31,16 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/test" "github.com/nuts-foundation/nuts-node/test/node" "github.com/nuts-foundation/nuts-node/vcr" credentialTypes "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vcr/issuer" + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" + "github.com/nuts-foundation/nuts-node/vdr/didsubject" + "github.com/nuts-foundation/nuts-node/vdr/resolver" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -126,7 +125,7 @@ func TestOpenID4VCIErrorResponses(t *testing.T) { require.NoError(t, err) requestBody, _ := json.Marshal(openid4vci.CredentialRequest{ - Format: vc.JSONLDCredentialProofFormat, + CredentialConfigurationID: "NutsOrganizationCredential_ldp_vc", }) t.Run("error from API layer (missing access token)", func(t *testing.T) { @@ -143,7 +142,7 @@ func TestOpenID4VCIErrorResponses(t *testing.T) { t.Run("error from service layer (unknown access token)", func(t *testing.T) { httpRequest, _ := http.NewRequest("POST", issuer.Metadata().CredentialEndpoint, bytes.NewReader(requestBody)) httpRequest.Header.Set("Content-Type", "application/json") - httpRequest.Header.Set("Authentication", "Bearer not-a-valid-token") + httpRequest.Header.Set("Authorization", "Bearer not-a-valid-token") httpResponse, err := http.DefaultClient.Do(httpRequest) @@ -158,10 +157,11 @@ func testCredential() vc.VerifiableCredential { issuanceDate := time.Now().Truncate(time.Second) return vc.VerifiableCredential{ Context: []ssi.URI{ - jsonld.JWS2020ContextV1URI(), + vc.VCContextV1URI(), credentialTypes.NutsV1ContextURI, }, Type: []ssi.URI{ + vc.VerifiableCredentialTypeV1URI(), ssi.MustParseURI("NutsAuthorizationCredential"), }, IssuanceDate: issuanceDate,