From 74bfc6039e4edda3aae5260f8a79acb23465d0e9 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Wed, 11 Mar 2026 14:49:46 +0100 Subject: [PATCH 1/9] feat(openid4vci): validate authorization_details against metadata Validate authorization_details entries per v1.0 Section 5.1.1: - type must be "openid_credential" - credential_configuration_id is required and must exist in issuer credential_configurations_supported - Inject locations field when authorization_servers is present - Sanitize entries to only known keys to prevent arbitrary JSON passthrough - Reject multiple entries (single credential issuance only) Add CredentialConfigurationsSupported to OpenIDCredentialIssuerMetadata. Also fix nil context usage throughout openid4vci_test.go: use context.Background() for method calls and gomock.Any() for mock expectations. --- auth/api/iam/openid4vci.go | 47 +++++- auth/api/iam/openid4vci_test.go | 284 ++++++++++++++++++++++---------- auth/oauth/types.go | 11 +- 3 files changed, 248 insertions(+), 94 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index e9b3eb5da..0f9715221 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -76,13 +76,18 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques clientID := r.subjectToBaseURL(request.SubjectID) - // Read and parse the authorization details + // Validate and process 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 + var sanitized []map[string]interface{} + credentialConfigID, sanitized, err = validateAuthorizationDetails(request.Body.AuthorizationDetails, credentialIssuerMetadata) + if err != nil { + return nil, core.InvalidInputError("%s", err) + } + authorizationDetails, err = json.Marshal(sanitized) + if err != nil { + return nil, fmt.Errorf("failed to marshal authorization_details: %w", err) } } // Generate the state and PKCE @@ -226,3 +231,37 @@ func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audien } return proofJwt, nil } + +// validateAuthorizationDetails validates the authorization_details entries per v1.0 Section 5.1.1. +// It returns the credential_configuration_id and sanitized entries (only known keys, with locations injected). +// Only a single entry is supported; multiple entries are rejected. +func validateAuthorizationDetails(details []map[string]interface{}, metadata *oauth.OpenIDCredentialIssuerMetadata) (string, []map[string]interface{}, error) { + if len(details) != 1 { + return "", nil, errors.New("invalid authorization_details: exactly one entry is supported") + } + if len(metadata.CredentialConfigurationsSupported) == 0 { + return "", nil, errors.New("invalid authorization_details: issuer does not advertise any credential configurations") + } + entry := details[0] + typ, _ := entry["type"].(string) + if typ != "openid_credential" { + return "", nil, errors.New("invalid authorization_details: type must be \"openid_credential\"") + } + configID, ok := entry["credential_configuration_id"].(string) + if !ok || configID == "" { + return "", nil, errors.New("invalid authorization_details: credential_configuration_id is required") + } + if _, exists := metadata.CredentialConfigurationsSupported[configID]; !exists { + return "", nil, fmt.Errorf("invalid authorization_details: credential_configuration_id %q not found in issuer metadata", configID) + } + // Build sanitized entry with only known fields + sanitized := map[string]interface{}{ + "type": typ, + "credential_configuration_id": configID, + } + // Inject locations when authorization_servers is present (v1.0 Section 5.1.1) + if len(metadata.AuthorizationServers) > 0 { + sanitized["locations"] = []string{metadata.CredentialIssuer} + } + return configID, []map[string]interface{}{sanitized}, nil +} diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 7cdf016df..596a4b60b 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -44,28 +44,31 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { CredentialIssuer: "issuer", CredentialEndpoint: "endpoint", AuthorizationServers: []string{authServer}, - Display: nil, + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "NutsOrganizationCredential_ldp_vc": {"format": "ldp_vc"}, + }, + Display: nil, } authzMetadata := oauth.AuthorizationServerMetadata{ AuthorizationEndpoint: "https://auth.server/authorize", TokenEndpoint: "https://auth.server/token", ClientIdSchemesSupported: clientIdSchemesSupported, } - t.Run("ok", func(t *testing.T) { + t.Run("ok - locations injected when authorization_servers present", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) - response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, RequestOpenid4VCICredentialIssuanceRequestObject{ + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "format": "vc+sd-jwt"}}, + AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), }, }) require.NoError(t, err) - require.NotNil(t, response) //RequestOid4vciCredentialIssuanceResponseObject + require.NotNil(t, response) redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) require.NoError(t, err) assert.Equal(t, "auth.server", redirectUri.Host) @@ -76,7 +79,118 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { assert.Equal(t, holderClientID, redirectUri.Query().Get("client_id")) assert.Equal(t, "S256", redirectUri.Query().Get("code_challenge_method")) assert.Equal(t, "code", redirectUri.Query().Get("response_type")) - assert.Equal(t, `[{"format":"vc+sd-jwt","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) + assert.Equal(t, `[{"credential_configuration_id":"NutsOrganizationCredential_ldp_vc","locations":["issuer"],"type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) + }) + t.Run("ok - no locations when authorization_servers absent", func(t *testing.T) { + ctx := newTestClient(t) + metadataNoAS := oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: issuerClientID, + CredentialEndpoint: "endpoint", + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "NutsOrganizationCredential_ldp_vc": {"format": "ldp_vc"}, + }, + } + authzMetadataLocal := oauth.AuthorizationServerMetadata{ + AuthorizationEndpoint: "https://auth.server/authorize", + TokenEndpoint: "https://auth.server/token", + ClientIdSchemesSupported: clientIdSchemesSupported, + } + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadataNoAS, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), issuerClientID).Return(&authzMetadataLocal, nil) + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + require.NoError(t, err) + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + assert.Equal(t, `[{"credential_configuration_id":"NutsOrganizationCredential_ldp_vc","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) + }) + t.Run("ok - unknown keys in authorization_details are stripped", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc", "evil_key": "injected"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + require.NoError(t, err) + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + assert.Equal(t, `[{"credential_configuration_id":"NutsOrganizationCredential_ldp_vc","locations":["issuer"],"type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) + }) + t.Run("error - multiple authorization_details entries", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: []map[string]interface{}{ + {"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}, + {"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}, + }, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "invalid authorization_details: exactly one entry is supported") + }) + t.Run("error - authorization_details type is not openid_credential", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: []map[string]interface{}{{"type": "invalid_type", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "invalid authorization_details: type must be \"openid_credential\"") + }) + t.Run("error - authorization_details entry missing credential_configuration_id", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "invalid authorization_details: credential_configuration_id is required") + }) + t.Run("error - credential_configuration_id not in issuer metadata", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "unknown_config"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "invalid authorization_details: credential_configuration_id \"unknown_config\" not found in issuer metadata") }) t.Run("openid4vciMetadata", func(t *testing.T) { t.Run("ok - fallback to issuerDID on empty AuthorizationServers", func(t *testing.T) { @@ -87,25 +201,25 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { AuthorizationServers: []string{}, // empty Display: nil, } - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, issuerClientID).Return(nil, assert.AnError) - _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), issuerClientID).Return(nil, assert.AnError) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.ErrorIs(t, err, assert.AnError) }) t.Run("error - none of the authorization servers can be reached", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, issuerClientID).Return(nil, assert.AnError) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(nil, assert.AnError) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), issuerClientID).Return(nil, assert.AnError) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(nil, assert.AnError) - _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.ErrorIs(t, err, assert.AnError) }) t.Run("error - fetching credential issuer metadata fails", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(nil, assert.AnError) - _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(nil, assert.AnError) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.ErrorIs(t, err, assert.AnError) }) }) @@ -114,21 +228,21 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { req.Body.Issuer = "" ctx := newTestClient(t) - _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), req) assert.EqualError(t, err, "issuer is empty") }) t.Run("error - invalid authorization endpoint in metadata", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) invalidAuthzMetadata := oauth.AuthorizationServerMetadata{ AuthorizationEndpoint: ":", TokenEndpoint: "https://auth.server/token", ClientIdSchemesSupported: []string{"did"}, } - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&invalidAuthzMetadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&invalidAuthzMetadata, nil) - _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "failed to parse the authorization_endpoint: parse \":\": missing protocol scheme") }) @@ -136,27 +250,27 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { ctx := newTestClient(t) metadata := metadata metadata.CredentialEndpoint = "" - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) - _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "no credential_endpoint found") }) t.Run("error - missing authorization_endpoint", func(t *testing.T) { ctx := newTestClient(t) authzMetadata := authzMetadata authzMetadata.AuthorizationEndpoint = "" - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) - _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "no authorization_endpoint found") }) t.Run("error - missing token_endpoint", func(t *testing.T) { ctx := newTestClient(t) authzMetadata := authzMetadata authzMetadata.TokenEndpoint = "" - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) - _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), requestCredentials(holderSubjectID, issuerClientID, redirectURI)) assert.EqualError(t, err, "no token_endpoint found") }) } @@ -215,8 +329,8 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("ok - with nonce endpoint", func(t *testing.T) { ctx := newTestClient(t) require.NoError(t, ctx.client.oauthClientStateStore().Put(state, &session)) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").DoAndReturn(func(_ context.Context, claims map[string]interface{}, headers map[string]interface{}, key interface{}) (string, error) { assert.Equal(t, map[string]interface{}{"typ": "openid4vci-proof+jwt", "kid": "kid"}, headers) @@ -229,11 +343,11 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Equal(t, expectedClaims, claims) return "signed-proof", nil }) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) - ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) - callback, err := ctx.client.Callback(nil, CallbackRequestObject{ + callback, err := ctx.client.Callback(context.Background(), CallbackRequestObject{ SubjectID: holderSubjectID, Params: CallbackParams{ Code: to.Ptr(code), @@ -248,18 +362,18 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { }) t.Run("ok - no nonce endpoint", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").DoAndReturn(func(_ context.Context, claims map[string]interface{}, headers map[string]interface{}, key interface{}) (string, error) { _, hasNonce := claims["nonce"] assert.False(t, hasNonce, "nonce should not be set when no nonce endpoint is configured") return "signed-proof", nil }) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) - ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionWithoutNonce) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &sessionWithoutNonce) require.NoError(t, err) assert.NotNil(t, callback) @@ -269,20 +383,20 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { freshNonce := "fresh-nonce" invalidNonceErr := openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: 400} - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) // first attempt fails with invalid_nonce ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil).Times(2) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-1", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof-1").Return(nil, invalidNonceErr) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof-1").Return(nil, invalidNonceErr) // retry with fresh nonce - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(freshNonce, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(freshNonce, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-2", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof-2").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof-2").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) - ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) require.NoError(t, err) assert.NotNil(t, callback) @@ -291,17 +405,17 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx := newTestClient(t) invalidNonceErr := openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: 400} - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil).Times(2) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-1", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof-1").Return(nil, invalidNonceErr) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof-1").Return(nil, invalidNonceErr) // retry also fails - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("fresh-nonce", nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return("fresh-nonce", nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-2", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof-2").Return(nil, errors.New("still failing")) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof-2").Return(nil, errors.New("still failing")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.ErrorContains(t, err, "error while fetching the credential from endpoint") @@ -310,34 +424,34 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx := newTestClient(t) invalidNonceErr := openid4vci.Error{Code: openid4vci.InvalidNonce, StatusCode: 400} - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(nil, invalidNonceErr) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(nil, invalidNonceErr) // retry nonce fetch fails - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("", errors.New("nonce endpoint down")) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return("", errors.New("nonce endpoint down")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.ErrorContains(t, err, "error fetching nonce for retry") }) t.Run("error - initial nonce request fails", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("", errors.New("nonce endpoint unavailable")) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return("", errors.New("nonce endpoint unavailable")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.ErrorContains(t, err, "error fetching nonce from") }) t.Run("fail_access_token", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(nil, errors.New("FAIL")) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(nil, errors.New("FAIL")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Error(t, err) assert.Nil(t, callback) @@ -345,65 +459,65 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { }) t.Run("fail_credential_response", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(nil, errors.New("FAIL")) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(nil, errors.New("FAIL")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.EqualError(t, err, "server_error - error while fetching the credential from endpoint https://auth.server/credz, error: FAIL") }) t.Run("err - invalid credential", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ Credentials: []openid4vci.CredentialResponseEntry{{Credential: json.RawMessage(`"super invalid"`)}}, }, nil) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.ErrorContains(t, err, "error while parsing the credential") }) t.Run("fail_verify", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil).Return(errors.New("FAIL")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.EqualError(t, err, "server_error - error while verifying the credential from issuer: did:web:example.com:iam:issuer, error: FAIL") }) t.Run("error - key not found", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("", nil, resolver.ErrKeyNotFound) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.ErrorContains(t, err, "failed to resolve key for did (did:web:example.com:iam:holder): "+resolver.ErrKeyNotFound.Error()) }) t.Run("error - signature failure", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("", errors.New("signature failed")) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.ErrorContains(t, err, "failed to sign the JWT with kid (kid): signature failed") @@ -413,22 +527,22 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { sessionNilDID := session sessionNilDID.OwnDID = nil - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionNilDID) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &sessionNilDID) assert.Nil(t, callback) assert.ErrorContains(t, err, "missing wallet DID in session") }) t.Run("error - empty credentials array", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.iamClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ Credentials: []openid4vci.CredentialResponseEntry{}, }, nil) - callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) assert.Nil(t, callback) assert.ErrorContains(t, err, "credential response does not contain any credentials") diff --git a/auth/oauth/types.go b/auth/oauth/types.go index 4224c072a..340ca3fae 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -405,11 +405,12 @@ type Redirect struct { // OpenIDCredentialIssuerMetadata represents the metadata of an OpenID credential issuer type OpenIDCredentialIssuerMetadata struct { - CredentialIssuer string `json:"credential_issuer"` - CredentialEndpoint string `json:"credential_endpoint"` - NonceEndpoint string `json:"nonce_endpoint,omitempty"` - AuthorizationServers []string `json:"authorization_servers,omitempty"` - Display []map[string]string `json:"display,omitempty"` + CredentialIssuer string `json:"credential_issuer"` + CredentialEndpoint string `json:"credential_endpoint"` + NonceEndpoint string `json:"nonce_endpoint,omitempty"` + AuthorizationServers []string `json:"authorization_servers,omitempty"` + CredentialConfigurationsSupported map[string]map[string]interface{} `json:"credential_configurations_supported,omitempty"` + Display []map[string]string `json:"display,omitempty"` } // OpenIDConfiguration represents the OpenID configuration From a2e52674ce06422c7b66206c8e5e7abcba915fb6 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Wed, 11 Mar 2026 15:24:59 +0100 Subject: [PATCH 2/9] feat(openid4vci): validate proof_signing_alg_values_supported Check holder's signing algorithm against the issuer's advertised proof_signing_alg_values_supported (v1.0 Appendix F.1) in both the authorization code flow and pre-authorized code flow. Shared validation logic extracted to openid4vci.ValidateProofSigningAlg. --- auth/api/iam/openid4vci.go | 32 +++++++-- auth/api/iam/openid4vci_test.go | 36 ++++++++++ auth/api/iam/session.go | 2 + vcr/holder/openid.go | 19 +++++- vcr/holder/openid_test.go | 114 ++++++++++++++++++++++++++++++++ vcr/openid4vci/types.go | 44 +++++++++++- vcr/openid4vci/types_test.go | 66 ++++++++++++++++++ 7 files changed, 307 insertions(+), 6 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 0f9715221..f9726368f 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -96,6 +96,16 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques // Figure out our own redirect URL by parsing the did:web and extracting the host. redirectUri := clientID.JoinPath(oauth.CallbackPath) + // Extract proof_signing_alg_values_supported from the credential configuration (v1.0 Appendix F.1) + var proofSigningAlgValues []string + if credentialConfigID != "" { + if config, exists := credentialIssuerMetadata.CredentialConfigurationsSupported[credentialConfigID]; exists { + proofSigningAlgValues, err = openid4vci.ProofSigningAlgValues(config) + if err != nil { + return nil, core.Error(http.StatusFailedDependency, "%s", err) + } + } + } // Store the session err = r.oauthClientStateStore().Put(state, &OAuthSession{ AuthorizationServerMetadata: authzServerMetadata, @@ -111,6 +121,7 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, IssuerNonceEndpoint: credentialIssuerMetadata.NonceEndpoint, IssuerCredentialConfigurationID: credentialConfigID, + ProofSigningAlgValuesSupported: proofSigningAlgValues, }) if err != nil { return nil, fmt.Errorf("failed to store session: %w", err) @@ -201,25 +212,38 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode } 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) + proofJWT, err := r.openid4vciProof(ctx, oauthSession, nonce) if err != nil { return nil, fmt.Errorf("error building proof: %w", err) } return r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, accessToken, oauthSession.IssuerCredentialConfigurationID, proofJWT) } -func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audience string, nonce string) (string, error) { - kid, _, err := r.keyResolver.ResolveKey(holderDid, nil, resolver.AssertionMethod) +func (r *Wrapper) openid4vciProof(ctx context.Context, session *OAuthSession, nonce string) (string, error) { + if session.OwnDID == nil { + return "", errors.New("session has no holder DID") + } + holderDid := *session.OwnDID + kid, pubKey, err := r.keyResolver.ResolveKey(holderDid, nil, resolver.AssertionMethod) if err != nil { return "", fmt.Errorf("failed to resolve key for did (%s): %w", holderDid.String(), err) } + if len(session.ProofSigningAlgValuesSupported) > 0 { + alg, algErr := crypto.SignatureAlgorithm(pubKey) + if algErr != nil { + return "", fmt.Errorf("failed to determine signing algorithm: %w", algErr) + } + if err = openid4vci.ValidateProofSigningAlg(alg.String(), session.ProofSigningAlgValuesSupported); err != nil { + return "", err + } + } headers := map[string]interface{}{ "typ": openid4vci.JWTTypeOpenID4VCIProof, // MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725]. "kid": kid, // JOSE Header containing the key ID. If the Credential shall be bound to a DID, the kid refers to a DID URL which identifies a particular key in the DID Document that the Credential shall be bound to. } claims := map[string]interface{}{ jwt.IssuerKey: holderDid.String(), - jwt.AudienceKey: audience, // Credential Issuer Identifier + jwt.AudienceKey: session.IssuerURL, // Credential Issuer Identifier jwt.IssuedAtKey: timeFunc().Unix(), } if nonce != "" { diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 596a4b60b..00092e441 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -20,6 +20,9 @@ package iam import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "encoding/json" "errors" "net/url" @@ -532,6 +535,39 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Nil(t, callback) assert.ErrorContains(t, err, "missing wallet DID in session") }) + t.Run("error - signing algorithm not supported by issuer", func(t *testing.T) { + ctx := newTestClient(t) + p256Key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + sessionAlgMismatch := session + sessionAlgMismatch.ProofSigningAlgValuesSupported = []string{"ES384"} + + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", &p256Key.PublicKey, nil) + + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &sessionAlgMismatch) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "signing algorithm ES256 is not supported by issuer (supported: ES384)") + }) + t.Run("ok - algorithm validation skipped when proof_signing_alg_values_supported absent", func(t *testing.T) { + ctx := newTestClient(t) + sessionNoAlg := session + sessionNoAlg.ProofSigningAlgValuesSupported = nil + + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &sessionNoAlg) + + require.NoError(t, err) + assert.NotNil(t, callback) + }) t.Run("error - empty credentials array", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index 09ef6fcd9..1fcebbdcc 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -59,6 +59,8 @@ type OAuthSession struct { IssuerNonceEndpoint string `json:"issuer_nonce_endpoint,omitempty"` // IssuerCredentialConfigurationID: the credential_configuration_id for the credential request in the OpenID4VCI flow IssuerCredentialConfigurationID string `json:"issuer_credential_configuration_id,omitempty"` + // ProofSigningAlgValuesSupported: algorithms the issuer accepts for proof JWTs (v1.0 Appendix F.1) + ProofSigningAlgValuesSupported []string `json:"proof_signing_alg_values_supported,omitempty"` } // oauthClientFlow is used by a client to identify the flow a particular callback is part of diff --git a/vcr/holder/openid.go b/vcr/holder/openid.go index 3ae8b5a32..c4b23bf6c 100644 --- a/vcr/holder/openid.go +++ b/vcr/holder/openid.go @@ -250,10 +250,27 @@ func (h *openidHandler) resolveCredentialConfiguration(metadata openid4vci.Crede } func (h *openidHandler) retrieveCredential(ctx context.Context, issuerClient openid4vci.IssuerAPIClient, credentialConfigID string, tokenResponse *oauth.TokenResponse) (*vc.VerifiableCredential, error) { - keyID, _, err := h.resolver.ResolveKey(h.did, nil, resolver.NutsSigningKeyType) + keyID, pubKey, err := h.resolver.ResolveKey(h.did, nil, resolver.NutsSigningKeyType) if err != nil { return nil, err } + if credentialConfigID != "" { + if config, exists := issuerClient.Metadata().CredentialConfigurationsSupported[credentialConfigID]; exists { + supportedAlgs, algErr := openid4vci.ProofSigningAlgValues(config) + if algErr != nil { + return nil, algErr + } + if len(supportedAlgs) > 0 { + alg, algErr := crypto.SignatureAlgorithm(pubKey) + if algErr != nil { + return nil, fmt.Errorf("failed to determine signing algorithm: %w", algErr) + } + if err = openid4vci.ValidateProofSigningAlg(alg.String(), supportedAlgs); err != nil { + return nil, err + } + } + } + } const maxAttempts = 2 for attempt := range maxAttempts { diff --git a/vcr/holder/openid_test.go b/vcr/holder/openid_test.go index c3e749bd9..d14256ee0 100644 --- a/vcr/holder/openid_test.go +++ b/vcr/holder/openid_test.go @@ -20,6 +20,9 @@ package holder import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "errors" "net/http" "testing" @@ -568,6 +571,117 @@ func Test_wallet_RetrieveCredentialWithNonceEndpoint(t *testing.T) { }) } +func Test_wallet_ProofSigningAlgValidation(t *testing.T) { + credentialOffer := openid4vci.CredentialOffer{ + CredentialIssuer: issuerDID.String(), + CredentialConfigurationIDs: []string{"ExampleCredential_ldp_vc"}, + Grants: &openid4vci.CredentialOfferGrants{ + PreAuthorizedCode: &openid4vci.PreAuthorizedCodeParams{ + PreAuthorizedCode: "code", + }, + }, + } + t.Run("error - signing algorithm not supported by issuer", func(t *testing.T) { + ctrl := gomock.NewController(t) + metadataAlgRestricted := openid4vci.CredentialIssuerMetadata{ + CredentialIssuer: issuerDID.String(), + CredentialEndpoint: "credential-endpoint", + NonceEndpoint: "https://issuer.example/nonce", + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "ExampleCredential_ldp_vc": { + "format": "ldp_vc", + "proof_types_supported": map[string]interface{}{ + "jwt": map[string]interface{}{ + "proof_signing_alg_values_supported": []interface{}{"ES384"}, + }, + }, + "credential_definition": map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1", "https://example.com/credentials/v1"}, + "type": []interface{}{"VerifiableCredential", "ExampleCredential"}, + }, + }, + }, + } + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadataAlgRestricted).AnyTimes() + tokenResponse := &oauth.TokenResponse{AccessToken: "access-token", TokenType: "bearer"} + issuerAPIClient.EXPECT().RequestAccessToken("urn:ietf:params:oauth:grant-type:pre-authorized_code", map[string]string{ + "pre-authorized_code": "code", + }).Return(tokenResponse, nil) + + p256Key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", &p256Key.PublicKey, nil) + + w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, crypto.NewMockJWTSigner(ctrl), keyResolver).(*openidHandler) + w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { + return issuerAPIClient, nil + } + + err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) + + require.EqualError(t, err, "server_error - unable to retrieve credential: signing algorithm ES256 is not supported by issuer (supported: ES384)") + }) + t.Run("ok - algorithm validation skipped when proof_types_supported absent", func(t *testing.T) { + ctrl := gomock.NewController(t) + metadataNoProofTypes := openid4vci.CredentialIssuerMetadata{ + CredentialIssuer: issuerDID.String(), + CredentialEndpoint: "credential-endpoint", + NonceEndpoint: "https://issuer.example/nonce", + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "ExampleCredential_ldp_vc": { + "format": "ldp_vc", + "credential_definition": map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1", "https://example.com/credentials/v1"}, + "type": []interface{}{"VerifiableCredential", "ExampleCredential"}, + }, + }, + }, + } + issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) + issuerAPIClient.EXPECT().Metadata().Return(metadataNoProofTypes).AnyTimes() + nonce := "nonce-from-endpoint" + issuerAPIClient.EXPECT().RequestNonce(gomock.Any()).Return(&openid4vci.NonceResponse{CNonce: nonce}, nil) + tokenResponse := &oauth.TokenResponse{AccessToken: "access-token", TokenType: "bearer"} + issuerAPIClient.EXPECT().RequestAccessToken("urn:ietf:params:oauth:grant-type:pre-authorized_code", map[string]string{ + "pre-authorized_code": "code", + }).Return(tokenResponse, nil) + expectedRequest := openid4vci.CredentialRequest{ + CredentialConfigurationID: "ExampleCredential_ldp_vc", + Proofs: &openid4vci.CredentialRequestProofs{Jwt: []string{"signed-jwt"}}, + } + issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), expectedRequest, "access-token"). + Return(&vc.VerifiableCredential{ + Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), ssi.MustParseURI("https://example.com/credentials/v1")}, + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("ExampleCredential")}, + Issuer: issuerDID.URI()}, nil) + + credentialStore := types.NewMockWriter(ctrl) + jwtSigner := crypto.NewMockJWTSigner(ctrl) + nowFunc = func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) } + t.Cleanup(func() { nowFunc = time.Now }) + jwtSigner.EXPECT().SignJWT(gomock.Any(), map[string]interface{}{ + "iss": holderDID.String(), + "aud": issuerDID.String(), + "iat": int64(1767225600), + "nonce": nonce, + }, gomock.Any(), "key-id").Return("signed-jwt", nil) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("key-id", nil, nil) + + w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, credentialStore, jwtSigner, keyResolver).(*openidHandler) + w.issuerClientCreator = func(_ context.Context, _ core.HTTPRequestDoer, _ string) (openid4vci.IssuerAPIClient, error) { + return issuerAPIClient, nil + } + + credentialStore.EXPECT().StoreCredential(gomock.Any(), nil).Return(nil) + + err := w.HandleCredentialOffer(audit.TestContext(), credentialOffer) + + require.NoError(t, err) + }) +} + // offeredCredential returns a resolved credential configuration for testing. func offeredCredential() []openid4vci.OfferedCredential { return []openid4vci.OfferedCredential{{ diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index d2afe6875..95d7712cc 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -22,8 +22,12 @@ package openid4vci import ( "encoding/json" - ssi "github.com/nuts-foundation/go-did" + "fmt" + "slices" + "strings" "time" + + ssi "github.com/nuts-foundation/go-did" ) // PreAuthorizedCodeGrant is the grant type used for pre-authorized code grant from the OpenID4VCI specification. @@ -178,6 +182,44 @@ type CredentialResponseEntry struct { Credential json.RawMessage `json:"credential"` } +// ProofSigningAlgValues extracts proof_signing_alg_values_supported from a credential configuration's +// proof_types_supported.jwt section (v1.0 Appendix F.1). +// Returns nil if proof_types_supported is absent or does not contain a jwt key. +// Returns an error if jwt is present but proof_signing_alg_values_supported is missing (spec violation). +func ProofSigningAlgValues(config map[string]interface{}) ([]string, error) { + proofTypes, ok := config["proof_types_supported"].(map[string]interface{}) + if !ok { + return nil, nil + } + jwtConfig, ok := proofTypes["jwt"].(map[string]interface{}) + if !ok { + return nil, nil + } + algValues, ok := jwtConfig["proof_signing_alg_values_supported"].([]interface{}) + if !ok { + return nil, fmt.Errorf("issuer metadata has proof_types_supported.jwt but is missing proof_signing_alg_values_supported") + } + result := make([]string, 0, len(algValues)) + for _, v := range algValues { + if s, ok := v.(string); ok { + result = append(result, s) + } + } + return result, nil +} + +// ValidateProofSigningAlg checks that the given algorithm is in the issuer's supported list. +// If supportedAlgs is empty, validation is skipped (issuer imposes no constraint). +func ValidateProofSigningAlg(alg string, supportedAlgs []string) error { + if len(supportedAlgs) == 0 { + return nil + } + if !slices.Contains(supportedAlgs, alg) { + return fmt.Errorf("signing algorithm %s is not supported by issuer (supported: %s)", alg, strings.Join(supportedAlgs, ", ")) + } + return nil +} + // Config holds the config for the OpenID4VCI credential issuer and wallet type Config struct { // DefinitionsDIR defines the directory where the additional credential definitions are stored diff --git a/vcr/openid4vci/types_test.go b/vcr/openid4vci/types_test.go index aad0d7058..798745824 100644 --- a/vcr/openid4vci/types_test.go +++ b/vcr/openid4vci/types_test.go @@ -232,6 +232,72 @@ func TestCredentialResponse_V1Spec(t *testing.T) { }) } +func TestProofSigningAlgValues(t *testing.T) { + t.Run("returns values when present", func(t *testing.T) { + config := map[string]interface{}{ + "proof_types_supported": map[string]interface{}{ + "jwt": map[string]interface{}{ + "proof_signing_alg_values_supported": []interface{}{"ES256", "ES384"}, + }, + }, + } + result, err := ProofSigningAlgValues(config) + require.NoError(t, err) + assert.Equal(t, []string{"ES256", "ES384"}, result) + }) + t.Run("returns nil when proof_types_supported absent", func(t *testing.T) { + config := map[string]interface{}{"format": "ldp_vc"} + result, err := ProofSigningAlgValues(config) + require.NoError(t, err) + assert.Nil(t, result) + }) + t.Run("returns nil when jwt absent in proof_types_supported", func(t *testing.T) { + config := map[string]interface{}{ + "proof_types_supported": map[string]interface{}{ + "cwt": map[string]interface{}{}, + }, + } + result, err := ProofSigningAlgValues(config) + require.NoError(t, err) + assert.Nil(t, result) + }) + t.Run("error - jwt present but proof_signing_alg_values_supported absent", func(t *testing.T) { + config := map[string]interface{}{ + "proof_types_supported": map[string]interface{}{ + "jwt": map[string]interface{}{}, + }, + } + result, err := ProofSigningAlgValues(config) + assert.Nil(t, result) + assert.EqualError(t, err, "issuer metadata has proof_types_supported.jwt but is missing proof_signing_alg_values_supported") + }) + t.Run("skips non-string values in algorithm array", func(t *testing.T) { + config := map[string]interface{}{ + "proof_types_supported": map[string]interface{}{ + "jwt": map[string]interface{}{ + "proof_signing_alg_values_supported": []interface{}{"ES256", 42, "ES384"}, + }, + }, + } + result, err := ProofSigningAlgValues(config) + require.NoError(t, err) + assert.Equal(t, []string{"ES256", "ES384"}, result) + }) +} + +func TestValidateProofSigningAlg(t *testing.T) { + t.Run("ok - algorithm is supported", func(t *testing.T) { + assert.NoError(t, ValidateProofSigningAlg("ES256", []string{"ES256", "ES384"})) + }) + t.Run("ok - no constraint when supportedAlgs is empty", func(t *testing.T) { + assert.NoError(t, ValidateProofSigningAlg("ES256", nil)) + }) + t.Run("error - algorithm not supported", func(t *testing.T) { + err := ValidateProofSigningAlg("ES256", []string{"ES384", "ES512"}) + assert.EqualError(t, err, "signing algorithm ES256 is not supported by issuer (supported: ES384, ES512)") + }) +} + // TestCredentialDefinition_Validation tests credential definition validation func TestCredentialDefinition_Validation(t *testing.T) { t.Run("valid definition", func(t *testing.T) { From cbfd342aca8d5a641aa73562edf52e7379002e28 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Wed, 11 Mar 2026 15:40:47 +0100 Subject: [PATCH 3/9] feat(openid4vci): detect deferred credential issuance Detect transaction_id in credential responses (v1.0 Section 8.3) and return a clear error instead of a generic "no credentials" message. The transaction_id value is logged at warn level but excluded from error messages to prevent leaking issuer-internal state. --- auth/api/iam/openid4vci.go | 3 +++ auth/api/iam/openid4vci_test.go | 15 +++++++++++++++ vcr/openid4vci/issuer_client.go | 4 ++++ vcr/openid4vci/issuer_client_test.go | 11 +++++++++++ vcr/openid4vci/types.go | 4 +++- vcr/openid4vci/types_test.go | 26 ++++++++++++++++++++++++++ 6 files changed, 62 insertions(+), 1 deletion(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index f9726368f..60ba965bb 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -189,6 +189,9 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while fetching the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI) } } + if credentialResponse.TransactionID != "" { + return nil, withCallbackURI(oauthError(oauth.ServerError, "deferred credential issuance is not supported"), appCallbackURI) + } if len(credentialResponse.Credentials) == 0 { return nil, withCallbackURI(oauthError(oauth.ServerError, "credential response does not contain any credentials"), appCallbackURI) } diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 00092e441..36bbaca1e 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -568,6 +568,21 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { require.NoError(t, err) assert.NotNil(t, callback) }) + t.Run("error - deferred issuance not supported", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ + TransactionID: "txn-456", + }, nil) + + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) + + assert.Nil(t, callback) + assert.ErrorContains(t, err, "deferred credential issuance is not supported") + }) t.Run("error - empty credentials array", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) diff --git a/vcr/openid4vci/issuer_client.go b/vcr/openid4vci/issuer_client.go index d7f91dc48..a807a45b5 100644 --- a/vcr/openid4vci/issuer_client.go +++ b/vcr/openid4vci/issuer_client.go @@ -102,6 +102,10 @@ func (h defaultIssuerAPIClient) RequestCredential(ctx context.Context, request C if err != nil { return nil, err } + if credentialResponse.TransactionID != "" { + log.Logger().Warnf("Issuer returned deferred credential response (transaction_id: %s)", credentialResponse.TransactionID) + return nil, errors.New("deferred credential issuance is not supported") + } if len(credentialResponse.Credentials) == 0 { return nil, errors.New("credential response does not contain any credentials") } diff --git a/vcr/openid4vci/issuer_client_test.go b/vcr/openid4vci/issuer_client_test.go index 8f4b07c7b..cf56691c4 100644 --- a/vcr/openid4vci/issuer_client_test.go +++ b/vcr/openid4vci/issuer_client_test.go @@ -101,6 +101,17 @@ func Test_httpIssuerClient_RequestCredential(t *testing.T) { require.NoError(t, err) require.NotNil(t, credential) }) + t.Run("error - deferred issuance not supported", func(t *testing.T) { + setup := setupClientTest(t) + setup.credentialHandler = setup.httpPostHandler(CredentialResponse{TransactionID: "txn-123"}) + client, err := NewIssuerAPIClient(ctx, httpClient, setup.issuerMetadata.CredentialIssuer) + require.NoError(t, err) + + credential, err := client.RequestCredential(ctx, credentialRequest, "token") + + require.EqualError(t, err, "deferred credential issuance is not supported") + require.Nil(t, credential) + }) t.Run("error - no credentials in response", func(t *testing.T) { setup := setupClientTest(t) setup.credentialHandler = setup.httpPostHandler(CredentialResponse{}) diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index 95d7712cc..60de3a395 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -172,8 +172,10 @@ type CredentialRequestProofs struct { // Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-response // In v1.0, when proofs (plural) is used in the request, the response uses `credentials` (array of wrapper objects). // Each element contains a `credential` key holding the actual issued credential. +// When deferred issuance is used (Section 8.3), the response contains a `transaction_id` instead of credentials. type CredentialResponse struct { - Credentials []CredentialResponseEntry `json:"credentials,omitempty"` + Credentials []CredentialResponseEntry `json:"credentials,omitempty"` + TransactionID string `json:"transaction_id,omitempty"` } // CredentialResponseEntry is a single entry in the credentials array of a CredentialResponse. diff --git a/vcr/openid4vci/types_test.go b/vcr/openid4vci/types_test.go index 798745824..9b702a06f 100644 --- a/vcr/openid4vci/types_test.go +++ b/vcr/openid4vci/types_test.go @@ -214,6 +214,32 @@ func TestCredentialResponse_V1Spec(t *testing.T) { assert.NotNil(t, entry["credential"], "each entry must have a credential key") }) + t.Run("deferred response with transaction_id", func(t *testing.T) { + responseJSON := `{"transaction_id": "txn-abc"}` + + var response CredentialResponse + err := json.Unmarshal([]byte(responseJSON), &response) + require.NoError(t, err) + + assert.Equal(t, "txn-abc", response.TransactionID) + assert.Empty(t, response.Credentials) + }) + t.Run("transaction_id omitted when empty", func(t *testing.T) { + credJSON, _ := json.Marshal(map[string]interface{}{"issuer": "did:nuts:issuer"}) + response := CredentialResponse{ + Credentials: []CredentialResponseEntry{{Credential: credJSON}}, + } + + jsonBytes, err := json.Marshal(response) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(jsonBytes, &parsed) + require.NoError(t, err) + + _, hasTransactionID := parsed["transaction_id"] + assert.False(t, hasTransactionID, "transaction_id must be absent when empty") + }) t.Run("response does not contain c_nonce fields", func(t *testing.T) { credJSON, _ := json.Marshal(map[string]interface{}{"issuer": "did:nuts:issuer"}) response := CredentialResponse{ From 26242a3018df52258acfb5f7b569b64ff279b3bd Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Wed, 11 Mar 2026 17:46:29 +0100 Subject: [PATCH 4/9] feat(openid4vci): add PAR support (RFC 9126) Use Pushed Authorization Requests when the AS metadata advertises a pushed_authorization_request_endpoint. All authorization parameters are POSTed server-to-server; the browser redirect carries only client_id and the returned request_uri. Falls back to query parameters when PAR is not advertised. --- auth/api/iam/openid4vci.go | 38 ++++++++++++++----- auth/api/iam/openid4vci_test.go | 62 +++++++++++++++++++++++++++++++ auth/client/iam/client.go | 32 ++++++++++++++++ auth/client/iam/interface.go | 9 +++++ auth/client/iam/mock.go | 16 ++++++++ auth/client/iam/openid4vp.go | 8 ++++ auth/client/iam/openid4vp_test.go | 56 ++++++++++++++++++++++++++++ auth/oauth/types.go | 3 ++ 8 files changed, 214 insertions(+), 10 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 60ba965bb..24932859b 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -131,16 +131,34 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques if err != nil { return nil, fmt.Errorf("failed to parse the authorization_endpoint: %w", err) } - redirectUrl := nutsHttp.AddQueryParams(*authorizationEndpoint, map[string]string{ - oauth.ResponseTypeParam: oauth.CodeResponseType, - oauth.StateParam: state, - oauth.ClientIDParam: clientID.String(), - oauth.ClientIDSchemeParam: entityClientIDScheme, - oauth.AuthorizationDetailsParam: string(authorizationDetails), - oauth.RedirectURIParam: redirectUri.String(), - oauth.CodeChallengeParam: pkceParams.Challenge, - oauth.CodeChallengeMethodParam: pkceParams.ChallengeMethod, - }) + authzParams := url.Values{ + oauth.ResponseTypeParam: {oauth.CodeResponseType}, + oauth.StateParam: {state}, + oauth.ClientIDParam: {clientID.String()}, + oauth.ClientIDSchemeParam: {entityClientIDScheme}, + oauth.AuthorizationDetailsParam: {string(authorizationDetails)}, + oauth.RedirectURIParam: {redirectUri.String()}, + oauth.CodeChallengeParam: {pkceParams.Challenge}, + oauth.CodeChallengeMethodParam: {pkceParams.ChallengeMethod}, + } + + var redirectUrl url.URL + if authzServerMetadata.PushedAuthorizationRequestEndpoint != "" { + parResponse, parErr := r.auth.IAMClient().PushedAuthorizationRequest(ctx, authzServerMetadata.PushedAuthorizationRequestEndpoint, authzParams) + if parErr != nil { + return nil, fmt.Errorf("PAR request failed: %w", parErr) + } + redirectUrl = nutsHttp.AddQueryParams(*authorizationEndpoint, map[string]string{ + oauth.ClientIDParam: clientID.String(), + "request_uri": parResponse.RequestURI, + }) + } else { + params := make(map[string]string, len(authzParams)) + for k, v := range authzParams { + params[k] = v[0] + } + redirectUrl = nutsHttp.AddQueryParams(*authorizationEndpoint, params) + } return RequestOpenid4VCICredentialIssuance200JSONResponse{ RedirectURI: redirectUrl.String(), diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 36bbaca1e..d316b371a 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -31,6 +31,7 @@ import ( "github.com/nuts-foundation/nuts-node/core/to" + iamclient "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" @@ -195,6 +196,67 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { }) assert.EqualError(t, err, "invalid authorization_details: credential_configuration_id \"unknown_config\" not found in issuer metadata") }) + t.Run("ok - uses PAR when endpoint advertised", func(t *testing.T) { + ctx := newTestClient(t) + parEndpoint := "https://auth.server/par" + authzMetadataWithPAR := oauth.AuthorizationServerMetadata{ + AuthorizationEndpoint: "https://auth.server/authorize", + TokenEndpoint: "https://auth.server/token", + ClientIdSchemesSupported: clientIdSchemesSupported, + PushedAuthorizationRequestEndpoint: parEndpoint, + } + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadataWithPAR, nil) + ctx.iamClient.EXPECT().PushedAuthorizationRequest(gomock.Any(), parEndpoint, gomock.Any()).DoAndReturn(func(_ context.Context, _ string, params url.Values) (*iamclient.PARResponse, error) { + assert.Equal(t, oauth.CodeResponseType, params.Get(oauth.ResponseTypeParam)) + assert.Equal(t, holderClientID, params.Get(oauth.ClientIDParam)) + assert.NotEmpty(t, params.Get(oauth.StateParam)) + assert.NotEmpty(t, params.Get(oauth.CodeChallengeParam)) + return &iamclient.PARResponse{RequestURI: "urn:ietf:params:oauth:request_uri:xyz", ExpiresIn: 60}, nil + }) + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + require.NoError(t, err) + require.NotNil(t, response) + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + assert.Equal(t, "auth.server", redirectUri.Host) + assert.Equal(t, "/authorize", redirectUri.Path) + assert.Equal(t, holderClientID, redirectUri.Query().Get("client_id")) + assert.Equal(t, "urn:ietf:params:oauth:request_uri:xyz", redirectUri.Query().Get("request_uri")) + assert.Empty(t, redirectUri.Query().Get("state"), "state should not be in redirect when using PAR") + assert.Empty(t, redirectUri.Query().Get("code_challenge"), "code_challenge should not be in redirect when using PAR") + }) + t.Run("error - PAR request fails", func(t *testing.T) { + ctx := newTestClient(t) + parEndpoint := "https://auth.server/par" + authzMetadataWithPAR := oauth.AuthorizationServerMetadata{ + AuthorizationEndpoint: "https://auth.server/authorize", + TokenEndpoint: "https://auth.server/token", + ClientIdSchemesSupported: clientIdSchemesSupported, + PushedAuthorizationRequestEndpoint: parEndpoint, + } + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadataWithPAR, nil) + ctx.iamClient.EXPECT().PushedAuthorizationRequest(gomock.Any(), parEndpoint, gomock.Any()).Return(nil, errors.New("PAR failed")) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "PAR request failed: PAR failed") + }) t.Run("openid4vciMetadata", func(t *testing.T) { t.Run("ok - fallback to issuerDID on empty AuthorizationServers", func(t *testing.T) { ctx := newTestClient(t) diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index db9694e4f..f5e7243bd 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -338,6 +338,38 @@ func (hb HTTPClient) KeyProvider() jws.KeyProviderFunc { } } +func (hb HTTPClient) PushedAuthorizationRequest(ctx context.Context, parEndpoint string, params url.Values) (*PARResponse, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parEndpoint, strings.NewReader(params.Encode())) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := hb.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("PAR request failed: %w", err) + } + defer response.Body.Close() + data, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("unable to read PAR response: %w", err) + } + if response.StatusCode != http.StatusCreated { + bodySnippet := string(data) + if len(bodySnippet) > core.HttpResponseBodyLogClipAt { + bodySnippet = bodySnippet[:core.HttpResponseBodyLogClipAt] + "...(clipped)" + } + return nil, fmt.Errorf("PAR endpoint returned HTTP %d (expected: 201): %s", response.StatusCode, bodySnippet) + } + var parResponse PARResponse + if err = json.Unmarshal(data, &parResponse); err != nil { + return nil, fmt.Errorf("unable to unmarshal PAR response: %w", err) + } + if !strings.HasPrefix(parResponse.RequestURI, "urn:ietf:params:oauth:request_uri:") { + return nil, fmt.Errorf("PAR response contains invalid request_uri: %q", parResponse.RequestURI) + } + return &parResponse, nil +} + func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJwt string) (*openid4vci.CredentialResponse, error) { credentialEndpointURL, err := url.Parse(credentialEndpoint) if err != nil { diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 5d55262aa..43e0ced37 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -20,6 +20,7 @@ package iam import ( "context" + "net/url" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" @@ -27,6 +28,12 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/pe" ) +// PARResponse holds the response from a Pushed Authorization Request (RFC 9126). +type PARResponse struct { + RequestURI string `json:"request_uri"` + ExpiresIn int `json:"expires_in"` +} + // Client defines OpenID4VP client methods using the IAM OpenAPI Spec. type Client interface { // AccessToken requests an access token at the oauth2 token endpoint. @@ -58,6 +65,8 @@ type Client interface { RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) // VerifiableCredentials requests Verifiable Credentials from the issuer at the given endpoint. VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJWT string) (*openid4vci.CredentialResponse, error) + // PushedAuthorizationRequest sends a Pushed Authorization Request (RFC 9126) to the given endpoint. + PushedAuthorizationRequest(ctx context.Context, parEndpoint string, params url.Values) (*PARResponse, error) // RequestObjectByGet retrieves the RequestObjectByGet from the authorization request's 'request_uri' endpoint using a GET method as defined in RFC9101/OpenID4VP. // This method is used when there is no 'request_uri_method', or its value is 'get'. RequestObjectByGet(ctx context.Context, requestURI string) (string, error) diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index d8a3e4192..8c7238ee1 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -11,6 +11,7 @@ package iam import ( context "context" + url "net/url" reflect "reflect" vc "github.com/nuts-foundation/go-did/vc" @@ -163,6 +164,21 @@ func (mr *MockClientMockRecorder) PresentationDefinition(ctx, endpoint any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinition", reflect.TypeOf((*MockClient)(nil).PresentationDefinition), ctx, endpoint) } +// PushedAuthorizationRequest mocks base method. +func (m *MockClient) PushedAuthorizationRequest(ctx context.Context, parEndpoint string, params url.Values) (*PARResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PushedAuthorizationRequest", ctx, parEndpoint, params) + ret0, _ := ret[0].(*PARResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PushedAuthorizationRequest indicates an expected call of PushedAuthorizationRequest. +func (mr *MockClientMockRecorder) PushedAuthorizationRequest(ctx, parEndpoint, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PushedAuthorizationRequest", reflect.TypeOf((*MockClient)(nil).PushedAuthorizationRequest), ctx, parEndpoint, params) +} + // RequestNonce mocks base method. func (m *MockClient) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { m.ctrl.T.Helper() diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index a3d56cb2d..9f3977efc 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -360,6 +360,14 @@ func (c *OpenID4VPClient) RequestNonce(ctx context.Context, nonceEndpoint string return c.httpClient.RequestNonce(ctx, nonceEndpoint) } +func (c *OpenID4VPClient) PushedAuthorizationRequest(ctx context.Context, parEndpoint string, params url.Values) (*PARResponse, error) { + parsedURL, err := core.ParsePublicURL(parEndpoint, c.strictMode) + if err != nil { + return nil, fmt.Errorf("invalid PAR endpoint: %w", err) + } + return c.httpClient.PushedAuthorizationRequest(ctx, parsedURL.String(), params) +} + func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJWT string) (*openid4vci.CredentialResponse, error) { iamClient := c.httpClient rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 7422661be..a6ceeda01 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -527,6 +527,7 @@ type clientServerTestContext struct { nonce func(writer http.ResponseWriter) credentials func(writer http.ResponseWriter) requestObjectJWT func(writer http.ResponseWriter) + par func(writer http.ResponseWriter, request *http.Request) } func createClientServerTestContext(t *testing.T) *clientServerTestContext { @@ -650,6 +651,11 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { ctx.requestObjectJWT(writer) return } + case "/par": + if ctx.par != nil { + ctx.par(writer, request) + return + } } writer.WriteHeader(http.StatusNotFound) } @@ -715,6 +721,56 @@ func TestIAMClient_RequestNonce(t *testing.T) { }) } +func TestIAMClient_PushedAuthorizationRequest(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.par = func(writer http.ResponseWriter, request *http.Request) { + assert.Equal(t, http.MethodPost, request.Method) + assert.Equal(t, "application/x-www-form-urlencoded", request.Header.Get("Content-Type")) + assert.NoError(t, request.ParseForm()) + assert.Equal(t, "value1", request.PostFormValue("key1")) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusCreated) + _, _ = writer.Write([]byte(`{"request_uri":"urn:ietf:params:oauth:request_uri:abc123","expires_in":60}`)) + } + + params := url.Values{"key1": {"value1"}} + response, err := ctx.client.PushedAuthorizationRequest(context.Background(), ctx.tlsServer.URL+"/par", params) + + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, "urn:ietf:params:oauth:request_uri:abc123", response.RequestURI) + assert.Equal(t, 60, response.ExpiresIn) + }) + t.Run("error - server returns error status", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.par = func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusBadRequest) + _, _ = writer.Write([]byte(`{"error":"invalid_request"}`)) + } + + response, err := ctx.client.PushedAuthorizationRequest(context.Background(), ctx.tlsServer.URL+"/par", url.Values{}) + + assert.Error(t, err) + assert.Nil(t, response) + assert.ErrorContains(t, err, "PAR endpoint returned HTTP 400 (expected: 201)") + }) + t.Run("error - response has invalid request_uri", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.par = func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusCreated) + _, _ = writer.Write([]byte(`{"request_uri":"https://evil.com/steal","expires_in":60}`)) + } + + response, err := ctx.client.PushedAuthorizationRequest(context.Background(), ctx.tlsServer.URL+"/par", url.Values{}) + + assert.Error(t, err) + assert.Nil(t, response) + assert.ErrorContains(t, err, "PAR response contains invalid request_uri") + }) +} + func TestIAMClient_VerifiableCredentials(t *testing.T) { accessToken := "code" proofJWT := "top secret" diff --git a/auth/oauth/types.go b/auth/oauth/types.go index 340ca3fae..bfd36c578 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -308,6 +308,9 @@ type AuthorizationServerMetadata struct { /* ******** JWT-Secured Authorization Request RFC9101 & OpenID Connect Core v1.0: §6. Passing Request Parameters as JWTs ******** */ + // PushedAuthorizationRequestEndpoint is the URL of the pushed authorization request endpoint (RFC 9126). + PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint,omitempty"` + // RequireSignedRequestObject specifies if the authorization server requires the use of signed request objects. RequireSignedRequestObject bool `json:"require_signed_request_object,omitempty"` From aaef59bce989972f2fe8dc9e0cc9e000afaa6979 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Thu, 12 Mar 2026 13:35:26 +0100 Subject: [PATCH 5/9] feat(openid4vci): support credential_identifiers in token response When the token response includes authorization_details with credential_identifiers (v1.0 Section 6.2), use credential_identifier instead of credential_configuration_id in the credential request (Section 8.2). Adds GetRaw to TokenResponse for accessing non-string additional parameters. --- auth/api/iam/openid4vci.go | 44 ++++++++- auth/api/iam/openid4vci_test.go | 149 +++++++++++++++++++++++++++--- auth/client/iam/client.go | 3 +- auth/client/iam/interface.go | 3 +- auth/client/iam/mock.go | 8 +- auth/client/iam/openid4vp.go | 4 +- auth/client/iam/openid4vp_test.go | 10 +- auth/oauth/types.go | 9 ++ auth/oauth/types_test.go | 23 ++++- vcr/openid4vci/types.go | 4 + 10 files changed, 226 insertions(+), 31 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 24932859b..e178f4f78 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -191,8 +191,11 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode } } + // Check for credential_identifiers in the token response (v1.0 Section 6.2) + credentialIdentifier := extractCredentialIdentifier(tokenResponse) + // build proof and request credential - credentialResponse, err := r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, nonce) + credentialResponse, err := r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, credentialIdentifier, nonce) if err != nil { // on invalid_nonce: fetch a fresh nonce and retry once var oidcErr openid4vci.Error @@ -201,7 +204,7 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error fetching nonce for retry from %s: %s", oauthSession.IssuerNonceEndpoint, err.Error())), appCallbackURI) } - credentialResponse, err = r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, nonce) + credentialResponse, err = r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, credentialIdentifier, nonce) } if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while fetching the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI) @@ -232,12 +235,16 @@ 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) { +func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *OAuthSession, accessToken string, credentialIdentifier string, nonce string) (*openid4vci.CredentialResponse, error) { proofJWT, err := r.openid4vciProof(ctx, oauthSession, nonce) if err != nil { return nil, fmt.Errorf("error building proof: %w", err) } - return r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, accessToken, oauthSession.IssuerCredentialConfigurationID, proofJWT) + credentialConfigID := oauthSession.IssuerCredentialConfigurationID + if credentialIdentifier != "" { + credentialConfigID = "" + } + return r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, proofJWT) } func (r *Wrapper) openid4vciProof(ctx context.Context, session *OAuthSession, nonce string) (string, error) { @@ -277,6 +284,35 @@ func (r *Wrapper) openid4vciProof(ctx context.Context, session *OAuthSession, no return proofJwt, nil } +// extractCredentialIdentifier extracts the first credential_identifier from the token response's +// authorization_details (v1.0 Section 6.2). Returns empty string if not present. +// Only considers entries with type "openid_credential" per RFC 9396. +// When multiple credential_identifiers are present, the first one is used. +func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse) string { + authzDetails, ok := tokenResponse.GetRaw("authorization_details").([]interface{}) + if !ok { + return "" + } + for _, item := range authzDetails { + entry, ok := item.(map[string]interface{}) + if !ok { + continue + } + if typ, _ := entry["type"].(string); typ != "openid_credential" { + continue + } + identifiers, ok := entry["credential_identifiers"].([]interface{}) + if !ok || len(identifiers) == 0 { + continue + } + identifier, ok := identifiers[0].(string) + if ok { + return identifier + } + } + return "" +} + // validateAuthorizationDetails validates the authorization_details entries per v1.0 Section 5.1.1. // It returns the credential_configuration_id and sanitized entries (only known keys, with locations injected). // Only a single entry is supported; multiple entries are rejected. diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index d316b371a..9c3ffd62e 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -408,7 +408,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Equal(t, expectedClaims, claims) return "signed-proof", nil }) - ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) @@ -434,7 +434,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.False(t, hasNonce, "nonce should not be set when no nonce endpoint is configured") return "signed-proof", nil }) - ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) @@ -453,11 +453,11 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { // 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(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof-1").Return(nil, invalidNonceErr) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof-1").Return(nil, invalidNonceErr) // retry with fresh nonce ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(freshNonce, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-2", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof-2").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof-2").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) @@ -474,11 +474,11 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil).Times(2) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-1", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof-1").Return(nil, invalidNonceErr) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof-1").Return(nil, invalidNonceErr) // retry also fails ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return("fresh-nonce", nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-2", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof-2").Return(nil, errors.New("still failing")) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof-2").Return(nil, errors.New("still failing")) callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) @@ -493,7 +493,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(nil, invalidNonceErr) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(nil, invalidNonceErr) // retry nonce fetch fails ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return("", errors.New("nonce endpoint down")) @@ -528,7 +528,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(nil, errors.New("FAIL")) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(nil, errors.New("FAIL")) callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) @@ -541,7 +541,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&openid4vci.CredentialResponse{ Credentials: []openid4vci.CredentialResponseEntry{{Credential: json.RawMessage(`"super invalid"`)}}, }, nil) @@ -556,7 +556,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil).Return(errors.New("FAIL")) callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) @@ -621,7 +621,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&credentialResponse, nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) @@ -636,7 +636,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&openid4vci.CredentialResponse{ TransactionID: "txn-456", }, nil) @@ -651,7 +651,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "signed-proof").Return(&openid4vci.CredentialResponse{ + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&openid4vci.CredentialResponse{ Credentials: []openid4vci.CredentialResponseEntry{}, }, nil) @@ -660,4 +660,127 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Nil(t, callback) assert.ErrorContains(t, err, "credential response does not contain any credentials") }) + t.Run("ok - uses credential_identifier from token response when present", func(t *testing.T) { + ctx := newTestClient(t) + tokenResponseWithIdentifier := &oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"} + tokenResponseWithIdentifier.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + "credential_configuration_id": credentialConfigID, + "credential_identifiers": []interface{}{"cred-id-1", "cred-id-2"}, + }, + }) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponseWithIdentifier, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, "", "cred-id-1", "signed-proof").Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) + + require.NoError(t, err) + assert.NotNil(t, callback) + }) + t.Run("ok - falls back to credential_configuration_id when no credential_identifiers", func(t *testing.T) { + ctx := newTestClient(t) + tokenResponseNoIdentifiers := &oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"} + tokenResponseNoIdentifiers.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + "credential_configuration_id": credentialConfigID, + }, + }) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponseNoIdentifiers, nil) + ctx.iamClient.EXPECT().RequestNonce(gomock.Any(), nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) + ctx.iamClient.EXPECT().VerifiableCredentials(gomock.Any(), credEndpoint, accessToken, credentialConfigID, "", "signed-proof").Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(gomock.Any(), *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(context.Background(), code, &session) + + require.NoError(t, err) + assert.NotNil(t, callback) + }) +} + +func TestExtractCredentialIdentifier(t *testing.T) { + t.Run("returns first identifier from openid_credential entry", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + "credential_identifiers": []interface{}{"id-1", "id-2"}, + }, + }) + + assert.Equal(t, "id-1", extractCredentialIdentifier(tokenResponse)) + }) + t.Run("skips non-openid_credential entries", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "other_type", + "credential_identifiers": []interface{}{"wrong"}, + }, + map[string]interface{}{ + "type": "openid_credential", + "credential_identifiers": []interface{}{"correct"}, + }, + }) + + assert.Equal(t, "correct", extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when authorization_details missing", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when authorization_details is not an array", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", "not-an-array") + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when credential_identifiers missing", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + }, + }) + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when credential_identifiers is empty", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + "credential_identifiers": []interface{}{}, + }, + }) + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when identifier is not a string", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{ + map[string]interface{}{ + "type": "openid_credential", + "credential_identifiers": []interface{}{42}, + }, + }) + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) + t.Run("returns empty when entry is not a map", func(t *testing.T) { + tokenResponse := &oauth.TokenResponse{} + tokenResponse.With("authorization_details", []interface{}{"not-a-map"}) + + assert.Empty(t, extractCredentialIdentifier(tokenResponse)) + }) } diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index f5e7243bd..7ff212c47 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -370,7 +370,7 @@ func (hb HTTPClient) PushedAuthorizationRequest(ctx context.Context, parEndpoint return &parResponse, nil } -func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJwt string) (*openid4vci.CredentialResponse, error) { +func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, credentialIdentifier string, proofJwt string) (*openid4vci.CredentialResponse, error) { credentialEndpointURL, err := url.Parse(credentialEndpoint) if err != nil { return nil, err @@ -378,6 +378,7 @@ func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoi credentialRequest := openid4vci.CredentialRequest{ CredentialConfigurationID: credentialConfigID, + CredentialIdentifier: credentialIdentifier, Proofs: &openid4vci.CredentialRequestProofs{ Jwt: []string{proofJwt}, }, diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 43e0ced37..1e757e953 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -64,7 +64,8 @@ type Client interface { // RequestNonce requests a fresh c_nonce from the issuer's Nonce Endpoint (v1.0 Section 7). RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) // VerifiableCredentials requests Verifiable Credentials from the issuer at the given endpoint. - VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJWT string) (*openid4vci.CredentialResponse, error) + // Either credentialConfigID or credentialIdentifier must be non-empty (mutually exclusive per v1.0 Section 8.2). + VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, credentialIdentifier string, proofJWT string) (*openid4vci.CredentialResponse, error) // PushedAuthorizationRequest sends a Pushed Authorization Request (RFC 9126) to the given endpoint. PushedAuthorizationRequest(ctx context.Context, parEndpoint string, params url.Values) (*PARResponse, error) // RequestObjectByGet retrieves the RequestObjectByGet from the authorization request's 'request_uri' endpoint using a GET method as defined in RFC9101/OpenID4VP. diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index 8c7238ee1..6d6111ace 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -240,16 +240,16 @@ func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjec } // VerifiableCredentials mocks base method. -func (m *MockClient) VerifiableCredentials(ctx context.Context, credentialEndpoint, accessToken, credentialConfigID, proofJWT string) (*openid4vci.CredentialResponse, error) { +func (m *MockClient) VerifiableCredentials(ctx context.Context, credentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, proofJWT string) (*openid4vci.CredentialResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "VerifiableCredentials", ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) + ret := m.ctrl.Call(m, "VerifiableCredentials", ctx, credentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, proofJWT) ret0, _ := ret[0].(*openid4vci.CredentialResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // VerifiableCredentials indicates an expected call of VerifiableCredentials. -func (mr *MockClientMockRecorder) VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT any) *gomock.Call { +func (mr *MockClientMockRecorder) VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, proofJWT any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifiableCredentials", reflect.TypeOf((*MockClient)(nil).VerifiableCredentials), ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifiableCredentials", reflect.TypeOf((*MockClient)(nil).VerifiableCredentials), ctx, credentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, proofJWT) } diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 9f3977efc..747b8be66 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -368,9 +368,9 @@ func (c *OpenID4VPClient) PushedAuthorizationRequest(ctx context.Context, parEnd return c.httpClient.PushedAuthorizationRequest(ctx, parsedURL.String(), params) } -func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, proofJWT string) (*openid4vci.CredentialResponse, error) { +func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, credentialIdentifier string, proofJWT string) (*openid4vci.CredentialResponse, error) { iamClient := c.httpClient - rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, proofJWT) + rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, proofJWT) if err != nil { return nil, err } diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index a6ceeda01..78d3a292c 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -779,7 +779,7 @@ func TestIAMClient_VerifiableCredentials(t *testing.T) { t.Run("ok", func(t *testing.T) { ctx := createClientServerTestContext(t) - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, "", proofJWT) require.NoError(t, err) require.NotNil(t, response) @@ -794,7 +794,7 @@ func TestIAMClient_VerifiableCredentials(t *testing.T) { _, _ = writer.Write([]byte(`{"credentials": [{"credential": {"@context": ["https://www.w3.org/2018/credentials/v1"], "type": ["VerifiableCredential"]}}]}`)) } - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, "", proofJWT) require.NoError(t, err) require.NotNil(t, response) @@ -805,7 +805,7 @@ func TestIAMClient_VerifiableCredentials(t *testing.T) { ctx := createClientServerTestContext(t) ctx.credentials = nil - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, "", proofJWT) assert.Error(t, err) assert.Nil(t, response) @@ -818,7 +818,7 @@ func TestIAMClient_VerifiableCredentials(t *testing.T) { _, _ = writer.Write([]byte(`{"error": "invalid_nonce"}`)) } - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, "", proofJWT) assert.Nil(t, response) require.Error(t, err) @@ -834,7 +834,7 @@ func TestIAMClient_VerifiableCredentials(t *testing.T) { _, _ = writer.Write([]byte(`{"credentials": fail}`)) } - response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, proofJWT) + response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, credentialConfigID, "", proofJWT) assert.Error(t, err) assert.Nil(t, response) diff --git a/auth/oauth/types.go b/auth/oauth/types.go index bfd36c578..ad258f199 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -101,6 +101,15 @@ func (t *TokenResponse) With(key string, value interface{}) *TokenResponse { return t } +// GetRaw returns the raw value of an additional parameter. +// Returns nil if the key does not exist. +func (t TokenResponse) GetRaw(key string) interface{} { + if t.additionalParams == nil { + return nil + } + return t.additionalParams[key] +} + // Get returns the value of the additional parameter with the given key as a string. // If the key does not exist or the value is not a string, it returns an empty string. // It should not be used to get any of the base parameters (access_token, expires_in, token_type, scope). diff --git a/auth/oauth/types_test.go b/auth/oauth/types_test.go index e0932a799..dddf1e69d 100644 --- a/auth/oauth/types_test.go +++ b/auth/oauth/types_test.go @@ -20,10 +20,11 @@ package oauth import ( "encoding/json" + "testing" + "github.com/nuts-foundation/nuts-node/core/to" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "testing" ) func TestIssuerIdToWellKnown(t *testing.T) { @@ -93,6 +94,26 @@ func TestTokenResponse_Get(t *testing.T) { }) } +func TestTokenResponse_GetRaw(t *testing.T) { + t.Run("nil map", func(t *testing.T) { + var tr TokenResponse + assert.Nil(t, tr.GetRaw("key")) + }) + t.Run("returns stored value", func(t *testing.T) { + tr := TokenResponse{} + expected := []interface{}{"a", "b"} + tr.With("details", expected) + + assert.Equal(t, expected, tr.GetRaw("details")) + }) + t.Run("returns nil for missing key", func(t *testing.T) { + tr := TokenResponse{} + tr.With("other", "value") + + assert.Nil(t, tr.GetRaw("missing")) + }) +} + func TestAuthorizationServerMetadata_SupportsClientIDScheme(t *testing.T) { m := AuthorizationServerMetadata{ ClientIdSchemesSupported: []string{"did"}, diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index 60de3a395..c7150e57b 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -156,7 +156,11 @@ type CredentialOfferResponse struct { // Per v1.0 Section 8.2, the request identifies the credential using credential_configuration_id. type CredentialRequest struct { // CredentialConfigurationID references a credential configuration from issuer metadata. + // Mutually exclusive with CredentialIdentifier. CredentialConfigurationID string `json:"credential_configuration_id,omitempty"` + // CredentialIdentifier references a specific credential from the token response's authorization_details. + // Mutually exclusive with CredentialConfigurationID. See v1.0 Section 8.2. + CredentialIdentifier string `json:"credential_identifier,omitempty"` // Proofs contains the proof(s) of possession of the key material. Proofs *CredentialRequestProofs `json:"proofs,omitempty"` } From 8643a6439558702669dca6764931c5ec8d6c9804 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Mon, 16 Mar 2026 13:48:03 +0100 Subject: [PATCH 6/9] feat(openid4vci): support scope-based credential requests Add scope as an alternative to authorization_details for requesting credentials (v1.0 Section 5.1.2). The scope is resolved against the issuer's credential_configurations_supported metadata. Both scope and authorization_details can be provided simultaneously per the spec. Only a single scope value is supported, consistent with the single-entry restriction for authorization_details. --- auth/api/iam/generated.go | 7 +- auth/api/iam/openid4vci.go | 68 +++++++++++--- auth/api/iam/openid4vci_test.go | 158 +++++++++++++++++++++++++++++--- docs/_static/auth/v2.yaml | 8 +- 4 files changed, 215 insertions(+), 26 deletions(-) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 5dbe21544..6232594bf 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -206,7 +206,7 @@ type Cnf struct { // RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance. type RequestOpenid4VCICredentialIssuanceJSONBody struct { - AuthorizationDetails []map[string]interface{} `json:"authorization_details"` + AuthorizationDetails *[]map[string]interface{} `json:"authorization_details,omitempty"` // Issuer The OAuth Authorization Server's identifier, that issues the Verifiable Credentials, as specified in RFC 8414 (section 2), // used to locate the OAuth2 Authorization Server metadata. @@ -215,6 +215,11 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // RedirectUri The URL to which the user-agent will be redirected after the authorization request. RedirectUri string `json:"redirect_uri"` + // Scope OAuth2 scope value mapped to a credential configuration in the issuer's metadata (v1.0 Section 5.1.2). + // The issuer's credential_configurations_supported must contain an entry with a matching 'scope' field. + // Can be used together with authorization_details; the issuer interprets them individually. + Scope *string `json:"scope,omitempty"` + // WalletDid The DID to which the Verifiable Credential must be issued. Must be owned by the given subject. WalletDid string `json:"wallet_did"` } diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index e178f4f78..0af04ff2b 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -25,6 +25,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "time" "github.com/lestrrat-go/jwx/v2/jwt" @@ -76,12 +77,18 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques clientID := r.subjectToBaseURL(request.SubjectID) - // Validate and process authorization details - authorizationDetails := []byte("[]") + // Validate authorization_details and/or scope (at least one must be provided) + hasAuthzDetails := request.Body.AuthorizationDetails != nil && len(*request.Body.AuthorizationDetails) > 0 + hasScope := request.Body.Scope != nil && *request.Body.Scope != "" + if !hasAuthzDetails && !hasScope { + return nil, core.InvalidInputError("either authorization_details or scope is required") + } + + var authorizationDetails []byte var credentialConfigID string - if len(request.Body.AuthorizationDetails) > 0 { + if hasAuthzDetails { var sanitized []map[string]interface{} - credentialConfigID, sanitized, err = validateAuthorizationDetails(request.Body.AuthorizationDetails, credentialIssuerMetadata) + credentialConfigID, sanitized, err = validateAuthorizationDetails(*request.Body.AuthorizationDetails, credentialIssuerMetadata) if err != nil { return nil, core.InvalidInputError("%s", err) } @@ -90,6 +97,19 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques return nil, fmt.Errorf("failed to marshal authorization_details: %w", err) } } + + // Resolve credential_configuration_id from scope (v1.0 Section 5.1.2) + if hasScope { + scopeConfigID, scopeErr := resolveCredentialConfigIDByScope(*request.Body.Scope, credentialIssuerMetadata) + if scopeErr != nil { + return nil, core.InvalidInputError("%s", scopeErr) + } + // Use scope's credential_configuration_id when authorization_details didn't provide one + if credentialConfigID == "" { + credentialConfigID = scopeConfigID + } + } + // Generate the state and PKCE state := crypto.GenerateNonce() pkceParams := generatePKCEParams() @@ -132,14 +152,19 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques return nil, fmt.Errorf("failed to parse the authorization_endpoint: %w", err) } authzParams := url.Values{ - oauth.ResponseTypeParam: {oauth.CodeResponseType}, - oauth.StateParam: {state}, - oauth.ClientIDParam: {clientID.String()}, - oauth.ClientIDSchemeParam: {entityClientIDScheme}, - oauth.AuthorizationDetailsParam: {string(authorizationDetails)}, - oauth.RedirectURIParam: {redirectUri.String()}, - oauth.CodeChallengeParam: {pkceParams.Challenge}, - oauth.CodeChallengeMethodParam: {pkceParams.ChallengeMethod}, + oauth.ResponseTypeParam: {oauth.CodeResponseType}, + oauth.StateParam: {state}, + oauth.ClientIDParam: {clientID.String()}, + oauth.ClientIDSchemeParam: {entityClientIDScheme}, + oauth.RedirectURIParam: {redirectUri.String()}, + oauth.CodeChallengeParam: {pkceParams.Challenge}, + oauth.CodeChallengeMethodParam: {pkceParams.ChallengeMethod}, + } + if hasAuthzDetails { + authzParams.Set(oauth.AuthorizationDetailsParam, string(authorizationDetails)) + } + if hasScope { + authzParams.Set(oauth.ScopeParam, *request.Body.Scope) } var redirectUrl url.URL @@ -313,6 +338,25 @@ func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse) string { return "" } +// resolveCredentialConfigIDByScope finds the credential_configuration_id that matches the given scope +// in the issuer's credential_configurations_supported (v1.0 Section 5.1.2). +// Per the spec, scope is a space-separated list where each value maps to a credential configuration. +// Only a single scope value is supported; multiple values are rejected (consistent with the +// single-entry restriction for authorization_details). +func resolveCredentialConfigIDByScope(scope string, metadata *oauth.OpenIDCredentialIssuerMetadata) (string, error) { + scopeValues := strings.Fields(scope) + if len(scopeValues) != 1 { + return "", fmt.Errorf("invalid scope: exactly one scope value is supported, got %d", len(scopeValues)) + } + scopeValue := scopeValues[0] + for configID, config := range metadata.CredentialConfigurationsSupported { + if s, _ := config["scope"].(string); s == scopeValue { + return configID, nil + } + } + return "", fmt.Errorf("scope %q not found in issuer's credential configurations", scopeValue) +} + // validateAuthorizationDetails validates the authorization_details entries per v1.0 Section 5.1.1. // It returns the credential_configuration_id and sanitized entries (only known keys, with locations injected). // Only a single entry is supported; multiple entries are rejected. diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 9c3ffd62e..58c6e9e32 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -65,7 +65,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -104,7 +104,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -122,7 +122,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc", "evil_key": "injected"}}, + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc", "evil_key": "injected"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -140,7 +140,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{ + AuthorizationDetails: &[]map[string]interface{}{ {"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}, {"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}, }, @@ -158,7 +158,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "invalid_type", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + AuthorizationDetails: &[]map[string]interface{}{{"type": "invalid_type", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -173,7 +173,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential"}}, + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -188,7 +188,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "unknown_config"}}, + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "unknown_config"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -217,7 +217,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -249,7 +249,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, Issuer: issuerClientID, RedirectUri: redirectURI, WalletDid: holderDID.String(), @@ -257,6 +257,96 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { }) assert.EqualError(t, err, "PAR request failed: PAR failed") }) + t.Run("error - neither authorization_details nor scope provided", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, "either authorization_details or scope is required") + }) + t.Run("ok - requests credential using scope", func(t *testing.T) { + ctx := newTestClient(t) + metadataWithScope := oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "issuer", + CredentialEndpoint: "endpoint", + AuthorizationServers: []string{authServer}, + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "NutsOrganizationCredential_ldp_vc": {"format": "ldp_vc", "scope": "nuts_org_credential"}, + }, + } + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadataWithScope, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + scope := "nuts_org_credential" + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + Scope: &scope, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + require.NoError(t, err) + require.NotNil(t, response) + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + assert.Equal(t, "nuts_org_credential", redirectUri.Query().Get("scope")) + assert.Empty(t, redirectUri.Query().Get("authorization_details"), "authorization_details should not be present when only scope is used") + }) + t.Run("error - scope not found in issuer credential configurations", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + scope := "unknown_scope" + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + Scope: &scope, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + assert.EqualError(t, err, `scope "unknown_scope" not found in issuer's credential configurations`) + }) + t.Run("ok - both authorization_details and scope provided", func(t *testing.T) { + ctx := newTestClient(t) + metadataWithScope := oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "issuer", + CredentialEndpoint: "endpoint", + AuthorizationServers: []string{authServer}, + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "NutsOrganizationCredential_ldp_vc": {"format": "ldp_vc"}, + "NutsOrganizationCredential_ldp_vc_v2": {"format": "ldp_vc", "scope": "nuts_org_credential_v2"}, + }, + } + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(gomock.Any(), issuerClientID).Return(&metadataWithScope, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), authServer).Return(&authzMetadata, nil) + scope := "nuts_org_credential_v2" + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(context.Background(), RequestOpenid4VCICredentialIssuanceRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Scope: &scope, + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), + }, + }) + require.NoError(t, err) + require.NotNil(t, response) + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + assert.NotEmpty(t, redirectUri.Query().Get("authorization_details"), "authorization_details should be present") + assert.Equal(t, "nuts_org_credential_v2", redirectUri.Query().Get("scope"), "scope should be present") + }) t.Run("openid4vciMetadata", func(t *testing.T) { t.Run("ok - fallback to issuerDID on empty AuthorizationServers", func(t *testing.T) { ctx := newTestClient(t) @@ -344,9 +434,10 @@ func requestCredentials(subjectID string, issuer string, redirectURI string) Req return RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: subjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - Issuer: issuer, - RedirectUri: redirectURI, - WalletDid: holderDID.String(), + AuthorizationDetails: &[]map[string]interface{}{{"type": "openid_credential", "credential_configuration_id": "NutsOrganizationCredential_ldp_vc"}}, + Issuer: issuer, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), }, } } @@ -784,3 +875,46 @@ func TestExtractCredentialIdentifier(t *testing.T) { assert.Empty(t, extractCredentialIdentifier(tokenResponse)) }) } + +func TestResolveCredentialConfigIDByScope(t *testing.T) { + t.Run("ok - finds matching config", func(t *testing.T) { + metadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "Degree": {"format": "jwt_vc_json", "scope": "UniversityDegree"}, + "Other": {"format": "ldp_vc"}, + }, + } + configID, err := resolveCredentialConfigIDByScope("UniversityDegree", metadata) + require.NoError(t, err) + assert.Equal(t, "Degree", configID) + }) + t.Run("error - scope not found", func(t *testing.T) { + metadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "Other": {"format": "ldp_vc"}, + }, + } + _, err := resolveCredentialConfigIDByScope("unknown", metadata) + assert.EqualError(t, err, `scope "unknown" not found in issuer's credential configurations`) + }) + t.Run("error - no configurations at all", func(t *testing.T) { + metadata := &oauth.OpenIDCredentialIssuerMetadata{} + _, err := resolveCredentialConfigIDByScope("test", metadata) + assert.EqualError(t, err, `scope "test" not found in issuer's credential configurations`) + }) + t.Run("error - multiple scope values", func(t *testing.T) { + metadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]map[string]interface{}{ + "Degree": {"format": "jwt_vc_json", "scope": "UniversityDegree"}, + "License": {"format": "jwt_vc_json", "scope": "DriverLicense"}, + }, + } + _, err := resolveCredentialConfigIDByScope("UniversityDegree DriverLicense", metadata) + assert.EqualError(t, err, "invalid scope: exactly one scope value is supported, got 2") + }) + t.Run("error - empty scope", func(t *testing.T) { + metadata := &oauth.OpenIDCredentialIssuerMetadata{} + _, err := resolveCredentialConfigIDByScope("", metadata) + assert.EqualError(t, err, "invalid scope: exactly one scope value is supported, got 0") + }) +} diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index c032a1ff6..78e6d9436 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -129,7 +129,6 @@ paths: schema: required: - issuer - - authorization_details - redirect_uri - wallet_did properties: @@ -157,6 +156,13 @@ paths: "credential_configuration_id": "UniversityDegreeCredential" } ] + scope: + type: string + description: | + OAuth2 scope value mapped to a credential configuration in the issuer's metadata (v1.0 Section 5.1.2). + The issuer's credential_configurations_supported must contain an entry with a matching 'scope' field. + Can be used together with authorization_details; the issuer interprets them individually. + example: UniversityDegree redirect_uri: type: string description: | From 68a9a74ceb15fe74c7a5f30db33a870914188951 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Mon, 16 Mar 2026 14:44:08 +0100 Subject: [PATCH 7/9] feat(openid4vci): verify signed_metadata in issuer metadata When the credential issuer's metadata contains signed_metadata (v1.0 Section 12.2.3), verify the JWT signature using the issuer's key from the DID document. Validates typ header, required claims (sub, iat), and compares metadata claims against the unsigned metadata. Rejects metadata if verification fails; proceeds without it if absent (field is OPTIONAL). --- auth/client/iam/client.go | 48 +++++- auth/client/iam/openid4vp_test.go | 242 +++++++++++++++++++++++++++++- auth/oauth/types.go | 2 + crypto/jwx.go | 12 ++ 4 files changed, 301 insertions(+), 3 deletions(-) diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 7ff212c47..de2412eba 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -21,6 +21,7 @@ package iam import ( "bytes" "context" + stdcrypto "crypto" "encoding/json" "errors" "fmt" @@ -282,7 +283,52 @@ func (hb HTTPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIs if err != nil { return nil, err } - return &metadata, err + if metadata.SignedMetadata != "" { + if err = hb.verifySignedMetadata(ctx, &metadata); err != nil { + return nil, fmt.Errorf("signed_metadata verification failed: %w", err) + } + } + return &metadata, nil +} + +// verifySignedMetadata verifies the signed_metadata JWT against the issuer's key (v1.0 Section 12.2.3). +// It validates the JWT signature, typ header, required claims (sub, iat), and compares +// key metadata claims (credential_issuer, credential_endpoint) against the unsigned metadata. +func (hb HTTPClient) verifySignedMetadata(ctx context.Context, metadata *oauth.OpenIDCredentialIssuerMetadata) error { + // Verify typ header to prevent JWT type confusion attacks + typ, err := crypto.JWTTyp(metadata.SignedMetadata) + if err != nil { + return fmt.Errorf("invalid JWT: %w", err) + } + if typ != "openidvci-issuer-metadata+jwt" { + return fmt.Errorf("typ header must be openidvci-issuer-metadata+jwt, got %q", typ) + } + // Parse, verify signature, and validate standard claims using shared infrastructure + token, err := crypto.ParseJWT(metadata.SignedMetadata, func(kid string) (stdcrypto.PublicKey, error) { + return hb.keyResolver.ResolveKeyByID(kid, nil, resolver.AssertionMethod) + }, jwt.WithValidate(true), jwt.WithAcceptableSkew(5*time.Second)) + if err != nil { + return fmt.Errorf("invalid JWT: %w", err) + } + // sub is REQUIRED, must match credential_issuer. iss is OPTIONAL per spec. + if token.Subject() != metadata.CredentialIssuer { + return fmt.Errorf("sub %q does not match credential_issuer %q", token.Subject(), metadata.CredentialIssuer) + } + if token.IssuedAt().IsZero() { + return fmt.Errorf("iat claim is required") + } + // Compare metadata claims from JWT payload against unsigned metadata + claims, err := token.AsMap(ctx) + if err != nil { + return fmt.Errorf("failed to extract claims: %w", err) + } + if ci, _ := claims["credential_issuer"].(string); ci != metadata.CredentialIssuer { + return fmt.Errorf("credential_issuer claim %q does not match metadata %q", ci, metadata.CredentialIssuer) + } + if ce, _ := claims["credential_endpoint"].(string); ce != "" && ce != metadata.CredentialEndpoint { + return fmt.Errorf("credential_endpoint claim %q does not match metadata %q", ce, metadata.CredentialEndpoint) + } + return nil } func (hb HTTPClient) OpenIDConfiguration(ctx context.Context, issuerURL string) (*oauth.OpenIDConfiguration, error) { diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 78d3a292c..9b361b8dd 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -20,10 +20,14 @@ package iam import ( "context" + "crypto/ecdsa" "crypto/tls" "encoding/json" "errors" "fmt" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/nuts-node/http/client" test2 "github.com/nuts-foundation/nuts-node/test" "github.com/nuts-foundation/nuts-node/vcr/credential" @@ -40,6 +44,7 @@ import ( "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/crypto" + cryptoTest "github.com/nuts-foundation/nuts-node/crypto/test" http2 "github.com/nuts-foundation/nuts-node/test/http" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" @@ -486,8 +491,9 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon wallet: wallet, subjectManager: subjectManager, httpClient: HTTPClient{ - strictMode: false, - httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig), + strictMode: false, + httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig), + keyResolver: keyResolver, }, jwtSigner: jwtSigner, keyResolver: keyResolver, @@ -697,6 +703,238 @@ func TestIAMClient_OpenIdCredentialIssuerMetadata(t *testing.T) { assert.Nil(t, response) assert.EqualError(t, err, "failed to retrieve Openid credential issuer metadata: server returned HTTP 404 (expected: 200)") }) + t.Run("ok - signed_metadata is verified", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWT(t, ecKey, kid, map[string]interface{}{ + "credential_issuer": "https://issuer.example.com", + "credential_endpoint": "https://issuer.example.com/credential", + }) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.openIDCredentialIssuerMetadata = issuerMetadata + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, "https://issuer.example.com", metadata.CredentialIssuer) + }) + t.Run("error - signed_metadata JWT signature invalid", func(t *testing.T) { + ctx := createClientServerTestContext(t) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: "invalid.jwt.token", + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "signed_metadata verification failed") + }) + t.Run("error - signed_metadata sub mismatch", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWT(t, ecKey, kid, map[string]interface{}{ + "credential_issuer": "https://other-issuer.example.com", + "credential_endpoint": "https://issuer.example.com/credential", + }) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "sub") + assert.ErrorContains(t, err, "does not match credential_issuer") + }) + t.Run("error - signed_metadata credential_endpoint mismatch", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWT(t, ecKey, kid, map[string]interface{}{ + "credential_issuer": "https://issuer.example.com", + "credential_endpoint": "https://evil.example.com/credential", + }) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "credential_endpoint claim") + assert.ErrorContains(t, err, "does not match metadata") + }) + t.Run("error - signed_metadata wrong typ header", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWTCustom(t, ecKey, kid, "jwt", map[string]interface{}{ + "iss": "https://issuer.example.com", + "sub": "https://issuer.example.com", + "credential_issuer": "https://issuer.example.com", + "credential_endpoint": "https://issuer.example.com/credential", + }, true, true) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "typ header must be openidvci-issuer-metadata+jwt") + }) + t.Run("error - signed_metadata missing sub", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWTCustom(t, ecKey, kid, "openidvci-issuer-metadata+jwt", map[string]interface{}{ + "iss": "https://issuer.example.com", + "credential_issuer": "https://issuer.example.com", + "credential_endpoint": "https://issuer.example.com/credential", + }, true, true) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "sub") + assert.ErrorContains(t, err, "does not match credential_issuer") + }) + t.Run("error - signed_metadata missing iat", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWTCustom(t, ecKey, kid, "openidvci-issuer-metadata+jwt", map[string]interface{}{ + "sub": "https://issuer.example.com", + "credential_issuer": "https://issuer.example.com", + "credential_endpoint": "https://issuer.example.com/credential", + }, false, false) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "iat claim is required") + }) +} + +func createSignedMetadataJWT(t *testing.T, key *ecdsa.PrivateKey, kid string, claims map[string]interface{}) string { + t.Helper() + token := jwt.New() + for k, v := range claims { + require.NoError(t, token.Set(k, v)) + } + if iss, ok := claims["credential_issuer"].(string); ok { + require.NoError(t, token.Set(jwt.IssuerKey, iss)) + require.NoError(t, token.Set(jwt.SubjectKey, iss)) + } + require.NoError(t, token.Set(jwt.IssuedAtKey, time.Now().Unix())) + require.NoError(t, token.Set(jwt.ExpirationKey, time.Now().Add(time.Hour).Unix())) + hdrs := jws.NewHeaders() + require.NoError(t, hdrs.Set(jws.KeyIDKey, kid)) + require.NoError(t, hdrs.Set(jws.TypeKey, "openidvci-issuer-metadata+jwt")) + signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, key, jws.WithProtectedHeaders(hdrs))) + require.NoError(t, err) + return string(signed) +} + +// createSignedMetadataJWTCustom allows overriding specific JWT fields for negative tests. +func createSignedMetadataJWTCustom(t *testing.T, key *ecdsa.PrivateKey, kid string, typ string, claims map[string]interface{}, setIat bool, setExp bool) string { + t.Helper() + token := jwt.New() + for k, v := range claims { + require.NoError(t, token.Set(k, v)) + } + if setIat { + require.NoError(t, token.Set(jwt.IssuedAtKey, time.Now().Unix())) + } + if setExp { + require.NoError(t, token.Set(jwt.ExpirationKey, time.Now().Add(time.Hour).Unix())) + } + hdrs := jws.NewHeaders() + require.NoError(t, hdrs.Set(jws.KeyIDKey, kid)) + if typ != "" { + require.NoError(t, hdrs.Set(jws.TypeKey, typ)) + } + signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, key, jws.WithProtectedHeaders(hdrs))) + require.NoError(t, err) + return string(signed) } func TestIAMClient_RequestNonce(t *testing.T) { diff --git a/auth/oauth/types.go b/auth/oauth/types.go index ad258f199..b92cf9630 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -423,6 +423,8 @@ type OpenIDCredentialIssuerMetadata struct { AuthorizationServers []string `json:"authorization_servers,omitempty"` CredentialConfigurationsSupported map[string]map[string]interface{} `json:"credential_configurations_supported,omitempty"` Display []map[string]string `json:"display,omitempty"` + // SignedMetadata is a JWT containing signed issuer metadata for trust verification (v1.0 Section 12.2.3). + SignedMetadata string `json:"signed_metadata,omitempty"` } // OpenIDConfiguration represents the OpenID configuration diff --git a/crypto/jwx.go b/crypto/jwx.go index e9bd81fcd..3670a4a00 100644 --- a/crypto/jwx.go +++ b/crypto/jwx.go @@ -165,6 +165,18 @@ func JWTKidAlg(tokenString string) (string, jwa.SignatureAlgorithm, error) { return hdrs.KeyID(), hdrs.Algorithm(), nil } +// JWTTyp parses a JWT without validation and returns the 'typ' header. +func JWTTyp(tokenString string) (string, error) { + j, err := jws.ParseString(tokenString) + if err != nil { + return "", err + } + if len(j.Signatures()) != 1 { + return "", errors.New("incorrect number of signatures in JWT") + } + return j.Signatures()[0].ProtectedHeaders().Type(), nil +} + // PublicKeyFunc defines a function that resolves a public key based on a kid type PublicKeyFunc func(kid string) (crypto.PublicKey, error) From 6b9902b82eed71052620469102856c425f86bea4 Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Mon, 16 Mar 2026 15:40:01 +0100 Subject: [PATCH 8/9] fix(openid4vci): use credential issuer identifier as proof audience The proof JWT audience (aud) must be the Credential Issuer Identifier per v1.0 Section 8.2.1.1, not the Authorization Server issuer. These differ when the credential issuer delegates to a separate AS. --- auth/api/iam/openid4vci.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 0af04ff2b..ede9d2610 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -137,7 +137,7 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques // OpenID4VCI issuers may use multiple Authorization Servers // We must use the token_endpoint that corresponds to the same Authorization Server used for the authorization_endpoint TokenEndpoint: authzServerMetadata.TokenEndpoint, - IssuerURL: authzServerMetadata.Issuer, + IssuerURL: credentialIssuerMetadata.CredentialIssuer, IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, IssuerNonceEndpoint: credentialIssuerMetadata.NonceEndpoint, IssuerCredentialConfigurationID: credentialConfigID, From ba667e5f4cec3d83eb25c77d7fbacb586fb6cdda Mon Sep 17 00:00:00 2001 From: Joris Scharp Date: Mon, 16 Mar 2026 17:06:34 +0100 Subject: [PATCH 9/9] fix(openid4vci): require credential_endpoint in signed_metadata Require credential_endpoint to be present in the signed_metadata JWT rather than silently skipping validation when absent. The spec requires all metadata parameters to be present as top-level claims in the JWT. Based on Copilot PR review feedback. --- auth/client/iam/client.go | 6 +++++- auth/client/iam/openid4vp_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index de2412eba..dd351ab48 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -325,7 +325,11 @@ func (hb HTTPClient) verifySignedMetadata(ctx context.Context, metadata *oauth.O if ci, _ := claims["credential_issuer"].(string); ci != metadata.CredentialIssuer { return fmt.Errorf("credential_issuer claim %q does not match metadata %q", ci, metadata.CredentialIssuer) } - if ce, _ := claims["credential_endpoint"].(string); ce != "" && ce != metadata.CredentialEndpoint { + ce, _ := claims["credential_endpoint"].(string) + if ce == "" { + return fmt.Errorf("credential_endpoint claim is required in signed metadata") + } + if ce != metadata.CredentialEndpoint { return fmt.Errorf("credential_endpoint claim %q does not match metadata %q", ce, metadata.CredentialEndpoint) } return nil diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 9b361b8dd..9f58a9746 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -892,6 +892,32 @@ func TestIAMClient_OpenIdCredentialIssuerMetadata(t *testing.T) { assert.Nil(t, metadata) assert.ErrorContains(t, err, "iat claim is required") }) + t.Run("error - signed_metadata missing credential_endpoint", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ecKey := cryptoTest.GenerateECKey() + kid := "did:web:example.com#key-1" + signedJWT := createSignedMetadataJWT(t, ecKey, kid, map[string]interface{}{ + "credential_issuer": "https://issuer.example.com", + }) + issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "https://issuer.example.com", + CredentialEndpoint: "https://issuer.example.com/credential", + SignedMetadata: signedJWT, + } + ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*issuerMetadata) + _, _ = writer.Write(bytes) + } + ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil) + + metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer") + + require.Error(t, err) + assert.Nil(t, metadata) + assert.ErrorContains(t, err, "credential_endpoint claim is required in signed metadata") + }) } func createSignedMetadataJWT(t *testing.T, key *ecdsa.PrivateKey, kid string, claims map[string]interface{}) string {