-
Notifications
You must be signed in to change notification settings - Fork 24
feat(auth): support both insert and append metadata discovery (#4310) #4346
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
77433e6
feat(auth): support both insert and append metadata discovery (#4310)
reinkrul 1d537db
fix(auth): tolerate trailing slash in metadata identifier match
reinkrul 7295a16
refactor(auth): extract generic FetchMetadata helper for well-known d…
reinkrul 1315a45
fix(auth): try every metadata candidate before failing, drop issuer t…
reinkrul e0dd192
fix(auth): don't overwrite upstream 5xx status with 502 Bad Gateway
reinkrul d8409eb
fix(auth): drop ErrInvalidClientCall wrapping, trim redundant status-…
reinkrul 25ba7cf
test(auth): trim wrapper-level tests duplicating oauth.FetchMetadata'…
reinkrul 93246e1
feat(auth): reinstate ErrInvalidClientCall, scoped to all-4xx exhaustion
reinkrul 4dbe586
fix(auth): strip terminating slash before inserting well-known segment
JorisHeadease File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.