Skip to content

fix(vcr): bound negative OpenID4VCI identifier caching, guard against empty offers - #4470

Open
reinkrul wants to merge 3 commits into
masterfrom
fix/4469-tls-identifier-cache-empty-result
Open

fix(vcr): bound negative OpenID4VCI identifier caching, guard against empty offers#4470
reinkrul wants to merge 3 commits into
masterfrom
fix/4469-tls-identifier-cache-empty-result

Conversation

@reinkrul

@reinkrul reinkrul commented Sep 4, 2026

Copy link
Copy Markdown
Member

Related: #4469

Problem

tlsIdentifierResolver (vcr/openid4vci/identifiers.go) cached its resolution result as soon as it completed without erroring — including an empty string when neither the DID document nor TLS-certificate-derived candidates yielded a base URL. Once that happened, every later Resolve() call for that DID short-circuited on the cached empty value for the life of the process, even after the missing node-http-services-baseurl service was added, permanently breaking OpenID4VCI credential-offer delivery until restart.

Observed on the sender side (issuer), for a credential_offer sent with credential_issuer:"":

level=warning msg="Couldn't publish credential over OpenID4VCI, fallback to publish over Nuts network" credentialID="did:nuts:ISSUER_DID_REDACTED#REDACTED-CREDENTIAL-UUID" error="unable to offer the credential over OpenID4VCI to (wallet: https://receiver.example.com/n2n/identity/did:nuts:RECEIVER_DID_REDACTED): unable to offer credential (client-metadata-url=https://receiver.example.com/n2n/identity/did:nuts:RECEIVER_DID_REDACTED/openid4vci/credential_offer): offer credential error: unexpected http response code (...): 500" module=VCR

Observed on the receiver side, rejecting that same offer:

time="2026-09-03T08:16:47Z" level=error msg="HandleCredentialOffer failed" error="server_error - unable to create issuer client: empty Credential Issuer Identifier" module=VCR/OpenID4VCI operation=HandleCredentialOffer operationID=HandleCredentialOffer requestURI="/n2n/identity/did:nuts:RECEIVER_DID_REDACTED/openid4vci/credential_offer?credential_offer=..." user="<nil>"

Fix

Bound the negative cache instead of removing it. An earlier version of this fix stopped caching empty results at all, but this resolver can be called on every OpenID4VCI request, so re-running resolution (including the DID document lookup) on every single call for a DID that never gets fixed isn't free — the original negative caching was likely deliberate. Instead, reuse the existing lastAttempt/tlsAttemptInterval throttle (already used to rate-limit the expensive TLS-certificate-derived resolution) to also bound how long an empty result is treated as cached, rather than adding a separate cache window. A successful (non-empty) identifier is still cached indefinitely, unchanged.

Guard against sending a broken offer. Nothing previously stopped an OpenID4VCI credential offer from being sent with an empty credential_issuer — which is what produced the receiver-side "empty Credential Issuer Identifier" rejection shown above. Introduced openid4vci.ErrIdentifierNotConfigured, returned when resolution succeeds but yields no identifier, and issueUsingOpenID4VCI now treats it like an unsupported wallet (fallback to the network) instead of constructing and sending the broken offer.

Warn the operator, unlike the unsupported-wallet case. An unsupported wallet is the other party's problem — nothing this operator can act on, so it stays silent. A missing node-http-services-baseurl service is this node's own misconfiguration, so it now logs a Warn pointing the operator at what to search the documentation for, instead of failing the same silent way.

Test plan

  • TestTLSIdentifierResolver/empty_result_is_cached_briefly,_not_forever (new) — asserts a cache hit on an immediate second call, then a re-check once the throttle window elapses.
  • Test_vcr_GetOIDCIssuer/found_DID,_owned,_but_no_identifier_configured (new) — asserts ErrIdentifierNotConfigured when resolution yields an empty identifier.
  • Test_issuer_Issue/OpenID4VCI/ok_-_OpenID4VCI_issuer_identifier_not_(yet)_configured_-_fallback_to_network (new) — asserts a quiet fallback to network publish, no offer sent, and the new Warn log.
  • Existing vcr, vcr/issuer, vcr/openid4vci suites still pass.

Assisted by AI

…fier

tlsIdentifierResolver cached a resolution result as soon as it succeeded
without erroring, including an empty string when no base-URL service or
TLS-derived candidate could be found. Once that happened, every later
Resolve() call for that DID short-circuited on the cached empty value for
the life of the process, even after the missing node-http-services-baseurl
service was added, permanently breaking OpenID4VCI credential-offer
delivery until restart.

Only cache a non-empty identifier, and only treat a non-empty cached value
as a hit, so resolution is retried on every call until it actually
succeeds.

Assisted by AI
@qltysh

qltysh Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (3)

RatingFile% DiffUncovered Line #s
Coverage rating: C Coverage rating: C
vcr/vcr.go100.0%
Coverage rating: B Coverage rating: B
vcr/issuer/issuer.go100.0%
Coverage rating: B Coverage rating: B
vcr/openid4vci/identifiers.go100.0%
Total100.0%
🚦 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.

Address review feedback on the previous commit: caching nothing at all
for an unresolved identifier would re-run resolution (including the DID
document lookup) on every single call for a DID that never gets fixed,
since this resolver can be invoked on every OpenID4VCI request. That
negative caching was likely deliberate, not a bug.

Reuse the existing lastAttempt/tlsAttemptInterval throttle (already used
to rate-limit the expensive TLS-certificate-derived resolution) to also
bound how long an empty result is treated as cached, instead of adding a
separate cache window. A successful (non-empty) identifier is still
cached indefinitely, unchanged.

Also close the actual sending-side gap: nothing previously stopped an
OpenID4VCI credential offer from being sent with an empty
`credential_issuer`, which is what produced the receiver-side "empty
Credential Issuer Identifier" rejection in the first place. Introduce
openid4vci.ErrIdentifierNotConfigured, returned when resolution succeeds
but yields no identifier, and have issueUsingOpenID4VCI treat it like an
unsupported wallet (quiet fallback to the network, no error) instead of
constructing and sending a broken offer.

Assisted by AI
@reinkrul reinkrul changed the title fix(vcr): don't permanently cache an empty OpenID4VCI base-URL identifier fix(vcr): bound negative OpenID4VCI identifier caching, guard against empty offers Sep 4, 2026
Unlike an unsupported wallet (the other party's problem, no action for
this operator), a missing node-http-services-baseurl service is this
node's own misconfiguration and needs the operator's attention. Log a
Warn pointing them at what to search the documentation for, rather than
staying silent like the unsupported-wallet case.

Assisted by AI
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.

1 participant