Skip to content

Metadata discovery: support both insert (RFC 8414) and append (OIDC Discovery) well-known forms #4310

@reinkrul

Description

@reinkrul

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:

  1. insert (RFC 8414, current behavior, spec-preferred)
  2. append (OIDC Discovery style)
  3. 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
  1. 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.
  2. 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.
  3. 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

  1. 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.
  2. auth/client/iam/client.go: OAuthAuthorizationServerMetadata (:61) iterates insert -> append -> openid-configuration.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions