Skip to content

Conversation

@reinkrul
Copy link
Member

@reinkrul reinkrul commented Feb 2, 2026

I have chosen to simply add an id_token field in the token request, and the Nuts node (OAuth client side) creates a DeziIDTokenCredential from it. This is then added as extra credential, so the same could be achieved by the EHR by making the credential itself and passing it in the existing credentials field.

TODO:

  • Check test coverage

Closes #3980

@qltysh
Copy link

qltysh bot commented Feb 2, 2026

Qlty

Coverage Impact

⬇️ Merging this pull request will decrease total coverage on master by 0.19%.

Modified Files with Diff Coverage (6)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: B
vcr/credential/util.go0.0%122-134
Coverage rating: A Coverage rating: A
vcr/verifier/verifier.go100.0%
Coverage rating: B Coverage rating: B
auth/api/iam/api.go16.7%757-761
Coverage rating: A Coverage rating: B
vcr/credential/validator.go0.0%392-437
Coverage rating: A Coverage rating: A
vcr/credential/resolver.go0.0%45-46
New file Coverage rating: C
vcr/credential/dezi.go72.6%15-16, 20-21, 24-25...
Total41.0%
🤖 Increase coverage with AI coding...

In the `iss3980-validate-idtoken-credential` branch, add test coverage for this new code:

- `auth/api/iam/api.go` -- Line 757-761
- `vcr/credential/dezi.go` -- Lines 15-16, 20-21, 24-25, 28-29, 32-33, 37-38, 44-45, 48-49, 52-53, and 56-57
- `vcr/credential/resolver.go` -- Line 45-46
- `vcr/credential/util.go` -- Line 122-134
- `vcr/credential/validator.go` -- Line 392-437

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support for supplying an id_token (Dezi JWT) in the service access token request and converting it into a DeziIDTokenCredential so it can participate in VC/VP-based authorization.

Changes:

  • Extend IAM request model/API/docs to accept id_token, and append a derived Dezi credential to the requested credentials set.
  • Introduce DeziIDTokenCredential type, creation helper, and a validator hook (currently stubbed).
  • Add unit/e2e tests and allow DeziIDJWT as an OpenID ldp_vc/ldp_vp proof type.

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
vcr/verifier/verifier_test.go Adds verifier test and helper to create a Dezi credential from a signed JWT.
vcr/verifier/verifier.go Skips VC signature verification when credential is DeziIDTokenCredential.
vcr/pe/presentation_definition_test.go Adds presentation-exchange matching tests for DeziIDTokenCredential.
vcr/credential/validator.go Adds deziIDTokenCredentialValidator (currently not performing real JWT validation).
vcr/credential/util.go Allows auto-correction for Dezi credentials with “derived proof” (DeziIDJWT).
vcr/credential/types.go Introduces DeziIDTokenCredentialTypeURI.
vcr/credential/resolver.go Registers deziIDTokenCredentialValidator based on VC type.
vcr/credential/dezi_test.go Adds unit test for CreateDeziIDTokenCredential.
vcr/credential/dezi.go Implements CreateDeziIDTokenCredential to derive a VC from an id_token.
e2e-tests/oauth-flow/dezi_idtoken/run-test.sh Adds e2e flow script to request/introspect tokens using id_token.
e2e-tests/oauth-flow/dezi_idtoken/generate-jwt.sh Adds JWT generation helper for the e2e test.
e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml Adds docker-compose setup for the new e2e test scenario.
e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.pem Adds test certificate for the e2e environment.
e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.key Adds test private key for the e2e environment.
e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA-chain.pem Adds test certificate chain for issuing X509Credential in e2e.
e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.pem Adds test signing certificate for Dezi JWT generation.
e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.key Adds test signing private key for Dezi JWT generation.
e2e-tests/oauth-flow/dezi_idtoken/certs/README.md Documents how e2e cert material was generated.
e2e-tests/oauth-flow/dezi_idtoken/accesspolicy.json Adds policy requiring DeziIDTokenCredential and mapping fields into claims.
e2e-tests/browser/client/iam/generated.go Updates generated client model to include id_token.
docs/_static/auth/v2.yaml Documents the new id_token request field in the API spec.
auth/oauth/openid.go Adds DeziIDJWT as supported proof type for ldp_vc/ldp_vp.
auth/api/iam/generated.go Updates generated server model to include id_token.
auth/api/iam/api.go Appends a derived Dezi credential when id_token is provided.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +12 to +16
func CreateDeziIDTokenCredential(idTokenSerialized string) (*vc.VerifiableCredential, error) {
idToken, err := jwt.Parse([]byte(idTokenSerialized), jwt.WithVerify(false), jwt.WithAcceptableSkew(time.Hour*24*365*10))
if err != nil {
return nil, fmt.Errorf("parsing id_token: %w", err)
}
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CreateDeziIDTokenCredential parses the id_token with signature verification explicitly disabled and with an extremely large acceptable clock skew (10 years). This turns untrusted input into a credential that will later be treated as “verified”. Parse and verify the JWT signature (using the x5c/x5t headers as per spec) and enforce reasonable skew / required time claims (nbf/exp) so expired/not-yet-valid tokens are rejected.

Copilot uses AI. Check for mistakes.
if len(credential.Proof) > 0 {
return credential
var proof []proof.LDProof
_ = credential.UnmarshalProofValue(&proof)
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AutoCorrectSelfAttestedCredential ignores the error from UnmarshalProofValue. If proof unmarshalling fails, this silently skips DeziIDJWT detection and can lead to credentials not being auto-corrected when expected. Handle the error explicitly (e.g., treat it as non-Dezi and return early, or return an error to the caller) instead of discarding it.

Suggested change
_ = credential.UnmarshalProofValue(&proof)
if err := credential.UnmarshalProofValue(&proof); err != nil {
// If the proof cannot be unmarshalled, treat it as non-Dezi and return without auto-correction.
return credential
}

Copilot uses AI. Check for mistakes.
Comment on lines 929 to 932
err = token.Set("Dezi_id", "900000009")
require.NoError(t, err)
err = token.Set("x5c", []string{base64.StdEncoding.EncodeToString(keyPair.Leaf.Raw)})
require.NoError(t, err)
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Dezi JWT certificate chain is added as an x5c claim in the payload, but the (currently commented) validator code is written to extract x5c from the JWS protected headers. To avoid tests diverging from the intended validation approach, put x5c in the JWT header (or update the validator to use the claim if that’s what the Dezi spec requires).

Copilot uses AI. Check for mistakes.
"jwt": idTokenSerialized,
},
}
data, _ := json.Marshal(credentialMap)
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

