Skip to content

Commit

Permalink
feat: Validate issuer claims against JSON schema (#1381)
Browse files Browse the repository at this point in the history
Added a JSON schema field to the credentialTemplate profile element. If the "jsonSchema" field is not empty then the issuer claims are validated against the schema.

Signed-off-by: Bob Stasyszyn <[email protected]>
  • Loading branch information
bstasyszyn authored Aug 18, 2023
1 parent 6421291 commit f3207e9
Show file tree
Hide file tree
Showing 10 changed files with 366 additions and 14 deletions.
1 change: 1 addition & 0 deletions pkg/profile/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
72 changes: 70 additions & 2 deletions pkg/restapi/v1/issuer/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}

Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand Down
173 changes: 168 additions & 5 deletions pkg/restapi/v1/issuer/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
},
)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
]
}
24 changes: 17 additions & 7 deletions pkg/restapi/v1/issuer/testdata/sample_vc_university_degree.jsonld
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading

0 comments on commit f3207e9

Please sign in to comment.