Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 10 additions & 14 deletions auth/client/iam/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,22 +64,18 @@ func (hb HTTPClient) OAuthAuthorizationServerMetadata(ctx context.Context, oauth
// - did:web:example.com becomes https://example.com/.well-known/oauth-authorization-server
// - did:web:example.com:iam:123 becomes https://example.com/.well-known/oauth-authorization-server/did:web:example.com:iam:123

metadataURL, err := oauth.IssuerIdToWellKnown(oauthIssuer, oauth.AuthzServerWellKnown, hb.strictMode)
if err != nil {
return nil, err
}
var metadata oauth.AuthorizationServerMetadata
if err = hb.doGet(ctx, metadataURL.String(), &metadata); err != nil {
// if this is a core.HttpError and the status code >= 500 then we want the caller to receive a 502 Bad Gateway
// we do this by changing the status code of the error
// any other error should result in a 400 Bad Request
if httpErr, ok := err.(core.HttpError); ok && httpErr.StatusCode >= 500 {
httpErr.StatusCode = http.StatusBadGateway
return nil, httpErr
}
// Try the well-known locations in priority order and take the first that
// returns a matching document:
// 1. insert (RFC 8414): https://host/.well-known/oauth-authorization-server/<path>
// 2. append (OIDC Disc): https://host/<path>/.well-known/oauth-authorization-server
Comment thread
reinkrul marked this conversation as resolved.
// Many authorization servers publish metadata only under the append convention.
metadata, err := oauth.FetchMetadata[oauth.AuthorizationServerMetadata](ctx, hb.httpClient, oauthIssuer, hb.strictMode)
if err != nil && errors.Is(err, oauth.ErrAllCandidates4xx) {
// Every candidate rejected the request outright (no 5xx, no network/decode failure, no
// identifier mismatch): the identifier itself is most likely wrong.
return nil, errors.Join(ErrInvalidClientCall, err)
}
return &metadata, err
return metadata, err
}

// ClientMetadata retrieves the client metadata from the client metadata endpoint given in the authorization request.
Expand Down
73 changes: 46 additions & 27 deletions auth/client/iam/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,44 +50,63 @@ import (
func TestHTTPClient_OAuthAuthorizationServerMetadata(t *testing.T) {
ctx := context.Background()

t.Run("ok using root web:did", func(t *testing.T) {
result := oauth.AuthorizationServerMetadata{TokenEndpoint: "/token"}
handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: result}
tlsServer, client := testServerAndClient(t, &handler)

metadata, err := client.OAuthAuthorizationServerMetadata(ctx, tlsServer.URL)
// metadataServer serves AuthorizationServerMetadata at the listed paths and returns
// missStatus for any other path. The served issuer equals the server URL plus issuerPath,
// so it matches an identifier of tlsServer.URL+issuerPath. It records every requested path.
metadataServer := func(t *testing.T, missStatus int, issuerPath string, servedPaths ...string) (*httptest.Server, *HTTPClient, *[]string) {
var requested []string
served := make(map[string]struct{}, len(servedPaths))
var issuer string
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requested = append(requested, r.URL.Path)
if _, ok := served[r.URL.Path]; ok {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(oauth.AuthorizationServerMetadata{Issuer: issuer, TokenEndpoint: "/token"})
return
}
w.WriteHeader(missStatus)
})
tlsServer, client := testServerAndClient(t, handler)
issuer = tlsServer.URL + issuerPath
for _, p := range servedPaths {
served[p] = struct{}{}
}
return tlsServer, client, &requested
}

require.NoError(t, err)
require.NotNil(t, metadata)
assert.Equal(t, "/token", metadata.TokenEndpoint)
require.NotNil(t, handler.Request)
assert.Equal(t, "GET", handler.Request.Method)
assert.Equal(t, "/.well-known/oauth-authorization-server", handler.Request.URL.Path)
})
t.Run("ok using user web:did", func(t *testing.T) {
result := oauth.AuthorizationServerMetadata{TokenEndpoint: "/token"}
handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: result}
tlsServer, client := testServerAndClient(t, &handler)
// The insert/append fallback, identifier-match, and error-joining behavior is exhaustively
// covered by oauth.FetchMetadata's own tests; this wraps it with no extra logic, so these
// tests only need to confirm the wiring (well-known constant, httpClient, strictMode) and
// that this specific bug (rewriting an upstream status code) doesn't reappear.
t.Run("ok - append form when insert 404s", func(t *testing.T) {
tlsServer, client, requested := metadataServer(t, http.StatusNotFound, "/iam/123", "/iam/123/.well-known/oauth-authorization-server")

metadata, err := client.OAuthAuthorizationServerMetadata(ctx, tlsServer.URL+"/iam/123")

require.NoError(t, err)
require.NotNil(t, metadata)
assert.Equal(t, "/token", metadata.TokenEndpoint)
require.NotNil(t, handler.Request)
assert.Equal(t, "GET", handler.Request.Method)
assert.Equal(t, "/.well-known/oauth-authorization-server/iam/123", handler.Request.URL.Path)
assert.Equal(t, []string{
"/.well-known/oauth-authorization-server/iam/123",
"/iam/123/.well-known/oauth-authorization-server",
}, *requested)
})
t.Run("error - server error changes status code to 502", func(t *testing.T) {
handler := http2.Handler{StatusCode: http.StatusInternalServerError}
tlsServer, client := testServerAndClient(t, &handler)
t.Run("error - errors are returned as-is", func(t *testing.T) {
tlsServer, client, _ := metadataServer(t, http.StatusInternalServerError, "")

_, err := client.OAuthAuthorizationServerMetadata(ctx, tlsServer.URL)

require.Error(t, err)
httpErr, ok := err.(core.HttpError)
require.True(t, ok)
assert.Equal(t, http.StatusBadGateway, httpErr.StatusCode)
var httpErr core.HttpError
require.ErrorAs(t, err, &httpErr)
assert.Equal(t, http.StatusInternalServerError, httpErr.StatusCode)
})
t.Run("error - all candidates 4xx classifies as ErrInvalidClientCall", func(t *testing.T) {
tlsServer, client, _ := metadataServer(t, http.StatusNotFound, "/iam/123")

_, err := client.OAuthAuthorizationServerMetadata(ctx, tlsServer.URL+"/iam/123")

require.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidClientCall)
})
}

Expand Down
7 changes: 3 additions & 4 deletions auth/client/iam/openid4vp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,7 @@ func TestIAMClient_AuthorizationServerMetadata(t *testing.T) {
_, err := ctx.client.AuthorizationServerMetadata(context.Background(), ctx.tlsServer.URL)

require.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidClientCall)
assert.ErrorContains(t, err, "server returned HTTP 404 (expected: 200)")
assert.ErrorContains(t, err, "failed to retrieve metadata")
})
}