json.Marshal errors are ignored here. While unlikely, marshaling can fail (e.g., if a claim value ends up being a non-marshalable type), and ignoring it can lead to confusing downstream parse errors. Handle and return the marshal error explicitly.

Suggested change
data, _ := json.Marshal(credentialMap)
data, err := json.Marshal(credentialMap)
if err != nil {
return nil, fmt.Errorf("marshaling credential map: %w", err)
}

Copilot uses AI. Check for mistakes.
Comment on lines +165 to +168
// DeziIDTokenCredential: signature is verified by Dezi id_token inside the credential. Signature verification is skipped here.
if credentialToVerify.IsType(credential.DeziIDTokenCredentialTypeURI) {
checkSignature = false
}
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disabling VC signature verification for DeziIDTokenCredential makes the credential effectively unverifiable unless the embedded id_token is cryptographically verified elsewhere. The current Dezi validator does not verify the JWT signature, so this becomes an integrity/authenticity bypass. Instead of skipping signature verification here, verify the embedded id_token signature (and its trust chain) as part of credential validation, or keep VC signature verification enabled when no embedded-token verification is performed.

Suggested change
// DeziIDTokenCredential: signature is verified by Dezi id_token inside the credential. Signature verification is skipped here.
if credentialToVerify.IsType(credential.DeziIDTokenCredentialTypeURI) {
checkSignature = false
}

Copilot uses AI. Check for mistakes.
Comment on lines 392 to 412
func (d deziIDTokenCredentialValidator) Validate(credential vc.VerifiableCredential) error {
type proofType struct {
Type string `json:"type"`
JWT string `json:"jwt"`
}
proofs := []proofType{}
if err := credential.UnmarshalProofValue(&proofs); err != nil {
return fmt.Errorf("%w: invalid proof format: %w", errValidation, err)
}
if len(proofs) != 1 {
return fmt.Errorf("%w: expected exactly one proof, got %d", errValidation, len(proofs))
}
proof := proofs[0]
if proof.Type != "DeziIDJWT" {
return fmt.Errorf("%w: invalid proof type: expected 'DeziIDToken', got '%s'", errValidation, proof.Type)
}
if err := d.validateDeziToken(credential, proof.JWT); err != nil {
return fmt.Errorf("%w: invalid Dezi id_token: %w", errValidation, err)
}
return (defaultCredentialValidator{}).Validate(credential)
}
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deziIDTokenCredentialValidator currently accepts any embedded JWT: validateDeziToken always returns nil and all signature/claim checks are commented out. This means an attacker can forge a DeziIDTokenCredential with arbitrary subject/roles and it will pass validation. Implement actual JWT parsing with signature verification (e.g., using x5c from the JWS header) and validate required claims (exp/nbf/iss/aud) before accepting the credential.

