Related: #4248, #4233, #4156
Problem Statement
Nuts derives metadata URLs using only the RFC 8414 insert convention: the well-known segment is placed at the authority root and the issuer/AS path is appended after it (https://host/.well-known/<doc>/<path>). This is implemented in credentialIssuerWellKnown (auth/openid4vci/client.go:248) for Credential Issuer Metadata and in oauth.IssuerIdToWellKnown (auth/oauth/types.go:255) for OAuth Authorization Server Metadata.
Many issuers and authorization servers publish metadata only under the OIDC Discovery append convention, where the well-known segment comes after the path (https://host/<path>/.well-known/<doc>). Against those servers Nuts gets a 404 (or 401/403/405) on metadata discovery and cannot complete issuance or token flows at all. Today the only workaround is per-deployment nginx rewriting that bridges insert -> append, which every operator integrating with such a partner has to set up and maintain by hand.
Both metadata documents are affected:
- Credential Issuer Metadata (
openid-credential-issuer) -- auth/openid4vci/client.go
- OAuth Authorization Server Metadata (
oauth-authorization-server) -- auth/client/iam/client.go via auth/oauth
Solution
When the issuer/AS identifier has a non-empty path, try the candidate well-known locations in priority order and take the first 2xx response:
- insert (RFC 8414, current behavior, spec-preferred)
- append (OIDC Discovery style)
- AS metadata only: also
openid-configuration (append form)
This is spec-defensible: RFC 8414 §5 acknowledges the OIDC Discovery append style for compatibility, and OpenID4VCI permits retrieving AS metadata via either RFC 8414 or OIDC Discovery. The insert form stays first so spec-compliant servers are unaffected and no extra request is made against them on the happy path.
When the identifier has no path, insert and append collapse to the same URL -- derivation is unchanged and only one request is made. The fallback only widens behavior; it never narrows it. Each candidate URL is independently validated for SSRF (core.ParsePublicURL / validateURL) before any request, and the existing identifier-match check (credential_issuer / issuer must equal the requested identifier) is applied to whichever candidate returns 2xx, so the fallback cannot be steered to an attacker-chosen document.
We considered making the convention an explicit per-issuer config flag but rejected it: operators integrating with these partners can't be expected to know which convention a given server uses, and the identifier-match check already makes "try both" safe.
User Stories
User Stories
- As a node operator integrating with an append-convention authorization server, I want Nuts to discover its metadata automatically, so that I don't have to run nginx rewrite rules per deployment.
- As a wallet/holder node, I want credential issuance against an append-convention Credential Issuer to succeed, so that issuance isn't blocked by a 404 on metadata discovery.
- As a developer, I want spec-compliant (insert) servers to keep working with a single request on the happy path, so that the fallback adds no latency or load against them.
Implementation Decisions
Candidate derivation and ordering
Add a new helper in auth/oauth that, given an identifier and a well-known document name, returns the ordered candidate URLs. IssuerIdToWellKnown is left untouched for its existing single-URL callers.
// wellKnownCandidates returns the metadata URLs to try, in priority order:
// 1. insert (RFC 8414): https://host/.well-known/<doc>/<path>
// 2. append (OIDC): https://host/<path>/.well-known/<doc>
// When the identifier has no path, both collapse to the same URL and a single
// candidate is returned. Caller fetches in order and takes the first 2xx.
func wellKnownCandidates(identifier, wellKnown string) ([]string, error)
Insert form (candidate 1) is exactly today's derivation, preserving the existing RawPath/%2F double-escape handling already in credentialIssuerWellKnown.
Fetch-with-fallback and failure modes
Each fetch wrapper iterates candidates and stops at the first 2xx whose body passes the identifier-match check:
- Fall through to the next candidate on any non-2xx status (servers commonly use
401/403/405, not 404, for a path they don't serve) or an identifier-match failure on a 2xx body.
- Accumulate the failures across all attempted candidates. When all are exhausted, fail loud with an error that names the identifier and reports the non-404 failures only -- a
404 just means "not at this location" and is noise, whereas a 403/405/5xx is the diagnostic worth surfacing. If every candidate returned 404, report a plain "metadata not found at any candidate" naming the identifier.
- Preserve the existing severity mapping: if any accumulated failure was
>= 500, surface it as 502 Bad Gateway (matching OAuthAuthorizationServerMetadata today at auth/client/iam/client.go:78). No silent empty-metadata fallback.
- Redirects (
3xx) are left to the HTTP client's default follow behavior; only the final status surfaces to this loop.
openid-configuration for AS metadata only
The third candidate (OpenIdConfigurationWellKnown, append form) is added only for the AS metadata path, matching OpenID4VCI's allowance. Credential Issuer Metadata has no openid-configuration equivalent and gets only insert + append. openid-configuration is the OIDC OP/AS discovery document; it carries AS metadata fields, not the Credential Issuer Metadata fields (credential_issuer, credential_endpoint, credential_configurations_supported), so it cannot satisfy issuer-metadata discovery and the credential_issuer match check would reject it anyway.
Modules to build/modify
auth/oauth: add wellKnownCandidates returning the ordered slice. Leave IssuerIdToWellKnown and its callers at auth/client/iam/client.go:296,349 (openid-configuration, cred-issuer) unchanged.
auth/client/iam/client.go: OAuthAuthorizationServerMetadata (:61) iterates insert -> append -> openid-configuration.
auth/openid4vci/client.go: OpenIDCredentialIssuerMetadata (:119) iterates insert -> append; the identifier-match check at :150 moves inside the per-candidate loop.
Testing Decisions
A good test asserts external behavior: given a server that serves metadata only at the append URL (and 404s the insert URL), discovery succeeds; given an insert-only server, discovery succeeds with a single request; given a server that 404s every candidate, discovery fails loud with an error naming the identifier; given a candidate that returns 403/500, that status is preserved in the final error after the remaining candidates are exhausted. Test against an httptest.Server with per-path handlers rather than asserting derived URL strings.
Modules to test:
auth/openid4vci: insert-only, append-only, both-404 (plain not-found error), identifier-mismatch-on-first-candidate (must fall through), and non-404-on-first (e.g. 403/500 preserved in the exhausted error) scenarios.
auth/client/iam: same matrix plus the openid-configuration third candidate; assert the >= 500 -> 502 mapping survives.
auth/oauth: unit-test wellKnownCandidates ordering for path and no-path identifiers, including %2F-encoded paths.
- Prior art: existing
IssuerIdToWellKnown tests in auth/oauth/types_test.go; existing metadata tests in auth/client/iam.
Impact Assessment
Backwards compatibility: fully additive. Insert-convention servers see identical behavior and a single request on the happy path.
Versioning: minor.
Configuration/deployment: no new config keys. Operators can remove per-deployment nginx metadata-rewrite rules once on this version.
Security: no new key material. New outbound requests against the same host are still SSRF-validated per candidate (core.ParsePublicURL), and the identifier-match check is enforced on the accepted document, so the fallback can't be redirected to an attacker-chosen metadata file. The widened candidate set means up to 2-3 outbound GETs against partner hosts on a miss -- a minor amplification, bounded and same-origin.
Spec stability: RFC 8414 (final) §5; OpenID4VCI 1.0 metadata retrieval.
Out of Scope
- Per-issuer explicit convention configuration -- rejected above; revisit only if the identifier-match check proves insufficient.
- Caching of which convention a given host uses across requests -- possible later optimization, not needed for correctness.
- Changing how Nuts serves its own metadata (this PRD is client-side discovery only).
- Issuer-metadata fallback is insert -> append only. Non-standard issuers that merge Credential Issuer Metadata into their
openid-configuration are out of scope; supporting them would require relaxing the credential_issuer match requirement, a separate and riskier decision.
- Consolidating SSRF validation into the shared HTTP client (already tracked as the TODO at
auth/openid4vci/client.go:104).
Further Notes
The two documents live in different packages with different fetch wrappers (openid4vci.client uses a raw http.Client; iam.HTTPClient uses doGet), so the shared piece is candidate derivation, not the fetch loop. Each wrapper keeps its own loop to preserve its existing error mapping.
Implementation Plan
| # |
Description |
PR |
Status |
Depends on |
| 1 |
Add wellKnownCandidates ordered derivation in auth/oauth + unit tests |
-- |
-- |
-- |
| 2 |
AS metadata fallback (insert -> append -> openid-configuration) in iam.OAuthAuthorizationServerMetadata |
-- |
-- |
#1 |
| 3 |
Credential Issuer Metadata fallback (insert -> append) in openid4vci.OpenIDCredentialIssuerMetadata |
-- |
-- |
#1 |
Related: #4248, #4233, #4156
Problem Statement
Nuts derives metadata URLs using only the RFC 8414 insert convention: the well-known segment is placed at the authority root and the issuer/AS path is appended after it (
https://host/.well-known/<doc>/<path>). This is implemented incredentialIssuerWellKnown(auth/openid4vci/client.go:248) for Credential Issuer Metadata and inoauth.IssuerIdToWellKnown(auth/oauth/types.go:255) for OAuth Authorization Server Metadata.Many issuers and authorization servers publish metadata only under the OIDC Discovery append convention, where the well-known segment comes after the path (
https://host/<path>/.well-known/<doc>). Against those servers Nuts gets a 404 (or 401/403/405) on metadata discovery and cannot complete issuance or token flows at all. Today the only workaround is per-deployment nginx rewriting that bridges insert -> append, which every operator integrating with such a partner has to set up and maintain by hand.Both metadata documents are affected:
openid-credential-issuer) --auth/openid4vci/client.gooauth-authorization-server) --auth/client/iam/client.goviaauth/oauthSolution
When the issuer/AS identifier has a non-empty path, try the candidate well-known locations in priority order and take the first
2xxresponse:openid-configuration(append form)This is spec-defensible: RFC 8414 §5 acknowledges the OIDC Discovery append style for compatibility, and OpenID4VCI permits retrieving AS metadata via either RFC 8414 or OIDC Discovery. The insert form stays first so spec-compliant servers are unaffected and no extra request is made against them on the happy path.
When the identifier has no path, insert and append collapse to the same URL -- derivation is unchanged and only one request is made. The fallback only widens behavior; it never narrows it. Each candidate URL is independently validated for SSRF (
core.ParsePublicURL/validateURL) before any request, and the existing identifier-match check (credential_issuer/issuermust equal the requested identifier) is applied to whichever candidate returns2xx, so the fallback cannot be steered to an attacker-chosen document.We considered making the convention an explicit per-issuer config flag but rejected it: operators integrating with these partners can't be expected to know which convention a given server uses, and the identifier-match check already makes "try both" safe.
User Stories
User Stories
Implementation Decisions
Candidate derivation and ordering
Add a new helper in
auth/oauththat, given an identifier and a well-known document name, returns the ordered candidate URLs.IssuerIdToWellKnownis left untouched for its existing single-URL callers.Insert form (candidate 1) is exactly today's derivation, preserving the existing
RawPath/%2Fdouble-escape handling already incredentialIssuerWellKnown.Fetch-with-fallback and failure modes
Each fetch wrapper iterates candidates and stops at the first
2xxwhose body passes the identifier-match check:401/403/405, not404, for a path they don't serve) or an identifier-match failure on a2xxbody.404just means "not at this location" and is noise, whereas a403/405/5xxis the diagnostic worth surfacing. If every candidate returned404, report a plain "metadata not found at any candidate" naming the identifier.>= 500, surface it as502 Bad Gateway(matchingOAuthAuthorizationServerMetadatatoday atauth/client/iam/client.go:78). No silent empty-metadata fallback.3xx) are left to the HTTP client's default follow behavior; only the final status surfaces to this loop.openid-configurationfor AS metadata onlyThe third candidate (
OpenIdConfigurationWellKnown, append form) is added only for the AS metadata path, matching OpenID4VCI's allowance. Credential Issuer Metadata has noopenid-configurationequivalent and gets only insert + append.openid-configurationis the OIDC OP/AS discovery document; it carries AS metadata fields, not the Credential Issuer Metadata fields (credential_issuer,credential_endpoint,credential_configurations_supported), so it cannot satisfy issuer-metadata discovery and thecredential_issuermatch check would reject it anyway.Modules to build/modify
auth/oauth: addwellKnownCandidatesreturning the ordered slice. LeaveIssuerIdToWellKnownand its callers atauth/client/iam/client.go:296,349(openid-configuration, cred-issuer) unchanged.auth/client/iam/client.go:OAuthAuthorizationServerMetadata(:61) iterates insert -> append -> openid-configuration.auth/openid4vci/client.go:OpenIDCredentialIssuerMetadata(:119) iterates insert -> append; the identifier-match check at:150moves inside the per-candidate loop.Testing Decisions
A good test asserts external behavior: given a server that serves metadata only at the append URL (and 404s the insert URL), discovery succeeds; given an insert-only server, discovery succeeds with a single request; given a server that 404s every candidate, discovery fails loud with an error naming the identifier; given a candidate that returns
403/500, that status is preserved in the final error after the remaining candidates are exhausted. Test against anhttptest.Serverwith per-path handlers rather than asserting derived URL strings.Modules to test:
auth/openid4vci: insert-only, append-only, both-404 (plain not-found error), identifier-mismatch-on-first-candidate (must fall through), and non-404-on-first (e.g. 403/500 preserved in the exhausted error) scenarios.auth/client/iam: same matrix plus theopenid-configurationthird candidate; assert the>= 500->502mapping survives.auth/oauth: unit-testwellKnownCandidatesordering for path and no-path identifiers, including%2F-encoded paths.IssuerIdToWellKnowntests inauth/oauth/types_test.go; existing metadata tests inauth/client/iam.Impact Assessment
Backwards compatibility: fully additive. Insert-convention servers see identical behavior and a single request on the happy path.
Versioning: minor.
Configuration/deployment: no new config keys. Operators can remove per-deployment nginx metadata-rewrite rules once on this version.
Security: no new key material. New outbound requests against the same host are still SSRF-validated per candidate (
core.ParsePublicURL), and the identifier-match check is enforced on the accepted document, so the fallback can't be redirected to an attacker-chosen metadata file. The widened candidate set means up to 2-3 outbound GETs against partner hosts on a miss -- a minor amplification, bounded and same-origin.Spec stability: RFC 8414 (final) §5; OpenID4VCI 1.0 metadata retrieval.
Out of Scope
openid-configurationare out of scope; supporting them would require relaxing thecredential_issuermatch requirement, a separate and riskier decision.auth/openid4vci/client.go:104).Further Notes
The two documents live in different packages with different fetch wrappers (
openid4vci.clientuses a rawhttp.Client;iam.HTTPClientusesdoGet), so the shared piece is candidate derivation, not the fetch loop. Each wrapper keeps its own loop to preserve its existing error mapping.Implementation Plan
wellKnownCandidatesordered derivation inauth/oauth+ unit testsiam.OAuthAuthorizationServerMetadataopenid4vci.OpenIDCredentialIssuerMetadata