Expand Down Expand Up @@ -381,8 +380,7 @@ func TestRelyingParty_RequestServiceAccessToken(t *testing.T) {
_, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil)

require.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidClientCall)
assert.ErrorContains(t, err, "server returned HTTP 404 (expected: 200)")
assert.ErrorContains(t, err, "failed to retrieve metadata")
})
t.Run("error - faulty presentation definition", func(t *testing.T) {
ctx := createClientServerTestContext(t)
Expand Down Expand Up @@ -1067,6 +1065,7 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext {
ctx.verifierURL = test2.MustParseURL(ctx.tlsServer.URL)
ctx.issuerDID = didweb.ServerURLToDIDWeb(t, ctx.tlsServer.URL+"/issuer")
ctx.authzServerMetadata = metadata
ctx.authzServerMetadata.Issuer = ctx.tlsServer.URL
ctx.authzServerMetadata.TokenEndpoint = ctx.tlsServer.URL + "/token"
ctx.authzServerMetadata.PresentationDefinitionEndpoint = ctx.tlsServer.URL + "/presentation_definition"
ctx.authzServerMetadata.AuthorizationEndpoint = ctx.tlsServer.URL + "/authorize"
Expand Down
143 changes: 143 additions & 0 deletions auth/oauth/metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* Copyright (C) 2026 Nuts community
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/

package oauth

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"

"github.com/nuts-foundation/nuts-node/core"
)

// ErrAllCandidates4xx indicates every well-known candidate for a FetchMetadata call responded
// with an HTTP 4xx status. It typically means the requested identifier doesn't correspond to a
// real issuer/AS, rather than the server being down or misconfigured; callers may use it to
// classify the failure as a client error instead of the default server error.
var ErrAllCandidates4xx = errors.New("metadata not found: every candidate returned a client error")

// FetchMetadata retrieves and validates a JSON metadata document of type T for identifier. The
// well-known path is taken from T.WellKnownPath(); the document is tried in both placements in
// priority order and the first candidate that returns 200, JSON-decodes into T, and whose
// GetIssuer() is identical to identifier is returned:
//
// 1. insert (RFC 8414): https://host/.well-known/<wellKnown>/<path>
// 2. append (OIDC Disc): https://host/<path>/.well-known/<wellKnown>
//
// When identifier has no path, insert and append collapse to a single URL. Every candidate
// shares identifier's scheme and host, so the single core.ParsePublicURL SSRF check on
// identifier covers them all.
//
// When every candidate fails, the returned error joins each candidate's failure. A per-candidate
// core.HttpError stays recoverable through the join (see errors.AsType), so callers can still
// inspect the original upstream status. If every candidate responded with an HTTP 4xx status (as
// opposed to a 5xx, network failure, decode failure, or identifier mismatch), the returned error
// also wraps ErrAllCandidates4xx.
func FetchMetadata[T interface {
WellKnownPath() string
GetIssuer() string
}](ctx context.Context, httpClient core.HTTPRequestDoer, identifier string, strictMode bool) (*T, error) {
var zero T
candidates, err := wellKnownCandidates(identifier, strictMode, zero.WellKnownPath())
if err != nil {
return nil, err
}
var errs []error
allClientError := true
for _, candidate := range candidates {
metadata, fetchErr := fetchMetadataCandidate[T](ctx, httpClient, candidate, identifier)
if fetchErr == nil {
return metadata, nil
}
var httpErr core.HttpError
if !errors.As(fetchErr, &httpErr) || httpErr.StatusCode < 400 || httpErr.StatusCode >= 500 {
allClientError = false
}
errs = append(errs, fmt.Errorf("%s: %w", candidate, fetchErr))
}
joined := fmt.Errorf("failed to retrieve metadata for %q: %w", identifier, errors.Join(errs...))
if allClientError {
return nil, errors.Join(ErrAllCandidates4xx, joined)
}
return nil, joined
}

// fetchMetadataCandidate retrieves, JSON-decodes and validates the metadata document from a
// single candidate URL.
func fetchMetadataCandidate[T interface{ GetIssuer() string }](ctx context.Context, httpClient core.HTTPRequestDoer, candidateURL string, identifier string) (*T, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, candidateURL, http.NoBody)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if httpErr := core.TestResponseCode(http.StatusOK, resp); httpErr != nil {
return nil, httpErr
}
var metadata T
if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
return nil, fmt.Errorf("decoding metadata: %w", err)
}
// The issuer in the document MUST be identical to the requested identifier (RFC 8414 §3.3,
// OpenID4VCI §12.2.4: byte-comparison, no normalization), so a host cannot steer discovery
// to metadata it serves under a different issuer. A mismatch falls through to the next
// candidate.
if metadata.GetIssuer() != identifier {
return nil, fmt.Errorf("issuer %q does not match requested identifier %q", metadata.GetIssuer(), identifier)
}
return &metadata, nil
}