Copilot uses AI. Check for mistakes.
Comment on lines 880 to 888
keyPair, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem")
require.NoError(t, err)

key, err := jwk.FromRaw(keyPair.PrivateKey)
require.NoError(t, err)

// Set the key ID and x5t (X.509 thumbprint)
x5t := sha1.Sum(keyPair.Leaf.Raw)
err = key.Set(jwk.KeyIDKey, base64.StdEncoding.EncodeToString(x5t[:]))
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tls.LoadX509KeyPair does not reliably populate keyPair.Leaf (the codebase sets it explicitly elsewhere, e.g. core/server_config.go:113). Accessing keyPair.Leaf.Raw here can panic with a nil pointer. Parse the leaf certificate from keyPair.Certificate[0] and assign it to keyPair.Leaf before using it.

Copilot uses AI. Check for mistakes.
Comment on lines 938 to 939
println(string(signed))

Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test helper prints the signed JWT to stdout. That adds noise to go test output and can leak potentially sensitive test material in CI logs. Please remove the println or gate it behind an explicit debug flag.

Suggested change
println(string(signed))

Copilot uses AI. Check for mistakes.
Comment on lines 108 to 123
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
token := jwt.New()
_ = token.Set(jwt.NotBeforeKey, 1701933627)
_ = token.Set(jwt.ExpirationKey, 1701933697)
_ = token.Set("initials", "B.B.")
_ = token.Set("surname", "Jansen")
_ = token.Set("surname_prefix", "van der")
_ = token.Set("Dezi_id", "900000009")
_ = token.Set("relations", []map[string]interface{}{
{
"entity_name": "Zorgaanbieder",
"roles": []string{"01.041", "30.000"},
"ura": "87654321",
},
})
signedToken, _ := jwt.Sign(token, jwt.WithKey(jwa.ES256, privateKey))
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test ignores errors from key generation, claim setting, signing, and JSON marshaling (using _ = / _ , _ :=). If any of these steps fails, the test may pass/fail for the wrong reason. Use require.NoError for each operation so failures are surfaced clearly.

Suggested change
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
token := jwt.New()
_ = token.Set(jwt.NotBeforeKey, 1701933627)
_ = token.Set(jwt.ExpirationKey, 1701933697)
_ = token.Set("initials", "B.B.")
_ = token.Set("surname", "Jansen")
_ = token.Set("surname_prefix", "van der")
_ = token.Set("Dezi_id", "900000009")
_ = token.Set("relations", []map[string]interface{}{
{
"entity_name": "Zorgaanbieder",
"roles": []string{"01.041", "30.000"},
"ura": "87654321",
},
})
signedToken, _ := jwt.Sign(token, jwt.WithKey(jwa.ES256, privateKey))
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
token := jwt.New()
err = token.Set(jwt.NotBeforeKey, 1701933627)
require.NoError(t, err)
err = token.Set(jwt.ExpirationKey, 1701933697)
require.NoError(t, err)
err = token.Set("initials", "B.B.")
require.NoError(t, err)
err = token.Set("surname", "Jansen")
require.NoError(t, err)
err = token.Set("surname_prefix", "van der")
require.NoError(t, err)
err = token.Set("Dezi_id", "900000009")
require.NoError(t, err)
err = token.Set("relations", []map[string]interface{}{
{
"entity_name": "Zorgaanbieder",
"roles": []string{"01.041", "30.000"},
"ura": "87654321",
},
})
require.NoError(t, err)
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, privateKey))
require.NoError(t, err)

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@qltysh
Copy link

qltysh bot commented Feb 2, 2026

❌ 25 blocking issues (31 total)

Tool Category Rule Count
shellcheck Lint Double quote to prevent globbing and word splitting. 22
shellcheck Lint Not following: ../../util.sh was not specified as input (see shellcheck -x). 1
shellcheck Lint Quotes/backslashes will be treated literally. Use an array. 1
shellcheck Lint Quotes/backslashes in this variable will not be respected. 1
ripgrep Lint // TODO: Create JSON-LD context? 4
qlty Structure Function with many returns (count = 8): RequestServiceAccessToken 2

@reinkrul reinkrul changed the title #3980: Support validation of DeziIDTokenCredential #3980: Support of DeziIDTokenCredential Feb 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support validation of DeziIDTokenCredential

2 participants