diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 3112ede9b..8c4ef901b 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -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/ + // 2. append (OIDC Disc): https://host//.well-known/oauth-authorization-server + // 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. diff --git a/auth/client/iam/client_test.go b/auth/client/iam/client_test.go index 560715c82..f9c89ddda 100644 --- a/auth/client/iam/client_test.go +++ b/auth/client/iam/client_test.go @@ -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) }) } diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 081617e08..4e53d033c 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -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") }) } @@ -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) @@ -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" diff --git a/auth/oauth/metadata.go b/auth/oauth/metadata.go new file mode 100644 index 000000000..c936ae422 --- /dev/null +++ b/auth/oauth/metadata.go @@ -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 . + * + */ + +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// +// 2. append (OIDC Disc): https://host//.well-known/ +// +// 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 +} diff --git a/auth/oauth/metadata_test.go b/auth/oauth/metadata_test.go new file mode 100644 index 000000000..133bf8971 --- /dev/null +++ b/auth/oauth/metadata_test.go @@ -0,0 +1,234 @@ +/* + * 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 . + * + */ + +package oauth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nuts-foundation/nuts-node/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWellKnownCandidates(t *testing.T) { + t.Run("identifier with path returns insert then append", func(t *testing.T) { + candidates, err := wellKnownCandidates("https://nuts.nl/iam/id", true, AuthzServerWellKnown) + require.NoError(t, err) + assert.Equal(t, []string{ + "https://nuts.nl/.well-known/oauth-authorization-server/iam/id", + "https://nuts.nl/iam/id/.well-known/oauth-authorization-server", + }, candidates) + }) + t.Run("no path collapses to a single candidate", func(t *testing.T) { + candidates, err := wellKnownCandidates("https://nuts.nl", true, AuthzServerWellKnown) + require.NoError(t, err) + assert.Equal(t, []string{"https://nuts.nl/.well-known/oauth-authorization-server"}, candidates) + }) + t.Run("trailing-slash-only path collapses to a single candidate", func(t *testing.T) { + candidates, err := wellKnownCandidates("https://nuts.nl/", true, AuthzServerWellKnown) + require.NoError(t, err) + assert.Equal(t, []string{"https://nuts.nl/.well-known/oauth-authorization-server"}, candidates) + }) + t.Run("terminating slash on the path is removed in both candidates", func(t *testing.T) { + // RFC 8414 §3.1 / OIDC Discovery §4.1: any terminating "/" MUST be removed + // before inserting/appending the well-known segment. + candidates, err := wellKnownCandidates("https://nuts.nl/iam/id/", true, AuthzServerWellKnown) + require.NoError(t, err) + assert.Equal(t, []string{ + "https://nuts.nl/.well-known/oauth-authorization-server/iam/id", + "https://nuts.nl/iam/id/.well-known/oauth-authorization-server", + }, candidates) + }) + t.Run("percent-encoded path is not double-escaped", func(t *testing.T) { + candidates, err := wellKnownCandidates("https://nuts.nl/foo%2Fbar", true, OpenIdCredIssuerWellKnown) + require.NoError(t, err) + assert.Equal(t, []string{ + "https://nuts.nl/.well-known/openid-credential-issuer/foo%2Fbar", + "https://nuts.nl/foo%2Fbar/.well-known/openid-credential-issuer", + }, candidates) + }) + t.Run("invalid identifier returns the SSRF/parse error", func(t *testing.T) { + candidates, err := wellKnownCandidates("http://nuts.nl/iam/id", true, AuthzServerWellKnown) + assert.ErrorContains(t, err, "scheme must be https") + assert.Nil(t, candidates) + }) +} + +func TestFetchMetadata(t *testing.T) { + ctx := context.Background() + + // 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 srv.URL+issuerPath. It records every requested path. + metadataServer := func(t *testing.T, missStatus int, issuerPath string, servedPaths ...string) (*httptest.Server, *[]string) { + var requested []string + served := make(map[string]struct{}, len(servedPaths)) + var issuer string + srv := httptest.NewServer(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(AuthorizationServerMetadata{Issuer: issuer, TokenEndpoint: "/token"}) + return + } + w.WriteHeader(missStatus) + })) + t.Cleanup(srv.Close) + issuer = srv.URL + issuerPath + for _, p := range servedPaths { + served[p] = struct{}{} + } + return srv, &requested + } + + t.Run("ok - insert form, root identifier, single request", func(t *testing.T) { + srv, requested := metadataServer(t, http.StatusNotFound, "", "/.well-known/oauth-authorization-server") + + metadata, err := FetchMetadata[AuthorizationServerMetadata](ctx, srv.Client(), srv.URL, false) + + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, "/token", metadata.TokenEndpoint) + assert.Equal(t, []string{"/.well-known/oauth-authorization-server"}, *requested) + }) + t.Run("ok - append form when insert 404s", func(t *testing.T) { + srv, requested := metadataServer(t, http.StatusNotFound, "/iam/123", "/iam/123/.well-known/oauth-authorization-server") + + metadata, err := FetchMetadata[AuthorizationServerMetadata](ctx, srv.Client(), srv.URL+"/iam/123", false) + + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, []string{ + "/.well-known/oauth-authorization-server/iam/123", + "/iam/123/.well-known/oauth-authorization-server", + }, *requested) + }) + t.Run("identifier mismatch on first candidate falls through to next", func(t *testing.T) { + var issuer string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/.well-known/oauth-authorization-server/iam/123": + // 200 but with a non-matching issuer: must be rejected and fallen through. + _ = json.NewEncoder(w).Encode(AuthorizationServerMetadata{Issuer: "https://attacker.example", TokenEndpoint: "/evil"}) + case "/iam/123/.well-known/oauth-authorization-server": + _ = json.NewEncoder(w).Encode(AuthorizationServerMetadata{Issuer: issuer, TokenEndpoint: "/token"}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + issuer = srv.URL + "/iam/123" + + metadata, err := FetchMetadata[AuthorizationServerMetadata](ctx, srv.Client(), issuer, false) + + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, "/token", metadata.TokenEndpoint) + }) + t.Run("error - issuer with a trailing slash does not match the requested identifier", func(t *testing.T) { + // RFC 8414 §3.3 / OpenID4VCI §12.2.4 require a byte-exact comparison, no normalization. + var issuer string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth/.well-known/oauth-authorization-server" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(AuthorizationServerMetadata{Issuer: issuer + "/", TokenEndpoint: "/token"}) + })) + t.Cleanup(srv.Close) + issuer = srv.URL + "/oauth" + + _, err := FetchMetadata[AuthorizationServerMetadata](ctx, srv.Client(), issuer, false) + + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match requested identifier") + }) + t.Run("error - all candidates 404 names the identifier and the tried locations", func(t *testing.T) { + srv, requested := metadataServer(t, http.StatusNotFound, "/iam/123") + + _, err := FetchMetadata[AuthorizationServerMetadata](ctx, srv.Client(), srv.URL+"/iam/123", false) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to retrieve metadata") + assert.Contains(t, err.Error(), srv.URL+"/iam/123") + // Every tried location is listed in the error. + assert.Contains(t, err.Error(), srv.URL+"/.well-known/oauth-authorization-server/iam/123") + assert.Contains(t, err.Error(), srv.URL+"/iam/123/.well-known/oauth-authorization-server") + assert.Len(t, *requested, 2) + // Every candidate was a clean 4xx: callers may classify this as a client error. + assert.ErrorIs(t, err, ErrAllCandidates4xx) + }) + t.Run("error - a candidate's core.HttpError stays recoverable through the join", func(t *testing.T) { + srv, requested := metadataServer(t, http.StatusInternalServerError, "/iam/123") + + _, err := FetchMetadata[AuthorizationServerMetadata](ctx, srv.Client(), srv.URL+"/iam/123", false) + + require.Error(t, err) + var httpErr core.HttpError + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, http.StatusInternalServerError, httpErr.StatusCode) + assert.Len(t, *requested, 2) + // A 5xx isn't the client's fault, so it must not be classified as one. + assert.NotErrorIs(t, err, ErrAllCandidates4xx) + }) + t.Run("error - identifier mismatch does not count as a client error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/oauth-authorization-server/iam/123": + w.WriteHeader(http.StatusNotFound) + case "/iam/123/.well-known/oauth-authorization-server": + // 200 but with a non-matching issuer: not an HTTP error status at all. + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(AuthorizationServerMetadata{Issuer: "https://attacker.example"}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + + _, err := FetchMetadata[AuthorizationServerMetadata](ctx, srv.Client(), srv.URL+"/iam/123", false) + + require.Error(t, err) + assert.NotErrorIs(t, err, ErrAllCandidates4xx) + }) + t.Run("error - invalid JSON body", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("{not valid json")) + })) + t.Cleanup(srv.Close) + + _, err := FetchMetadata[AuthorizationServerMetadata](ctx, srv.Client(), srv.URL, false) + + require.Error(t, err) + assert.Contains(t, err.Error(), "decoding metadata") + }) + t.Run("error - invalid identifier is rejected before any request", func(t *testing.T) { + _, err := FetchMetadata[AuthorizationServerMetadata](ctx, http.DefaultClient, "http://nuts.nl/iam/id", true) + + require.Error(t, err) + assert.ErrorContains(t, err, "scheme must be https") + }) +} diff --git a/auth/oauth/types.go b/auth/oauth/types.go index 380909c0e..e1e3d8d58 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -21,9 +21,10 @@ package oauth import ( "encoding/json" + "net/url" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/nuts-foundation/nuts-node/core" - "net/url" ) // this file contains constants, variables and helper functions for OAuth related code @@ -339,6 +340,18 @@ type AuthorizationServerMetadata struct { RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported,omitempty"` } +// GetIssuer returns the authorization server's issuer identifier, for metadata +// discovery validation (see FetchMetadata). +func (m AuthorizationServerMetadata) GetIssuer() string { + return m.Issuer +} + +// WellKnownPath returns the well-known path under which this document is published +// (RFC 8414), used by FetchMetadata to derive the metadata URL. +func (m AuthorizationServerMetadata) WellKnownPath() string { + return AuthzServerWellKnown +} + // SupportsClientIDScheme checks if the Authorization Server supports the given client ID scheme. func (m AuthorizationServerMetadata) SupportsClientIDScheme(scheme string) bool { for _, method := range m.ClientIdSchemesSupported { diff --git a/auth/oauth/types_test.go b/auth/oauth/types_test.go index e0932a799..4aa4e8877 100644 --- a/auth/oauth/types_test.go +++ b/auth/oauth/types_test.go @@ -20,10 +20,11 @@ package oauth import ( "encoding/json" + "testing" + "github.com/nuts-foundation/nuts-node/core/to" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "testing" ) func TestIssuerIdToWellKnown(t *testing.T) { diff --git a/auth/openid4vci/client.go b/auth/openid4vci/client.go index 9bef49ba9..43c51dde5 100644 --- a/auth/openid4vci/client.go +++ b/auth/openid4vci/client.go @@ -31,10 +31,6 @@ import ( "github.com/nuts-foundation/nuts-node/core" ) -// wellKnownPath is the path segment defined in OpenID4VCI 1.0 §12.2.2 for -// retrieving the Credential Issuer Metadata document. -const wellKnownPath = "/.well-known/openid-credential-issuer" - // RequestCredentialOpts carries all parameters for a Credential Request. // // CredentialIdentifier and CredentialConfigurationID are mutually exclusive @@ -124,32 +120,13 @@ func (c *client) OpenIDCredentialIssuerMetadata(ctx context.Context, issuerURL s if parsed, _ := url.Parse(issuerURL); parsed != nil && (parsed.RawQuery != "" || parsed.Fragment != "") { return nil, fmt.Errorf("openid4vci: invalid issuer URL: query and fragment components are not allowed") } - wellKnownURL, err := credentialIssuerWellKnown(issuerURL) - if err != nil { - return nil, fmt.Errorf("openid4vci: invalid issuer URL: %w", err) - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL, http.NoBody) + // Per §12.2.4 the credential_issuer in the document MUST match the requested issuer; + // FetchMetadata enforces that and tries the insert/append well-known placements. + metadata, err := oauth.FetchMetadata[OpenIDCredentialIssuerMetadata](ctx, c.httpClient, issuerURL, c.strictMode) if err != nil { - return nil, err - } - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode > 299 { - return nil, fmt.Errorf("openid4vci: fetching issuer metadata returned status %d", resp.StatusCode) + return nil, fmt.Errorf("openid4vci: %w", err) } - var metadata OpenIDCredentialIssuerMetadata - if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { - return nil, fmt.Errorf("openid4vci: decoding issuer metadata: %w", err) - } - // Per §12.2.4: the credential_issuer value MUST match the issuer identifier - // the metadata document was retrieved for. Mismatched metadata MUST NOT be used. - if metadata.CredentialIssuer != issuerURL { - return nil, fmt.Errorf("openid4vci: credential_issuer %q does not match requested issuer %q", metadata.CredentialIssuer, issuerURL) - } - return &metadata, nil + return metadata, nil } func (c *client) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) { @@ -234,25 +211,3 @@ func (c *client) RequestCredential(ctx context.Context, opts RequestCredentialOp } return &credResp, nil } - -// credentialIssuerWellKnown returns the Credential Issuer Metadata URL for -// the given issuer identifier per RFC 8615: the well-known segment is -// inserted at the authority root, and the issuer's path is appended after. -// -// Example: https://example.com/oauth2/alice -// -// -> https://example.com/.well-known/openid-credential-issuer/oauth2/alice -func credentialIssuerWellKnown(issuerURL string) (string, error) { - u, err := url.Parse(issuerURL) - if err != nil { - return "", err - } - // Prepend the well-known segment to both Path (decoded) and RawPath - // (encoded) when the latter is set, so u.String() does not double-escape - // pre-encoded characters like %2F via EscapedPath's reescaping pass. - u.Path = wellKnownPath + u.Path - if u.RawPath != "" { - u.RawPath = wellKnownPath + u.RawPath - } - return u.String(), nil -} diff --git a/auth/openid4vci/client_test.go b/auth/openid4vci/client_test.go index 95f0e21fe..b14350197 100644 --- a/auth/openid4vci/client_test.go +++ b/auth/openid4vci/client_test.go @@ -144,20 +144,25 @@ func TestClient_OpenIDCredentialIssuerMetadata(t *testing.T) { client := NewClient(srv.Client(), false) _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL) require.Error(t, err) - assert.Contains(t, err.Error(), "credential_issuer") + assert.Contains(t, err.Error(), "https://attacker.example") assert.Contains(t, err.Error(), "does not match") }) - t.Run("error on non-2xx", func(t *testing.T) { + // 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 beyond the + // "openid4vci: " error prefix, so it's enough to confirm the wiring (well-known constant, + // httpClient, strictMode) and that prefix. + t.Run("all candidates 404 names the identifier and the tried locations", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "not found", http.StatusNotFound) })) defer srv.Close() client := NewClient(srv.Client(), false) - _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL) + _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL+"/oauth2/alice") require.Error(t, err) - assert.Contains(t, err.Error(), "404") + assert.Contains(t, err.Error(), "failed to retrieve metadata") + assert.Contains(t, err.Error(), srv.URL+"/oauth2/alice") }) t.Run("rejects non-https issuer URL in strict mode", func(t *testing.T) { @@ -188,7 +193,7 @@ func TestClient_OpenIDCredentialIssuerMetadata(t *testing.T) { client := NewClient(srv.Client(), false) _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL) require.Error(t, err) - assert.Contains(t, err.Error(), "decoding issuer metadata") + assert.Contains(t, err.Error(), "decoding metadata") }) } diff --git a/auth/openid4vci/types.go b/auth/openid4vci/types.go index c76366424..095d302a9 100644 --- a/auth/openid4vci/types.go +++ b/auth/openid4vci/types.go @@ -32,6 +32,8 @@ package openid4vci import ( "encoding/json" + + "github.com/nuts-foundation/nuts-node/auth/oauth" ) // JWTTypeOpenID4VCIProof is the JWT typ claim value used in OpenID4VCI key @@ -50,6 +52,18 @@ type OpenIDCredentialIssuerMetadata struct { Display []map[string]string `json:"display,omitempty"` } +// GetIssuer returns the credential issuer identifier, for metadata discovery +// validation (see oauth.FetchMetadata). +func (m OpenIDCredentialIssuerMetadata) GetIssuer() string { + return m.CredentialIssuer +} + +// WellKnownPath returns the well-known path under which the Credential Issuer Metadata is +// published (§12.2), used by oauth.FetchMetadata to derive the metadata URL. +func (m OpenIDCredentialIssuerMetadata) WellKnownPath() string { + return oauth.OpenIdCredIssuerWellKnown +} + // NonceResponse is the body returned by the Nonce Endpoint (Section 7.2). type NonceResponse struct { CNonce string `json:"c_nonce"`