// wellKnownCandidates returns the metadata URLs to try for identifier, in priority order:
// the insert (RFC 8414) placement, then the append (OIDC Discovery) placement. When
// identifier has no path, both collapse to a single URL.
func wellKnownCandidates(identifier string, strictMode bool, wellKnown string) ([]string, error) {
identifierURL, err := core.ParsePublicURL(identifier, strictMode)
if err != nil {
return nil, err
}
// insert places the well-known segment at the authority root with the identifier path
// appended. RawPath is set alongside Path when present so url.String() does not re-escape
// pre-encoded characters like %2F.
insert := *identifierURL
if strings.Trim(identifierURL.Path, "/") == "" {
// No path: insert and append are identical; a single candidate suffices.
insert.Path = wellKnown
insert.RawPath = ""
return []string{insert.String()}, nil
}
// RFC 8414 §3.1: any terminating "/" MUST be removed before inserting the well-known segment.
insert.Path = wellKnown + strings.TrimSuffix(identifierURL.Path, "/")
if identifierURL.RawPath != "" {
insert.RawPath = wellKnown + strings.TrimSuffix(identifierURL.RawPath, "/")
}
// append places the well-known segment after the identifier path (OIDC Discovery).
appended := *identifierURL
appended.Path = strings.TrimSuffix(identifierURL.Path, "/") + wellKnown
if identifierURL.RawPath != "" {
appended.RawPath = strings.TrimSuffix(identifierURL.RawPath, "/") + wellKnown
}
return []string{insert.String(), appended.String()}, nil
}
Loading
Loading