diff --git a/pkg/profile/api.go b/pkg/profile/api.go index 1b165bcb9..fcd7f9333 100644 --- a/pkg/profile/api.go +++ b/pkg/profile/api.go @@ -62,6 +62,7 @@ type CredentialTemplate struct { CredentialDefaultExpirationDuration *time.Duration `json:"credentialDefaultExpirationDuration"` Checks CredentialTemplateChecks `json:"checks"` SdJWT *SelectiveDisclosureTemplate `json:"sdJWT"` + JSONSchema string `json:"jsonSchema,omitempty"` } type SelectiveDisclosureTemplate struct { diff --git a/pkg/restapi/v1/issuer/controller.go b/pkg/restapi/v1/issuer/controller.go index f9b8a5be7..90bc09992 100644 --- a/pkg/restapi/v1/issuer/controller.go +++ b/pkg/restapi/v1/issuer/controller.go @@ -23,6 +23,7 @@ import ( "github.com/labstack/echo/v4" "github.com/piprate/json-gold/ld" "github.com/samber/lo" + "github.com/xeipuuv/gojsonschema" "go.opentelemetry.io/otel/trace" "github.com/hyperledger/aries-framework-go/component/models/ld/validator" @@ -651,7 +652,7 @@ func (c *Controller) PrepareCredential(e echo.Context) error { errors.New("credentials should not be nil")) } - if err = c.validateClaims(result.Credential, result.EnforceStrictValidation); err != nil { + if err = c.validateClaims(result.Credential, result.CredentialTemplate, result.EnforceStrictValidation); err != nil { return err } @@ -672,6 +673,7 @@ func (c *Controller) PrepareCredential(e echo.Context) error { func (c *Controller) validateClaims( //nolint:gocognit cred *verifiable.Credential, + credentialTemplate *profileapi.CredentialTemplate, strictValidation bool, ) error { if !strictValidation { @@ -690,7 +692,13 @@ func (c *Controller) validateClaims( //nolint:gocognit types = append(types, t) } - if sub, ok := cred.Subject.(verifiable.Subject); ok { //nolint:nestif + validate := func(sub verifiable.Subject) error { + if credentialTemplate != nil && credentialTemplate.JSONSchema != "" { + if err := validateJSONSchema(sub.CustomFields, credentialTemplate.JSONSchema); err != nil { + return fmt.Errorf("validate claims: %w", err) + } + } + for k, v := range sub.CustomFields { if k == "type" || k == "@type" { if v1, ok1 := v.(string); ok1 { @@ -711,6 +719,19 @@ func (c *Controller) validateClaims( //nolint:gocognit data[k] = v } + + return nil + } + + subjects, err := getCredentialSubjects(cred.Subject) + if err != nil { + return err + } + + for _, sub := range subjects { + if err := validate(sub); err != nil { + return err + } } data["@context"] = ctx @@ -722,6 +743,53 @@ func (c *Controller) validateClaims( //nolint:gocognit ) } +func getCredentialSubjects(subject interface{}) ([]verifiable.Subject, error) { + if subject == nil { + return nil, nil + } + + if sub, ok := subject.(verifiable.Subject); ok { + return []verifiable.Subject{sub}, nil + } + + if subs, ok := subject.([]verifiable.Subject); ok { + return subs, nil + } + + return nil, fmt.Errorf("invalid type for credential subject: %T", subject) +} + +type JSONSchemaValidationErrors []gojsonschema.ResultError + +func (e JSONSchemaValidationErrors) Error() string { + var errMsg string + + for i, msg := range e { + errMsg += msg.String() + if i+1 < len(e) { + errMsg += "; " + } + } + + return fmt.Sprintf("[%s]", errMsg) +} + +func validateJSONSchema(data interface{}, schema string) error { + result, err := gojsonschema.Validate( + gojsonschema.NewStringLoader(schema), + gojsonschema.NewGoLoader(data), + ) + if err != nil { + return fmt.Errorf("schema error: %w", err) + } + + if !result.Valid() { + return fmt.Errorf("validation error: %w", JSONSchemaValidationErrors(result.Errors())) + } + + return nil +} + // OpenidConfig request openid configuration for issuer. // GET /issuer/{profileID}/{profileVersion}/.well-known/openid-configuration. func (c *Controller) OpenidConfig(ctx echo.Context, profileID, profileVersion string) error { diff --git a/pkg/restapi/v1/issuer/controller_test.go b/pkg/restapi/v1/issuer/controller_test.go index a9f74d5fc..40bf91de3 100644 --- a/pkg/restapi/v1/issuer/controller_test.go +++ b/pkg/restapi/v1/issuer/controller_test.go @@ -53,6 +53,10 @@ var ( sampleVCJWT string //go:embed testdata/sample_vc_university_degree.jsonld sampleVCUniversityDegree []byte + //go:embed testdata/sample_vc_invalid_university_degree.jsonld + sampleVCInvalidUniversityDegree []byte + //go:embed testdata/universitydegree.schema.json + universityDegreeSchema string ) // nolint:gochecknoglobals @@ -1293,11 +1297,18 @@ func TestController_PrepareCredential(t *testing.T) { assert.Equal(t, oidc4ci.TxID("123"), req.TxID) return &oidc4ci.PrepareCredentialResult{ - ProfileID: profileID, - ProfileVersion: profileVersion, - Credential: sampleVC, - Format: vcsverifiable.Ldp, - Retry: false, + ProfileID: profileID, + ProfileVersion: profileVersion, + Credential: sampleVC, + Format: vcsverifiable.Ldp, + Retry: false, + EnforceStrictValidation: true, + CredentialTemplate: &profileapi.CredentialTemplate{ + JSONSchema: universityDegreeSchema, + Checks: profileapi.CredentialTemplateChecks{ + Strict: true, + }, + }, }, nil }, ) @@ -1433,6 +1444,62 @@ func TestController_PrepareCredential(t *testing.T) { ctx := echoContext(withRequestBody([]byte(req))) assert.ErrorContains(t, c.PrepareCredential(ctx), "rand-code[]: rand") }) + + t.Run("claims schema validation error", func(t *testing.T) { + invalidVC, err := verifiable.ParseCredential( + sampleVCInvalidUniversityDegree, + verifiable.WithDisabledProofCheck(), + verifiable.WithJSONLDDocumentLoader(testutil.DocumentLoader(t)), + ) + require.NoError(t, err) + + mockProfileSvc := NewMockProfileService(gomock.NewController(t)) + mockProfileSvc.EXPECT().GetProfile(profileID, profileVersion).Times(1).Return( + &profileapi.Issuer{ + OrganizationID: orgID, + ID: profileID, + VCConfig: &profileapi.VCConfig{ + Format: vcsverifiable.Ldp, + }, + }, nil) + + mockIssueCredentialSvc := NewMockIssueCredentialService(gomock.NewController(t)) + mockOIDC4CIService := NewMockOIDC4CIService(gomock.NewController(t)) + mockOIDC4CIService.EXPECT().PrepareCredential(gomock.Any(), gomock.Any()).DoAndReturn( + func( + ctx context.Context, + req *oidc4ci.PrepareCredential, + ) (*oidc4ci.PrepareCredentialResult, error) { + assert.Equal(t, oidc4ci.TxID("123"), req.TxID) + + return &oidc4ci.PrepareCredentialResult{ + ProfileID: profileID, + ProfileVersion: profileVersion, + Credential: invalidVC, + Format: vcsverifiable.Ldp, + Retry: false, + EnforceStrictValidation: true, + CredentialTemplate: &profileapi.CredentialTemplate{ + JSONSchema: universityDegreeSchema, + Checks: profileapi.CredentialTemplateChecks{ + Strict: true, + }, + }, + }, nil + }, + ) + + c := NewController(&Config{ + ProfileSvc: mockProfileSvc, + IssueCredentialService: mockIssueCredentialSvc, + OIDC4CIService: mockOIDC4CIService, + DocumentLoader: testutil.DocumentLoader(t), + }) + + req := `{"tx_id":"123","type":"UniversityDegreeCredential","format":"ldp_vc"}` + ctx := echoContext(withRequestBody([]byte(req))) + assert.EqualError(t, c.PrepareCredential(ctx), "validate claims: validation error: [alumniOf: name is required]") + }) } func TestOpenIDConfigurationController(t *testing.T) { @@ -1700,6 +1767,102 @@ func TestOpenIdConfiguration_GrantTypesSupportedAndScopesSupported(t *testing.T) assert.Equal(t, config.ScopesSupported, s) } +func Test_validateJSONSchema(t *testing.T) { + t.Run("success", func(t *testing.T) { + data := map[string]interface{}{ + "alumniOf": map[string]interface{}{ + "id": "did:example:c276e12ec21ebfeb1f712ebc6f1", + "name": []map[string]interface{}{ + { + "value": "Example University", + "lang": "en", + }, + { + "value": "Exemple d'Université", + "lang": "fr", + }, + }, + }, + "degree": map[string]interface{}{ + "type": "BachelorDegree", + "name": "Bachelor of Science and Arts", + }, + } + + err := validateJSONSchema(data, universityDegreeSchema) + require.NoError(t, err) + }) + + t.Run("validation error", func(t *testing.T) { + data := map[string]interface{}{ + "alumniOf": map[string]interface{}{ + "id": "did:example:c276e12ec21ebfeb1f712ebc6f1", + "name": []map[string]interface{}{ + { + "value": "Example University", + "lang": 0, + }, + { + "value": "Exemple d'Université", + }, + }, + }, + } + + err := validateJSONSchema(data, universityDegreeSchema) + require.EqualError(t, err, + "validation error: [(root): degree is required; alumniOf.name.0.lang: Invalid type. "+ + "Expected: string, given: integer; alumniOf.name.1: lang is required]") + }) + + t.Run("schema error", func(t *testing.T) { + data := map[string]interface{}{ + "alumniOf": map[string]interface{}{ + "id": "did:example:c276e12ec21ebfeb1f712ebc6f1", + "name": []map[string]interface{}{ + { + "value": "Example University", + "lang": 0, + }, + { + "value": "Exemple d'Université", + }, + }, + }, + } + + err := validateJSONSchema(data, `{"type":"invalid"}`) + require.Error(t, err) + require.Contains(t, err.Error(), "schema error") + }) +} + +func Test_getCredentialSubjects(t *testing.T) { + t.Run("subject", func(t *testing.T) { + subjects, err := getCredentialSubjects(verifiable.Subject{ID: "id1"}) + require.NoError(t, err) + require.Len(t, subjects, 1) + }) + + t.Run("slice of subjects", func(t *testing.T) { + subjects, err := getCredentialSubjects([]verifiable.Subject{{ID: "id1"}, {ID: "id2"}}) + require.NoError(t, err) + require.Len(t, subjects, 2) + }) + + t.Run("invalid subject", func(t *testing.T) { + subjects, err := getCredentialSubjects("id2") + require.EqualError(t, err, "invalid type for credential subject: string") + require.Len(t, subjects, 0) + }) + + t.Run("nil subject", func(t *testing.T) { + subjects, err := getCredentialSubjects(nil) + require.NoError(t, err) + require.Len(t, subjects, 0) + }) +} + type options struct { tenantID string requestBody []byte diff --git a/pkg/restapi/v1/issuer/testdata/sample_vc_invalid_university_degree.jsonld b/pkg/restapi/v1/issuer/testdata/sample_vc_invalid_university_degree.jsonld new file mode 100644 index 000000000..e49ca10c7 --- /dev/null +++ b/pkg/restapi/v1/issuer/testdata/sample_vc_invalid_university_degree.jsonld @@ -0,0 +1,29 @@ +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1", + "https://w3id.org/security/bbs/v1" + ], + "credentialSchema": [], + "credentialSubject": { + "alumniOf": { + "id": "did:example:c276e12ec21ebfeb1f712ebc6f1" + }, + "degree": { + "type": "BachelorDegree", + "name": "Bachelor of Science and Arts" + } + }, + "expirationDate": "2038-01-19T03:14:00Z", + "id": "http://example.edu/credentials/1872", + "issuanceDate": "2010-01-01T19:23:24Z", + "issuer": { + "id": "did:example:76e12ec712ebc6f1c221ebfeb1f", + "name": "Example University" + }, + "referenceNumber": 83294847, + "type": [ + "VerifiableCredential", + "UniversityDegreeCredential" + ] +} \ No newline at end of file diff --git a/pkg/restapi/v1/issuer/testdata/sample_vc_university_degree.jsonld b/pkg/restapi/v1/issuer/testdata/sample_vc_university_degree.jsonld index 520f78266..fbb9c6c4a 100644 --- a/pkg/restapi/v1/issuer/testdata/sample_vc_university_degree.jsonld +++ b/pkg/restapi/v1/issuer/testdata/sample_vc_university_degree.jsonld @@ -6,13 +6,23 @@ ], "credentialSchema": [], "credentialSubject": { - "degree": { - "type": "BachelorDegree", - "university": "MIT" - }, - "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", - "name": "Jayden Doe", - "spouse": "did:example:c276e12ec21ebfeb1f712ebc6f1" + "alumniOf": { + "id": "did:example:c276e12ec21ebfeb1f712ebc6f1", + "name": [ + { + "value": "Example University", + "lang": "en" + }, + { + "value": "Exemple d'Université", + "lang": "fr" + } + ] + }, + "degree": { + "type": "BachelorDegree", + "name": "Bachelor of Science and Arts" + } }, "expirationDate": "2038-01-19T03:14:00Z", "id": "http://example.edu/credentials/1872", diff --git a/pkg/restapi/v1/issuer/testdata/universitydegree.schema.json b/pkg/restapi/v1/issuer/testdata/universitydegree.schema.json new file mode 100644 index 000000000..ec0e11281 --- /dev/null +++ b/pkg/restapi/v1/issuer/testdata/universitydegree.schema.json @@ -0,0 +1,49 @@ +{ + "$id": "https://trustbloc.com/universitydegree.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "UniversityDegreeCredential", + "type": "object", + "properties": { + "alumniOf": { + "type": "object", + "description": "Describes the university.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "array", + "description": "A list of language-specific names.", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "lang": { + "type": "string" + } + }, + "required": ["value", "lang"] + }, + "minItems": 1 + } + }, + "required": ["id", "name"] + }, + "degree": { + "type": "object", + "description": "Describes the degree.", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["type","name"] + } + }, + "required": ["alumniOf","degree"] +} \ No newline at end of file diff --git a/test/bdd/features/oidc4vc_api.feature b/test/bdd/features/oidc4vc_api.feature index 269482c7b..c48f52bca 100644 --- a/test/bdd/features/oidc4vc_api.feature +++ b/test/bdd/features/oidc4vc_api.feature @@ -61,6 +61,11 @@ Feature: OIDC4VC REST API And Issuer with id "bank_issuer/v1.0" is authorized as a Profile user Then User interacts with Wallet to initiate credential issuance using pre authorization code flow with invalid claims + Scenario: OIDC credential issuance and verification Pre Auth flow (Claims JSON schema validation error) + Given Organization "test_org" has been authorized with client id "f13d1va9lp403pb9lyj89vk55" and secret "ejqxi9jb1vew2jbdnogpjcgrz" + And Issuer with id "bank_issuer/v1.0" is authorized as a Profile user + Then User interacts with Wallet to initiate credential issuance using pre authorization code flow with invalid claims schema + Scenario: OIDC credential issuance and verification Pre Auth flow (Invalid Field in Presentation Definition) Given Organization "test_org" has been authorized with client id "f13d1va9lp403pb9lyj89vk55" and secret "ejqxi9jb1vew2jbdnogpjcgrz" And Issuer with id "i_myprofile_ud_es256k_jwt/v1.0" is authorized as a Profile user diff --git a/test/bdd/fixtures/profile/profiles.json b/test/bdd/fixtures/profile/profiles.json index 3233c0e22..e0ab49186 100644 --- a/test/bdd/fixtures/profile/profiles.json +++ b/test/bdd/fixtures/profile/profiles.json @@ -704,6 +704,7 @@ "https://www.w3.org/2018/credentials/v1", "https://www.w3.org/2018/credentials/examples/v1" ], + "jsonSchema": "{\"$id\":\"https://trustbloc.com/universitydegree.schema.json\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"title\":\"UniversityDegreeCredential\",\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"spouse\":{\"type\":\"string\"},\"degree\":{\"type\":\"object\",\"description\":\"Describes the degree.\",\"properties\":{\"type\":{\"type\":\"string\"},\"degree\":{\"type\":\"string\"}},\"required\":[\"type\",\"degree\"]}},\"required\":[\"name\",\"spouse\",\"degree\"]}", "type": "UniversityDegreeCredential", "id": "universityDegreeTemplateID", "issuer": "did:orb:bank_issuer", diff --git a/test/bdd/pkg/v1/oidc4vc/oidc4ci.go b/test/bdd/pkg/v1/oidc4vc/oidc4ci.go index 941cace0f..c15f699e2 100644 --- a/test/bdd/pkg/v1/oidc4vc/oidc4ci.go +++ b/test/bdd/pkg/v1/oidc4vc/oidc4ci.go @@ -163,6 +163,31 @@ func (s *Steps) runOIDC4CIPreAuthWithInvalidClaims() error { return nil } +func (s *Steps) runOIDC4CIPreAuthWithInvalidClaimsSchema() error { + initiateIssuanceRequest := initiateOIDC4CIRequest{ + CredentialTemplateId: "universityDegreeTemplateID", + ClaimData: &map[string]interface{}{ + "degree": map[string]string{ + "degree": "MIT", + }, + // "name": "Jayden Doe", + "spouse": "did:example:c276e12ec21ebfeb1f712ebc6f1", + }, + UserPinRequired: true, + } + + err := s.runOIDC4CIPreAuth(initiateIssuanceRequest) + if err == nil { + return errors.New("error expected") + } + + if !strings.Contains(err.Error(), "validate claims: validation error: [(root): name is required; degree: type is required]") { + return fmt.Errorf("unexpected error: %w", err) + } + + return nil +} + func (s *Steps) fetchClaimData(issuedCredentialType string) (map[string]interface{}, error) { resp, err := bddutil.HTTPSDo( http.MethodPost, diff --git a/test/bdd/pkg/v1/oidc4vc/steps.go b/test/bdd/pkg/v1/oidc4vc/steps.go index 62454533b..f06683466 100644 --- a/test/bdd/pkg/v1/oidc4vc/steps.go +++ b/test/bdd/pkg/v1/oidc4vc/steps.go @@ -130,6 +130,7 @@ func (s *Steps) RegisterSteps(sc *godog.ScenarioContext) { // Errors. sc.Step(`^User interacts with Wallet to initiate credential issuance using pre authorization code flow with invalid claims$`, s.runOIDC4CIPreAuthWithInvalidClaims) + sc.Step(`^User interacts with Wallet to initiate credential issuance using pre authorization code flow with invalid claims schema$`, s.runOIDC4CIPreAuthWithInvalidClaimsSchema) sc.Step(`^User interacts with Wallet to initiate credential issuance using pre authorization code flow and receives "([^"]*)" error$`, s.runOIDC4CIPreAuthWithError) sc.Step(`^Verifier form organization "([^"]*)" requests deleted interactions claims$`, s.retrieveExpiredOrDeletedInteractionsClaim) sc.Step(`^Verifier form organization "([^"]*)" requests expired interactions claims$`, s.retrieveExpiredOrDeletedInteractionsClaim)