diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 13e0eeeda..a5be7886a 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -19,11 +19,6 @@ const ( JwtBearerAuthScopes = "jwtBearerAuth.Scopes" ) -// Defines values for AuthorizationDetailType. -const ( - OpenidCredential AuthorizationDetailType = "openid_credential" -) - // Defines values for ServiceAccessTokenRequestTokenType. const ( ServiceAccessTokenRequestTokenTypeBearer ServiceAccessTokenRequestTokenType = "Bearer" @@ -36,26 +31,6 @@ const ( UserAccessTokenRequestTokenTypeDPoP UserAccessTokenRequestTokenType = "DPoP" ) -// AuthorizationDetail A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1.1. -// Only the fields used by the user/browser issuance flow are modeled. -type AuthorizationDetail struct { - // CredentialConfigurationId References a credential configuration from the issuer's - // credential_configurations_supported metadata. REQUIRED for - // type=openid_credential per §5.1.1. - CredentialConfigurationId string `json:"credential_configuration_id"` - - // Format Optional credential format hint (e.g. "vc+sd-jwt"). - Format *string `json:"format,omitempty"` - - // Type The authorization details type. For OpenID4VCI flows this MUST - // be "openid_credential" per §5.1.1. - Type AuthorizationDetailType `json:"type"` -} - -// AuthorizationDetailType The authorization details type. For OpenID4VCI flows this MUST -// be "openid_credential" per §5.1.1. -type AuthorizationDetailType string - // DPoPRequest defines model for DPoPRequest. type DPoPRequest struct { // Htm The HTTP method for which the DPoP proof is requested. @@ -256,11 +231,6 @@ type Cnf struct { // RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance. type RequestOpenid4VCICredentialIssuanceJSONBody struct { - // AuthorizationDetails Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1.1. - // The current implementation processes a single credential - // issuance per call and only consumes the first entry. - AuthorizationDetails []AuthorizationDetail `json:"authorization_details"` - // AuthorizationRequestParams Optional key/value pairs added to the OpenID4VCI authorization request (the redirect to the // Authorization Server's authorization_endpoint). These may only add parameters; they must not // override the OpenID4VCI parameters set by the node (the request is rejected if they do). @@ -275,6 +245,21 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // responsible for the resulting wire shape (§8.2 mutual exclusivity, proof binding, etc.). CredentialRequestParams *map[string]interface{} `json:"credential_request_params,omitempty"` + // CredentialType The type of Verifiable Credential to request, e.g. "HealthcareProviderRoleTypeCredential". + // The node takes care of the OpenID4VCI details: + // 1. It reads the issuer's Credential Issuer Metadata (`/.well-known/openid-credential-issuer`, + // §12.2) and matches this type against each entry's `credential_definition.type` or + // `vct` in `credential_configurations_supported`, to find the matching + // `credential_configuration_id`. + // 2. It checks the Authorization Server's `authorization_details_types_supported` + // metadata to decide whether to send that `credential_configuration_id` via an + // `authorization_details` parameter (RFC 9396) on the authorization request, or via + // `credential_configuration_id` on the Credential Request. + // 3. Once the credential is issued, it checks the returned credential's type matches + // what was requested, before storing it. + // Only one credential can be requested per call. + CredentialType string `json:"credential_type"` + // 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. Issuer string `json:"issuer"` diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index c5817da2c..ea8796a7b 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -25,9 +25,12 @@ import ( "fmt" "net/http" "net/url" + "slices" "time" "github.com/lestrrat-go/jwx/v2/jwt" + + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" @@ -59,14 +62,9 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques if issuer == "" { return nil, core.InvalidInputError("issuer is empty") } - // Per §5.1.1 the openid_credential authorization_details flow requires at - // least one entry; the current implementation issues a single credential - // per call and only consumes the first entry. The OpenAPI schema declares - // both minItems: 1 and maxItems: 1, but the StrictServer middleware does - // not enforce array bounds at runtime, so reject here before any outbound - // metadata fetches. - if len(request.Body.AuthorizationDetails) != 1 { - return nil, core.InvalidInputError("authorization_details must contain exactly one entry") + credentialType := request.Body.CredentialType + if credentialType == "" { + return nil, core.InvalidInputError("credential_type is empty") } // Fetch metadata containing the endpoints credentialIssuerMetadata, authzServerMetadata, err := r.openid4vciMetadata(ctx, request.Body.Issuer) @@ -85,12 +83,12 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques clientID := r.subjectToBaseURL(request.SubjectID) - // Per §5.1.1, type and credential_configuration_id are required on each - // openid_credential authorization_details entry; the OpenAPI schema - // enforces both. Non-emptiness was checked above before any outbound - // metadata fetches. - authorizationDetails, _ := json.Marshal(request.Body.AuthorizationDetails) - credentialConfigID := request.Body.AuthorizationDetails[0].CredentialConfigurationId + // Resolve the caller-supplied credential_type to a credential_configuration_id by matching it + // against the issuer's credential_configurations_supported metadata (§12.2). + credentialConfigID, err := credentialIssuerMetadata.ResolveCredentialConfigurationID(credentialType) + if err != nil { + return nil, core.InvalidInputError("%w", err) + } // Capture optional credential_request_params, used as the base body of the Credential Request later in the flow. var credentialRequestParams map[string]any if request.Body.CredentialRequestParams != nil { @@ -116,6 +114,7 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, IssuerNonceEndpoint: credentialIssuerMetadata.NonceEndpoint, IssuerCredentialConfigurationID: credentialConfigID, + RequestedCredentialType: credentialType, IssuerCredentialIssuer: credentialIssuerMetadata.CredentialIssuer, CredentialRequestParams: credentialRequestParams, }) @@ -128,14 +127,24 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques return nil, fmt.Errorf("failed to parse the authorization_endpoint: %w", err) } authzParams := map[string]string{ - oauth.ResponseTypeParam: oauth.CodeResponseType, - oauth.StateParam: state, - oauth.ClientIDParam: clientID.String(), - oauth.ClientIDSchemeParam: entityClientIDScheme, - oauth.AuthorizationDetailsParam: string(authorizationDetails), - oauth.RedirectURIParam: r.callbackURL().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: r.callbackURL().String(), + oauth.CodeChallengeParam: pkceParams.Challenge, + oauth.CodeChallengeMethodParam: pkceParams.ChallengeMethod, + } + // Authorization-stage flow selection (no probing): if the AS advertises support for the + // openid_credential authorization_details type, use the Authorization Details flow. Otherwise, + // omit authorization_details entirely (Credential Configuration ID flow) — the resolved + // credential_configuration_id is identified later, at the Credential Request (§8.2). + if slices.Contains(authzServerMetadata.AuthorizationDetailsTypesSupported, openid4vci.AuthorizationDetailsTypeOpenIDCredential) { + authorizationDetails, _ := json.Marshal([]openid4vci.AuthorizationDetail{{ + Type: openid4vci.AuthorizationDetailsTypeOpenIDCredential, + CredentialConfigurationID: credentialConfigID, + }}) + authzParams[oauth.AuthorizationDetailsParam] = string(authorizationDetails) } // Optional caller-supplied authorization request parameters, for issuers that need extras // (e.g. auth_method=SmartCard). These may only add parameters; they must not override the @@ -231,6 +240,11 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while verifying the credential from issuer: %s, error: %s", credential.Issuer.String(), err.Error())), appCallbackURI) } + // Guard against an issuer returning a credential of a different type than requested, before + // it is stored in the wallet. + if !credential.IsType(ssi.MustParseURI(oauthSession.RequestedCredentialType)) { + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("issued credential does not have the requested type %q", oauthSession.RequestedCredentialType)), appCallbackURI) + } err = r.vcr.Wallet().Put(ctx, *credential) if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while storing credential with id: %s, error: %s", credential.ID, err.Error())), appCallbackURI) @@ -247,14 +261,22 @@ func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *O if err != nil { return nil, fmt.Errorf("error building proof: %w", err) } - return r.auth.OpenID4VCIClient().RequestCredential(ctx, openid4vci.RequestCredentialOpts{ - CredentialEndpoint: oauthSession.IssuerCredentialEndpoint, - AccessToken: accessToken, - CredentialConfigurationID: oauthSession.IssuerCredentialConfigurationID, - CredentialIdentifier: credentialIdentifier, - ProofJWT: proofJWT, - CredentialRequestParams: oauthSession.CredentialRequestParams, - }) + opts := openid4vci.RequestCredentialOpts{ + CredentialEndpoint: oauthSession.IssuerCredentialEndpoint, + AccessToken: accessToken, + ProofJWT: proofJWT, + CredentialRequestParams: oauthSession.CredentialRequestParams, + } + // Per §8.2, credential_identifier and credential_configuration_id are mutually exclusive: + // exactly one MUST be present. Use credential_identifier when extractCredentialIdentifier + // found one in the Token Response (Authorization Details flow); otherwise identify the + // credential via the resolved credential_configuration_id (Credential Configuration ID flow). + if credentialIdentifier != "" { + opts.CredentialIdentifier = credentialIdentifier + } else { + opts.CredentialConfigurationID = oauthSession.IssuerCredentialConfigurationID + } + return r.auth.OpenID4VCIClient().RequestCredential(ctx, opts) } // extractCredentialIdentifier reads authorization_details from the Token @@ -262,9 +284,15 @@ func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *O // configuration. Per OpenID4VCI 1.0 §3.3.4 / §8.2, when the AS returns // authorization_details with credential_identifiers, the wallet MUST use a // credential_identifier in the Credential Request — silently falling back -// to credential_configuration_id is not allowed. Returns ("", nil) only -// when the Token Response did not carry authorization_details at all -// (which permits the §3.3.4 scope-flow fallback to credential_configuration_id). +// to credential_configuration_id is not allowed. Returns ("", nil) when the +// Token Response carries no entry of type openid_credential at all — either +// because authorization_details is absent entirely, or because it only +// carries entries unrelated to credential issuance (the Credential +// Configuration ID flow, which never requests authorization_details of type +// openid_credential, is expected to hit this path). It is only an error when +// an openid_credential entry is present but for a different +// credential_configuration_id than requested — that is a genuine mismatch +// between what was requested and what the AS granted. func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse, credentialConfigurationID string) (string, error) { raw, ok := tokenResponse.GetAny(oauth.AuthorizationDetailsParam) if !ok { @@ -282,10 +310,12 @@ func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse, credentialC if err := json.Unmarshal(bytes, &details); err != nil { return "", fmt.Errorf("token response authorization_details malformed: %w", err) } + sawOpenIDCredential := false for _, d := range details { - if d.Type != "openid_credential" { + if d.Type != openid4vci.AuthorizationDetailsTypeOpenIDCredential { continue } + sawOpenIDCredential = true if d.CredentialConfigurationID != credentialConfigurationID { continue } @@ -294,6 +324,9 @@ func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse, credentialC } return d.CredentialIdentifiers[0], nil } + if !sawOpenIDCredential { + return "", nil + } return "", fmt.Errorf("token response authorization_details has no entry for credential_configuration_id %q", credentialConfigurationID) } diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index da39e4122..8338f2d99 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -26,8 +26,6 @@ import ( "testing" "time" - "github.com/nuts-foundation/nuts-node/core/to" - "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/crypto" @@ -44,11 +42,18 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { CredentialIssuer: "issuer", CredentialEndpoint: "endpoint", AuthorizationServers: []string{authServer}, + CredentialConfigurationsSupported: map[string]openid4vci.CredentialConfiguration{ + "UniversityDegreeCredential_jwt_vc_json": { + Format: "jwt_vc_json", + CredentialDefinition: &openid4vci.CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + }, + }, } authzMetadata := oauth.AuthorizationServerMetadata{ - AuthorizationEndpoint: "https://auth.server/authorize", - TokenEndpoint: "https://auth.server/token", - ClientIdSchemesSupported: clientIdSchemesSupported, + AuthorizationEndpoint: "https://auth.server/authorize", + TokenEndpoint: "https://auth.server/token", + ClientIdSchemesSupported: clientIdSchemesSupported, + AuthorizationDetailsTypesSupported: []string{"openid_credential"}, } t.Run("ok", func(t *testing.T) { ctx := newTestClient(t) @@ -57,10 +62,10 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []AuthorizationDetail{{Type: "openid_credential", CredentialConfigurationId: "UniversityDegreeCredential", Format: to.Ptr("vc+sd-jwt")}}, - Issuer: issuerClientID, - RedirectUri: redirectURI, - WalletDid: holderDID.String(), + CredentialType: "UniversityDegreeCredential", + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), }, }) require.NoError(t, err) @@ -75,7 +80,7 @@ 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, `[{"credential_configuration_id":"UniversityDegreeCredential","format":"vc+sd-jwt","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) + assert.Equal(t, `[{"type":"openid_credential","credential_configuration_id":"UniversityDegreeCredential_jwt_vc_json"}]`, redirectUri.Query().Get("authorization_details")) }) t.Run("ok - authorization_request_params merged into authorization request", func(t *testing.T) { ctx := newTestClient(t) @@ -157,32 +162,103 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { assert.EqualError(t, err, "issuer is empty") }) - t.Run("error - empty authorization_details", func(t *testing.T) { - // Schema declares minItems: 1 but the StrictServer middleware does not - // enforce array bounds at runtime; the handler must reject empty arrays - // before any outbound metadata fetches. + t.Run("error - empty credential_type", func(t *testing.T) { req := requestCredentials(holderSubjectID, issuerClientID, redirectURI) - req.Body.AuthorizationDetails = []AuthorizationDetail{} + req.Body.CredentialType = "" ctx := newTestClient(t) // Deliberately no mock expectations: rejection must happen before // metadata is fetched. _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) - assert.ErrorContains(t, err, "must contain exactly one entry") + assert.EqualError(t, err, "credential_type is empty") }) - t.Run("error - multiple authorization_details", func(t *testing.T) { - // Schema declares maxItems: 1; same StrictServer gap as minItems. + t.Run("error - issuer does not offer requested credential_type", func(t *testing.T) { + ctx := newTestClient(t) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) req := requestCredentials(holderSubjectID, issuerClientID, redirectURI) - req.Body.AuthorizationDetails = []AuthorizationDetail{ - {Type: "openid_credential", CredentialConfigurationId: "First"}, - {Type: "openid_credential", CredentialConfigurationId: "Second"}, - } + req.Body.CredentialType = "UnknownCredential" + + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) + + assert.EqualError(t, err, `issuer does not offer a credential of type "UnknownCredential"`) + }) + t.Run("error - credential_type only offered in a format the node does not support", func(t *testing.T) { ctx := newTestClient(t) + unsupportedFormatMetadata := metadata + unsupportedFormatMetadata.CredentialConfigurationsSupported = map[string]openid4vci.CredentialConfiguration{ + "MdocOnlyCredential_mso_mdoc": { + Format: "mso_mdoc", + CredentialDefinition: &openid4vci.CredentialDefinition{Type: []string{"VerifiableCredential", "MdocOnlyCredential"}}, + }, + } + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&unsupportedFormatMetadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) + req := requestCredentials(holderSubjectID, issuerClientID, redirectURI) + req.Body.CredentialType = "MdocOnlyCredential" _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) - assert.ErrorContains(t, err, "must contain exactly one entry") + assert.EqualError(t, err, `issuer offers "MdocOnlyCredential" only in format(s): mso_mdoc`) + }) + t.Run("ok - multiple matches in supported formats resolved by sorted candidate ID", func(t *testing.T) { + ctx := newTestClient(t) + multiFormatMetadata := metadata + multiFormatMetadata.CredentialConfigurationsSupported = map[string]openid4vci.CredentialConfiguration{ + "ZZZ_UniversityDegreeCredential_jwt_vc_json": { + Format: "jwt_vc_json", + CredentialDefinition: &openid4vci.CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + }, + "AAA_UniversityDegreeCredential_ldp_vc": { + Format: "ldp_vc", + CredentialDefinition: &openid4vci.CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + }, + } + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&multiFormatMetadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) + + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + + require.NoError(t, err) + redirectUri, _ := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + // Both formats are supported (oauth.DefaultOpenIDSupportedFormats); there is no format + // ranking between them, so the lexicographically smallest candidate ID wins. + assert.Contains(t, redirectUri.Query().Get("authorization_details"), `"credential_configuration_id":"AAA_UniversityDegreeCredential_ldp_vc"`) + }) + t.Run("error - credential_type only offered via vct in an unsupported format", func(t *testing.T) { + ctx := newTestClient(t) + // The node does not support SD-JWT VCs; vct is still matched so this produces the more + // useful "only offered in format(s)" error instead of "does not offer this type at all". + sdJwtOnlyMetadata := metadata + sdJwtOnlyMetadata.CredentialConfigurationsSupported = map[string]openid4vci.CredentialConfiguration{ + "UniversityDegreeCredential_sd_jwt": { + Format: "vc+sd-jwt", + Vct: "UniversityDegreeCredential", + }, + } + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&sdJwtOnlyMetadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) + + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + + assert.EqualError(t, err, `issuer offers "UniversityDegreeCredential" only in format(s): vc+sd-jwt`) + }) + t.Run("ok - Credential Configuration ID flow when AS does not support authorization_details", func(t *testing.T) { + ctx := newTestClient(t) + authzMetadataNoDetails := authzMetadata + authzMetadataNoDetails.AuthorizationDetailsTypesSupported = nil + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadataNoDetails, nil) + + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + + require.NoError(t, err) + redirectUri, _ := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + assert.False(t, redirectUri.Query().Has("authorization_details")) + var stored OAuthSession + require.NoError(t, ctx.client.oauthClientStateStore().Get(redirectUri.Query().Get("state"), &stored)) + assert.Equal(t, "UniversityDegreeCredential_jwt_vc_json", stored.IssuerCredentialConfigurationID) }) t.Run("error - invalid authorization endpoint in metadata", func(t *testing.T) { ctx := newTestClient(t) @@ -231,10 +307,10 @@ func requestCredentials(subjectID string, issuer string, redirectURI string) Req return RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: subjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []AuthorizationDetail{{Type: "openid_credential", CredentialConfigurationId: "UniversityDegreeCredential"}}, - Issuer: issuer, - RedirectUri: redirectURI, - WalletDid: holderDID.String(), + CredentialType: "UniversityDegreeCredential", + Issuer: issuer, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), }, } } @@ -268,6 +344,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { IssuerCredentialEndpoint: credEndpoint, IssuerNonceEndpoint: nonceEndpoint, IssuerCredentialConfigurationID: credentialConfigID, + RequestedCredentialType: "ExampleType", IssuerCredentialIssuer: issuerClientID, } sessionWithoutNonce := session @@ -432,12 +509,14 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) + // Per §8.2, credential_identifier and credential_configuration_id are mutually + // exclusive: credential_configuration_id MUST NOT be present when + // credential_identifier is used. ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{ - CredentialEndpoint: credEndpoint, - AccessToken: accessToken, - CredentialConfigurationID: credentialConfigID, - CredentialIdentifier: "CivilEngineeringDegree-2023", - ProofJWT: "signed-proof", + CredentialEndpoint: credEndpoint, + AccessToken: accessToken, + CredentialIdentifier: "CivilEngineeringDegree-2023", + ProofJWT: "signed-proof", }).Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) ctx.wallet.EXPECT().Put(nil, *verifiableCredential) @@ -465,6 +544,30 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "credential_identifiers") }) + t.Run("ok - falls back to credential_configuration_id when authorization_details has no openid_credential entry", func(t *testing.T) { + ctx := newTestClient(t) + // Credential Configuration ID flow: the wallet never requested openid_credential + // authorization_details, but the AS may still return an authorization_details array for + // unrelated purposes. That must not be treated as "no entry for my + // credential_configuration_id" — it should fall back to credential_configuration_id, same + // as when authorization_details is absent entirely. + tokenResponseWithUnrelatedDetails := (&oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"}). + With(oauth.AuthorizationDetailsParam, []map[string]interface{}{{ + "type": "some_other_type", + }}) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponseWithUnrelatedDetails, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + require.NoError(t, err) + require.NotNil(t, callback) + }) t.Run("error - 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) @@ -575,4 +678,20 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Nil(t, callback) assert.ErrorContains(t, err, "credential response does not contain any credentials") }) + t.Run("error - issued credential type does not match requested credential_type", func(t *testing.T) { + ctx := newTestClient(t) + sessionWithDifferentType := session + sessionWithDifferentType.RequestedCredentialType = "SomeOtherCredentialType" + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionWithDifferentType) + + assert.Nil(t, callback) + assert.EqualError(t, err, `server_error - issued credential does not have the requested type "SomeOtherCredentialType"`) + }) } diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index c7ad01d3a..a63ab73f5 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -59,6 +59,9 @@ 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"` + // RequestedCredentialType is the credential_type requested by the caller, used to verify the + // issued credential's type before it is stored in the wallet. + RequestedCredentialType string `json:"requested_credential_type,omitempty"` // IssuerCredentialIssuer is the Credential Issuer Identifier (`credential_issuer` // from the metadata, §12.2.1). It is used as the `aud` claim in the proof JWT // per §F.1; this can differ from IssuerURL (the AS issuer) when the metadata diff --git a/auth/oauth/types.go b/auth/oauth/types.go index e1e3d8d58..d5d4740c4 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -330,6 +330,11 @@ type AuthorizationServerMetadata struct { // DPoPSigningAlgValuesSupported is a JSON array containing a list of the DPoP proof JWS signing algorithms ("alg" values) supported by the token endpoint. DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` + // AuthorizationDetailsTypesSupported is a JSON array containing the authorization_details + // "type" values (RFC 9396) supported by this Authorization Server. OpenID4VCI wallets use this + // to decide whether the "openid_credential" type is supported for the Authorization Request (§5.1.1). + AuthorizationDetailsTypesSupported []string `json:"authorization_details_types_supported,omitempty"` + /* ******** JWT-Secured Authorization Request RFC9101 & OpenID Connect Core v1.0: §6. Passing Request Parameters as JWTs ******** */ // RequireSignedRequestObject specifies if the authorization server requires the use of signed request objects. diff --git a/auth/openid4vci/types.go b/auth/openid4vci/types.go index c523a6308..c8ff52b77 100644 --- a/auth/openid4vci/types.go +++ b/auth/openid4vci/types.go @@ -32,6 +32,10 @@ package openid4vci import ( "encoding/json" + "fmt" + "slices" + "sort" + "strings" "github.com/nuts-foundation/nuts-node/auth/oauth" ) @@ -40,15 +44,60 @@ import ( // proofs (Appendix F.1). const JWTTypeOpenID4VCIProof = "openid4vci-proof+jwt" +// AuthorizationDetailsTypeOpenIDCredential is the RFC 9396 authorization_details +// "type" value used for OpenID4VCI credential issuance (§5.1.1). +const AuthorizationDetailsTypeOpenIDCredential = "openid_credential" + // OpenIDCredentialIssuerMetadata describes the OpenID4VCI Credential Issuer // Metadata document published at /.well-known/openid-credential-issuer // (Section 12.2). The document is OpenID4VCI-defined; it is not an OAuth // authorization-server metadata document. type OpenIDCredentialIssuerMetadata struct { - CredentialIssuer string `json:"credential_issuer"` - CredentialEndpoint string `json:"credential_endpoint"` - NonceEndpoint string `json:"nonce_endpoint,omitempty"` - AuthorizationServers []string `json:"authorization_servers,omitempty"` + 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]CredentialConfiguration `json:"credential_configurations_supported,omitempty"` +} + +// CredentialConfiguration is one entry of credential_configurations_supported +// in the Credential Issuer Metadata (§12.2). Only the fields needed to resolve +// a credential_type to a credential_configuration_id are modeled: the +// type-locating field depends on the format (CredentialDefinition.Type for +// jwt_vc_json/ldp_vc, Vct for vc+sd-jwt/dc+sd-jwt). +type CredentialConfiguration struct { + Format string `json:"format"` + CredentialDefinition *CredentialDefinition `json:"credential_definition,omitempty"` + Vct string `json:"vct,omitempty"` +} + +// MatchesType checks whether this credential_configurations_supported entry is of the given +// credential type, using the type-locating field per format (§12.2): credential_definition.type +// (jwt_vc_json, ldp_vc) or vct (vc+sd-jwt, dc+sd-jwt). vct is still matched here, even though the +// node doesn't support those formats (oauth.DefaultOpenIDSupportedFormats), so such entries +// produce the more useful "only offered in unsupported format(s)" error instead of "does not offer +// this type at all". Callers resolving a caller-supplied credential_type should reject the base +// "VerifiableCredential" type themselves (see ResolveCredentialConfigurationID) — every entry's +// credential_definition.type contains it, so it is not a useful match here. +func (c CredentialConfiguration) MatchesType(credentialType string) bool { + if c.CredentialDefinition != nil && slices.Contains(c.CredentialDefinition.Type, credentialType) { + return true + } + return c.Vct != "" && c.Vct == credentialType +} + +// CredentialDefinition carries the credential type array used by the +// jwt_vc_json and ldp_vc formats. +type CredentialDefinition struct { + Type []string `json:"type"` +} + +// AuthorizationDetail is a single authorization_details entry (RFC 9396) sent +// on the Authorization Request when the Authorization Server supports the +// openid_credential type (§5.1.1). +type AuthorizationDetail struct { + Type string `json:"type"` + CredentialConfigurationID string `json:"credential_configuration_id"` } // GetIssuer returns the credential issuer identifier, for metadata discovery @@ -63,6 +112,46 @@ func (m OpenIDCredentialIssuerMetadata) WellKnownPath() string { return oauth.OpenIdCredIssuerWellKnown } +// ResolveCredentialConfigurationID matches credentialType against +// CredentialConfigurationsSupported (§12.2) and returns the matching credential_configuration_id. +// Matching is done on the type-locating field, not the map key: the spec lets +// credential_configuration_id be an arbitrary issuer-chosen string. +// +// - credentialType is the base "VerifiableCredential" type: rejected upfront, since every +// credential_definition.type array contains it and it does not identify a specific credential. +// - 0 matches: the issuer does not offer this credential type at all. +// - matches only in formats the node does not support (oauth.DefaultOpenIDSupportedFormats): the +// type exists, but the node cannot request it. +// - 1+ matches in a supported format: candidate IDs are sorted so the pick is deterministic +// (never Go map order); the smallest ID wins. +func (m OpenIDCredentialIssuerMetadata) ResolveCredentialConfigurationID(credentialType string) (string, error) { + if credentialType == "VerifiableCredential" { + return "", fmt.Errorf("issuer does not offer a credential of type %q", credentialType) + } + supportedFormats := oauth.DefaultOpenIDSupportedFormats() + var matchedFormats []string + var supportedIDs []string + for id, config := range m.CredentialConfigurationsSupported { + if !config.MatchesType(credentialType) { + continue + } + matchedFormats = append(matchedFormats, config.Format) + if _, ok := supportedFormats[config.Format]; ok { + supportedIDs = append(supportedIDs, id) + } + } + if len(matchedFormats) == 0 { + return "", fmt.Errorf("issuer does not offer a credential of type %q", credentialType) + } + if len(supportedIDs) == 0 { + sort.Strings(matchedFormats) + matchedFormats = slices.Compact(matchedFormats) + return "", fmt.Errorf("issuer offers %q only in format(s): %s", credentialType, strings.Join(matchedFormats, ", ")) + } + sort.Strings(supportedIDs) + return supportedIDs[0], nil +} + // NonceResponse is the body returned by the Nonce Endpoint (Section 7.2). type NonceResponse struct { CNonce string `json:"c_nonce"` diff --git a/auth/openid4vci/types_test.go b/auth/openid4vci/types_test.go new file mode 100644 index 000000000..1b6fcfc64 --- /dev/null +++ b/auth/openid4vci/types_test.go @@ -0,0 +1,171 @@ +/* + * Nuts node + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package openid4vci + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenIDCredentialIssuerMetadata_ResolveCredentialConfigurationID(t *testing.T) { + t.Run("ok - single match", func(t *testing.T) { + metadata := OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]CredentialConfiguration{ + "UniversityDegreeCredential_jwt_vc_json": { + Format: "jwt_vc_json", + CredentialDefinition: &CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + }, + }, + } + + id, err := metadata.ResolveCredentialConfigurationID("UniversityDegreeCredential") + + require.NoError(t, err) + assert.Equal(t, "UniversityDegreeCredential_jwt_vc_json", id) + }) + t.Run("ok - matches on vct for vc+sd-jwt/dc+sd-jwt formats", func(t *testing.T) { + metadata := OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]CredentialConfiguration{ + "UniversityDegreeCredential_dc_sd_jwt": { + Format: "dc+sd-jwt", + Vct: "UniversityDegreeCredential", + }, + }, + } + + // Not a node-supported format, but the type is still located and reported accordingly. + _, err := metadata.ResolveCredentialConfigurationID("UniversityDegreeCredential") + + assert.EqualError(t, err, `issuer offers "UniversityDegreeCredential" only in format(s): dc+sd-jwt`) + }) + t.Run("ok - multiple matches in supported formats resolved by sorted candidate ID", func(t *testing.T) { + metadata := OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]CredentialConfiguration{ + "ZZZ_UniversityDegreeCredential_jwt_vc_json": { + Format: "jwt_vc_json", + CredentialDefinition: &CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + }, + "AAA_UniversityDegreeCredential_ldp_vc": { + Format: "ldp_vc", + CredentialDefinition: &CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + }, + }, + } + + // Both formats are supported; there is no format ranking between them, so the + // lexicographically smallest candidate ID wins. + id, err := metadata.ResolveCredentialConfigurationID("UniversityDegreeCredential") + + require.NoError(t, err) + assert.Equal(t, "AAA_UniversityDegreeCredential_ldp_vc", id) + }) + t.Run("error - no matches at all", func(t *testing.T) { + metadata := OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]CredentialConfiguration{ + "UniversityDegreeCredential_jwt_vc_json": { + Format: "jwt_vc_json", + CredentialDefinition: &CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + }, + }, + } + + _, err := metadata.ResolveCredentialConfigurationID("UnknownCredential") + + assert.EqualError(t, err, `issuer does not offer a credential of type "UnknownCredential"`) + }) + t.Run("error - matches only in unsupported format(s)", func(t *testing.T) { + metadata := OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]CredentialConfiguration{ + "MdocOnlyCredential_mso_mdoc": { + Format: "mso_mdoc", + CredentialDefinition: &CredentialDefinition{Type: []string{"VerifiableCredential", "MdocOnlyCredential"}}, + }, + }, + } + + _, err := metadata.ResolveCredentialConfigurationID("MdocOnlyCredential") + + assert.EqualError(t, err, `issuer offers "MdocOnlyCredential" only in format(s): mso_mdoc`) + }) + t.Run("error - unsupported formats are listed sorted and deduplicated", func(t *testing.T) { + metadata := OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]CredentialConfiguration{ + "1": {Format: "mso_mdoc", CredentialDefinition: &CredentialDefinition{Type: []string{"SomeCredential"}}}, + "2": {Format: "vc+sd-jwt", Vct: "SomeCredential"}, + "3": {Format: "vc+sd-jwt", Vct: "SomeCredential"}, + }, + } + + _, err := metadata.ResolveCredentialConfigurationID("SomeCredential") + + assert.EqualError(t, err, `issuer offers "SomeCredential" only in format(s): mso_mdoc, vc+sd-jwt`) + }) + t.Run("error - rejects the base VerifiableCredential type upfront", func(t *testing.T) { + metadata := OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]CredentialConfiguration{ + "UniversityDegreeCredential_jwt_vc_json": { + Format: "jwt_vc_json", + CredentialDefinition: &CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + }, + }, + } + + // Every credential_definition.type array contains "VerifiableCredential"; without this + // guard it would resolve to an arbitrary, unrelated credential_configuration_id. + _, err := metadata.ResolveCredentialConfigurationID("VerifiableCredential") + + assert.EqualError(t, err, `issuer does not offer a credential of type "VerifiableCredential"`) + }) +} + +func TestCredentialConfiguration_MatchesType(t *testing.T) { + t.Run("matches credential_definition.type", func(t *testing.T) { + config := CredentialConfiguration{ + Format: "jwt_vc_json", + CredentialDefinition: &CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + } + + assert.True(t, config.MatchesType("UniversityDegreeCredential")) + assert.False(t, config.MatchesType("OtherCredential")) + }) + t.Run("matches the base VerifiableCredential type like any other entry", func(t *testing.T) { + // Excluding "VerifiableCredential" as a resolvable type is the caller's + // responsibility (see ResolveCredentialConfigurationID); MatchesType itself does a + // plain membership check. + config := CredentialConfiguration{ + Format: "jwt_vc_json", + CredentialDefinition: &CredentialDefinition{Type: []string{"VerifiableCredential"}}, + } + + assert.True(t, config.MatchesType("VerifiableCredential")) + }) + t.Run("matches vct", func(t *testing.T) { + config := CredentialConfiguration{Format: "vc+sd-jwt", Vct: "UniversityDegreeCredential"} + + assert.True(t, config.MatchesType("UniversityDegreeCredential")) + assert.False(t, config.MatchesType("OtherCredential")) + }) + t.Run("no credential_definition and no vct", func(t *testing.T) { + config := CredentialConfiguration{Format: "jwt_vc_json"} + + assert.False(t, config.MatchesType("UniversityDegreeCredential")) + }) +} diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 254a6cd44..6d4f2c9b2 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -150,7 +150,7 @@ paths: schema: required: - issuer - - authorization_details + - credential_type - redirect_uri - wallet_did properties: @@ -164,16 +164,23 @@ paths: 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. example: did:web:issuer.example.com - authorization_details: + credential_type: + type: string description: | - Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1.1. - The current implementation processes a single credential - issuance per call and only consumes the first entry. - type: array - minItems: 1 - maxItems: 1 - items: - $ref: '#/components/schemas/AuthorizationDetail' + The type of Verifiable Credential to request, e.g. "HealthcareProviderRoleTypeCredential". + The node takes care of the OpenID4VCI details: + 1. It reads the issuer's Credential Issuer Metadata (`/.well-known/openid-credential-issuer`, + §12.2) and matches this type against each entry's `credential_definition.type` or + `vct` in `credential_configurations_supported`, to find the matching + `credential_configuration_id`. + 2. It checks the Authorization Server's `authorization_details_types_supported` + metadata to decide whether to send that `credential_configuration_id` via an + `authorization_details` parameter (RFC 9396) on the authorization request, or via + `credential_configuration_id` on the Credential Request. + 3. Once the credential is issued, it checks the returned credential's type matches + what was requested, before storing it. + Only one credential can be requested per call. + example: HealthcareProviderRoleTypeCredential credential_request_params: type: object additionalProperties: true @@ -759,31 +766,6 @@ components: description: | Presentation Definitions, as described in Presentation Exchange specification, fulfilled to obtain the access token The map key is the wallet owner (user/organization) - AuthorizationDetail: - description: | - A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1.1. - Only the fields used by the user/browser issuance flow are modeled. - type: object - required: - - type - - credential_configuration_id - properties: - type: - type: string - enum: [openid_credential] - description: | - The authorization details type. For OpenID4VCI flows this MUST - be "openid_credential" per §5.1.1. - credential_configuration_id: - type: string - description: | - References a credential configuration from the issuer's - credential_configurations_supported metadata. REQUIRED for - type=openid_credential per §5.1.1. - format: - type: string - description: | - Optional credential format hint (e.g. "vc+sd-jwt"). securitySchemes: jwtBearerAuth: type: http diff --git a/docs/pages/deployment/oauth.rst b/docs/pages/deployment/oauth.rst index 71b7dcbb6..e8a53aa7e 100644 --- a/docs/pages/deployment/oauth.rst +++ b/docs/pages/deployment/oauth.rst @@ -54,14 +54,23 @@ OpenID4VCI ********** The Nuts node implements the OpenID for Verifiable Credential Issuance 1.0 wallet flow. -On behalf of a user, the node requests a Verifiable Credential from a remote Credential Issuer over the Authorization Code Flow: - -- Authorization Request with ``authorization_details`` of type ``openid_credential`` (RFC 9396 / OpenID4VCI §5.1.1). +On behalf of a user, the node requests a Verifiable Credential from a remote Credential Issuer over the Authorization Code Flow. +The caller supplies a ``credential_type`` (a VC concept); the node resolves it to the issuer's +``credential_configuration_id`` by matching it against the Credential Issuer Metadata's +``credential_configurations_supported`` (§12.2), and picks the authorization-stage flow from the +Authorization Server metadata, without any caller involvement: + +- Authorization Request: if the Authorization Server advertises ``authorization_details_types_supported`` + containing ``openid_credential``, the request includes ``authorization_details`` of type ``openid_credential`` + with the resolved ``credential_configuration_id`` (RFC 9396 / OpenID4VCI §5.1.1). Otherwise, ``authorization_details`` + is omitted and the resolved ``credential_configuration_id`` is used only at the Credential Request. - PKCE for the authorization code, as in the OpenID4VP flow. -- Token Response with ``credential_identifiers`` per the requested ``credential_configuration_id`` (§6.2 and §3.3.4). +- Token Response with ``credential_identifiers`` per the requested ``credential_configuration_id`` (§6.2 and §3.3.4), + when the Authorization Details flow was used. - Nonce Endpoint to obtain a fresh ``c_nonce`` before requesting a Credential (§7). - Credential Request with a key proof JWT bound to the holder's DID (Appendix F.1). - On an ``invalid_nonce`` response, the wallet fetches a fresh ``c_nonce`` and retries the Credential Request once (§8.3.1.2 prescribes fetching a new ``c_nonce``; retrying once is local policy). +- After the credential is issued, the node verifies its type matches the requested ``credential_type`` before storing it. The relevant API: @@ -71,7 +80,7 @@ Not implemented: - Deferred issuance (HTTP 202 with ``transaction_id`` / ``interval``). - The Notification Endpoint (§11). ``notification_id`` returned by the issuer is ignored. -- Multiple credentials per call: only a single ``authorization_details`` entry is accepted and only the first credential in the response is processed. +- Multiple credentials per call: ``credential_type`` accepts a single value and only the first credential in the response is processed. Note: the unrelated internal flow in the ``vcr/openid4vci`` package is used by Nuts nodes to issue ``NutsAuthorizationCredential`` to each other over HTTP. That flow is based on a subset of OpenID4VCI draft-11 and is not a wallet implementation per §2. diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index ac9c1b36c..d0944010b 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -12,6 +12,7 @@ Unreleased * #4078: Add the experimental RFC 7523 ``jwt-bearer`` two-VP token request flow, gated behind ``auth.experimental.jwtbearerclient`` (default ``false``, subject to change) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4227 * #4078: Expose the experimental two-VP flow on ``POST /internal/auth/v2/{subjectID}/request-service-access-token`` via the optional ``service_provider_subject_id`` body field by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4228 * #4233: ``request-credential`` API gains an optional ``credential_request_params`` JSON object overlaid on top of the OpenID4VCI Credential Request body sent to the issuer. Lets the wallet talk to issuers that accept additional fields, or to override the credential request entirely. +* #4372: ``request-credential`` API replaces ``authorization_details`` with a ``credential_type`` field; the node resolves ``credential_configuration_id`` from the issuer's metadata and picks the authorization-stage flow (RFC 9396 ``authorization_details`` vs. ``credential_configuration_id`` at the Credential Request) from the Authorization Server metadata. Breaking change to this experimental API. **************** Peanut (v6.2.8) diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 8eb4c6a33..4b21b8eac 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -20,11 +20,6 @@ const ( JwtBearerAuthScopes = "jwtBearerAuth.Scopes" ) -// Defines values for AuthorizationDetailType. -const ( - OpenidCredential AuthorizationDetailType = "openid_credential" -) - // Defines values for ServiceAccessTokenRequestTokenType. const ( ServiceAccessTokenRequestTokenTypeBearer ServiceAccessTokenRequestTokenType = "Bearer" @@ -37,26 +32,6 @@ const ( UserAccessTokenRequestTokenTypeDPoP UserAccessTokenRequestTokenType = "DPoP" ) -// AuthorizationDetail A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1.1. -// Only the fields used by the user/browser issuance flow are modeled. -type AuthorizationDetail struct { - // CredentialConfigurationId References a credential configuration from the issuer's - // credential_configurations_supported metadata. REQUIRED for - // type=openid_credential per §5.1.1. - CredentialConfigurationId string `json:"credential_configuration_id"` - - // Format Optional credential format hint (e.g. "vc+sd-jwt"). - Format *string `json:"format,omitempty"` - - // Type The authorization details type. For OpenID4VCI flows this MUST - // be "openid_credential" per §5.1.1. - Type AuthorizationDetailType `json:"type"` -} - -// AuthorizationDetailType The authorization details type. For OpenID4VCI flows this MUST -// be "openid_credential" per §5.1.1. -type AuthorizationDetailType string - // DPoPRequest defines model for DPoPRequest. type DPoPRequest struct { // Htm The HTTP method for which the DPoP proof is requested. @@ -250,11 +225,6 @@ type Cnf struct { // RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance. type RequestOpenid4VCICredentialIssuanceJSONBody struct { - // AuthorizationDetails Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1.1. - // The current implementation processes a single credential - // issuance per call and only consumes the first entry. - AuthorizationDetails []AuthorizationDetail `json:"authorization_details"` - // AuthorizationRequestParams Optional key/value pairs added to the OpenID4VCI authorization request (the redirect to the // Authorization Server's authorization_endpoint). These may only add parameters; they must not // override the OpenID4VCI parameters set by the node (the request is rejected if they do). @@ -269,6 +239,21 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // responsible for the resulting wire shape (§8.2 mutual exclusivity, proof binding, etc.). CredentialRequestParams *map[string]interface{} `json:"credential_request_params,omitempty"` + // CredentialType The type of Verifiable Credential to request, e.g. "HealthcareProviderRoleTypeCredential". + // The node takes care of the OpenID4VCI details: + // 1. It reads the issuer's Credential Issuer Metadata (`/.well-known/openid-credential-issuer`, + // §12.2) and matches this type against each entry's `credential_definition.type` or + // `vct` in `credential_configurations_supported`, to find the matching + // `credential_configuration_id`. + // 2. It checks the Authorization Server's `authorization_details_types_supported` + // metadata to decide whether to send that `credential_configuration_id` via an + // `authorization_details` parameter (RFC 9396) on the authorization request, or via + // `credential_configuration_id` on the Credential Request. + // 3. Once the credential is issued, it checks the returned credential's type matches + // what was requested, before storing it. + // Only one credential can be requested per call. + CredentialType string `json:"credential_type"` + // 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. Issuer string `json:"issuer"`