From d0dfec40124127199a5e3910a1e8e06446773795 Mon Sep 17 00:00:00 2001 From: Oscar Sanderson Date: Fri, 2 Oct 2026 12:14:38 +0800 Subject: [PATCH] feat(resource): build a VerifyRequest from an http.Request resource.VerifyRequestFromHTTP(r, target) fills in every field of the VerifyRequest for a request to the protected resource at target: the method, the Authorization header, every DPoP header, and the TLS client certificate. Filling the struct by hand compiles with a field left out, and then fails only for the clients that need it. Four of the demos left out PeerCertificate, which refuses every mTLS-bound token, and payroll-run left out DPoPProofs. The server's own FromHTTP constructors exist for the same reason. target stays the caller's: this endpoint's fixed external URL, never one built from the request's Host header. It is copied, never modified. The five demos, cmd/conformance-as and GETTING_STARTED now use it. Co-Authored-By: Claude Opus 5.5 --- GETTING_STARTED.md | 38 +++++++-------- cmd/conformance-as/resource.go | 16 +------ examples/decoupled-checkout/checkout/api.go | 5 +- examples/identity-check/identity/bank.go | 5 +- examples/linked-accounts/linked/api.go | 5 +- examples/payment-consent/payment/api.go | 5 +- examples/payroll-run/payroll/api.go | 7 +-- resource/verify_request_http.go | 38 +++++++++++++++ resource/verify_request_http_test.go | 53 +++++++++++++++++++++ 9 files changed, 117 insertions(+), 55 deletions(-) create mode 100644 resource/verify_request_http.go create mode 100644 resource/verify_request_http_test.go diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index be9b3482..83d9de2e 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -433,14 +433,9 @@ verifier, err := resource.NewVerifier(cfg, deps) ### Verify a request ```go -authCtx, err := verifier.Verify(ctx, resource.VerifyRequest{ - Method: r.Method, - URL: protectedResourceURL, // this endpoint's own fixed external URL — see below, never r.URL - Authorization: r.Header.Get("Authorization"), - DPoPProofs: r.Header.Values("DPoP"), // see below — never r.Header.Get("DPoP") - // The TLS client certificate, for an mTLS-bound access token. - PeerCertificate: resource.PeerCertificateFromHTTP(r), -}) +// protectedResourceURL: this endpoint's own fixed external URL — see +// below, never r.URL. +authCtx, err := verifier.Verify(ctx, resource.VerifyRequestFromHTTP(r, protectedResourceURL)) ``` On success, `authCtx.Subject`/`ClientID`/`Scopes`/`Claims` are what your @@ -458,25 +453,30 @@ if err != nil { } ``` -One easy-to-miss detail `cmd/conformance-as/resource.go` gets right -that's worth copying: `URL` must be this endpoint's own fixed, +`resource.VerifyRequestFromHTTP` fills in every field of the +`VerifyRequest` from `r`: the method, the `Authorization` header, every +`DPoP` header and the TLS client certificate. Building the struct by +hand compiles just as well with a field left out, and then fails only +for the clients that need it — which is why the constructor exists. + +The one thing it takes from you is the URL: this endpoint's own fixed, externally-visible URL (it's the DPoP proof's expected `htu`) — never inferred from the incoming request's `Host` header, the same reasoning -`server.Endpoints` is never inferred from a request either. +`server.Endpoints` is never inferred from a request either. For a route +with path parameters, copy a fixed origin and set its `Path` from +`r.URL.Path`. -`DPoPProofs` takes every raw "DPoP" header value the request carried — -pass `r.Header.Values("DPoP")` directly, never `r.Header.Get("DPoP")`, +`DPoPProofs` carries every raw "DPoP" header value the request +carried — `r.Header.Values("DPoP")`, never `r.Header.Get("DPoP")`, which silently returns only the first of several duplicate headers. `Verify` itself rejects a request that carried more than one (RFC 9449 -§7.1), so there's no adapter-side check to write here — unlike an -older version of this library, which left that check to the caller. +§7.1), so there's no adapter-side check to write here. `PeerCertificate` is the TLS client certificate the request arrived with: an access token bound to a certificate (RFC 8705) is refused -without it. `resource.PeerCertificateFromHTTP(r)` reads it from `r.TLS`; -behind a proxy that terminates TLS, set it from however the proxy -forwards the certificate instead. Leave it out only if no client of -this API uses mTLS-bound tokens. +without it. The constructor reads it from `r.TLS`; behind a proxy that +terminates TLS, set it afterwards from however the proxy forwards the +certificate. That's the whole surface: `resource.Verifier` has no other public entry point. Everything above `Verify` — routing, and what the protected API diff --git a/cmd/conformance-as/resource.go b/cmd/conformance-as/resource.go index 1c80ef00..0844999b 100644 --- a/cmd/conformance-as/resource.go +++ b/cmd/conformance-as/resource.go @@ -58,13 +58,7 @@ func userinfoHandler(srv *server.Server, verifier *fapires.Verifier, userinfoURL } w.Header().Set(fapires.InteractionIDHeader, interactionID) - authCtx, err := verifier.Verify(r.Context(), fapires.VerifyRequest{ - Method: r.Method, - URL: userinfoURL, - Authorization: r.Header.Get("Authorization"), - DPoPProofs: r.Header.Values("DPoP"), - PeerCertificate: fapires.PeerCertificateFromHTTP(r), - }) + authCtx, err := verifier.Verify(r.Context(), fapires.VerifyRequestFromHTTP(r, userinfoURL)) if err != nil { writeResourceError(w, err) return @@ -158,13 +152,7 @@ func accountsHandler(verifier *fapires.Verifier, accountsURL *url.URL) http.Hand } w.Header().Set(fapires.InteractionIDHeader, interactionID) - authCtx, err := verifier.Verify(r.Context(), fapires.VerifyRequest{ - Method: r.Method, - URL: accountsURL, - Authorization: r.Header.Get("Authorization"), - DPoPProofs: r.Header.Values("DPoP"), - PeerCertificate: fapires.PeerCertificateFromHTTP(r), - }) + authCtx, err := verifier.Verify(r.Context(), fapires.VerifyRequestFromHTTP(r, accountsURL)) if err != nil { writeResourceError(w, err) return diff --git a/examples/decoupled-checkout/checkout/api.go b/examples/decoupled-checkout/checkout/api.go index 1b6cc82a..6ddb090f 100644 --- a/examples/decoupled-checkout/checkout/api.go +++ b/examples/decoupled-checkout/checkout/api.go @@ -69,10 +69,7 @@ func (a *api) verify(r *http.Request) (resource.AuthorizationContext, error) { if err != nil { return resource.AuthorizationContext{}, err } - return a.verifier.Verify(r.Context(), resource.VerifyRequest{ - Method: r.Method, URL: target, - Authorization: r.Header.Get("Authorization"), DPoPProofs: r.Header.Values("DPoP"), - }) + return a.verifier.Verify(r.Context(), resource.VerifyRequestFromHTTP(r, target)) } // forbidden is RFC 6750 §3.1's insufficient_scope: a valid token that diff --git a/examples/identity-check/identity/bank.go b/examples/identity-check/identity/bank.go index 7c7c4c2f..e151e0fe 100644 --- a/examples/identity-check/identity/bank.go +++ b/examples/identity-check/identity/bank.go @@ -276,10 +276,7 @@ func (b *bank) userinfo(w http.ResponseWriter, r *http.Request) { internalError(w, err) return } - authz, err := b.verifier.Verify(r.Context(), resource.VerifyRequest{ - Method: r.Method, URL: target, - Authorization: r.Header.Get("Authorization"), DPoPProofs: r.Header.Values("DPoP"), - }) + authz, err := b.verifier.Verify(r.Context(), resource.VerifyRequestFromHTTP(r, target)) if err != nil { resource.WriteError(w, err) return diff --git a/examples/linked-accounts/linked/api.go b/examples/linked-accounts/linked/api.go index 346729a1..9ef36edc 100644 --- a/examples/linked-accounts/linked/api.go +++ b/examples/linked-accounts/linked/api.go @@ -49,10 +49,7 @@ func (a *api) accounts(w http.ResponseWriter, r *http.Request) { internalError(w, err) return } - authz, err := a.verifier.Verify(r.Context(), resource.VerifyRequest{ - Method: r.Method, URL: target, - Authorization: r.Header.Get("Authorization"), DPoPProofs: r.Header.Values("DPoP"), - }) + authz, err := a.verifier.Verify(r.Context(), resource.VerifyRequestFromHTTP(r, target)) if err != nil { resource.WriteError(w, err) return diff --git a/examples/payment-consent/payment/api.go b/examples/payment-consent/payment/api.go index b619c3d6..1eda71d4 100644 --- a/examples/payment-consent/payment/api.go +++ b/examples/payment-consent/payment/api.go @@ -69,10 +69,7 @@ func (a *api) pay(w http.ResponseWriter, r *http.Request) { internalError(w, err) return } - authz, err := a.verifier.Verify(r.Context(), resource.VerifyRequest{ - Method: r.Method, URL: target, - Authorization: r.Header.Get("Authorization"), DPoPProofs: r.Header.Values("DPoP"), - }) + authz, err := a.verifier.Verify(r.Context(), resource.VerifyRequestFromHTTP(r, target)) if err != nil { resource.WriteError(w, err) return diff --git a/examples/payroll-run/payroll/api.go b/examples/payroll-run/payroll/api.go index cbc32cf3..d221acca 100644 --- a/examples/payroll-run/payroll/api.go +++ b/examples/payroll-run/payroll/api.go @@ -75,12 +75,7 @@ func (a *api) submit(w http.ResponseWriter, r *http.Request) { internalError(w, err) return } - authz, err := a.verifier.Verify(r.Context(), resource.VerifyRequest{ - Method: r.Method, URL: target, Authorization: r.Header.Get("Authorization"), - // RFC 8705 §3: the token's cnf.x5t#S256 must be this - // certificate's thumbprint. - PeerCertificate: resource.PeerCertificateFromHTTP(r), - }) + authz, err := a.verifier.Verify(r.Context(), resource.VerifyRequestFromHTTP(r, target)) if err != nil { resource.WriteError(w, err) return diff --git a/resource/verify_request_http.go b/resource/verify_request_http.go new file mode 100644 index 00000000..3c11346c --- /dev/null +++ b/resource/verify_request_http.go @@ -0,0 +1,38 @@ +package resource + +import ( + "net/http" + "net/url" +) + +// VerifyRequestFromHTTP builds the VerifyRequest for r, an incoming +// request to the protected resource whose external URL is target: its +// method, Authorization header, every DPoP header (DPoPProofsFromHTTP) +// and TLS client certificate (PeerCertificateFromHTTP). Filling the +// struct field by field compiles just as well with a field left out, and +// then fails only for the clients that need it — a missing +// PeerCertificate refuses every mTLS-bound token, a missing DPoPProofs +// every DPoP-bound one. +// +// target is this endpoint's own fixed, externally visible URL — what a +// DPoP proof's htu names — never one built from r's Host header, which +// the client controls. For a route with path parameters, copy a fixed +// origin and set its Path from r.URL.Path. target is copied, never +// modified. +// +// Behind a proxy that terminates TLS, r carries no client certificate: +// set PeerCertificate afterwards from however the proxy forwards it. +func VerifyRequestFromHTTP(r *http.Request, target *url.URL) VerifyRequest { + var u *url.URL + if target != nil { + copied := *target + u = &copied + } + return VerifyRequest{ + Method: r.Method, + URL: u, + Authorization: r.Header.Get("Authorization"), + DPoPProofs: DPoPProofsFromHTTP(r), + PeerCertificate: PeerCertificateFromHTTP(r), + } +} diff --git a/resource/verify_request_http_test.go b/resource/verify_request_http_test.go new file mode 100644 index 00000000..e1e00299 --- /dev/null +++ b/resource/verify_request_http_test.go @@ -0,0 +1,53 @@ +package resource_test + +import ( + "crypto/tls" + "crypto/x509" + "net/http/httptest" + "net/url" + "slices" + "testing" + + "github.com/idfoundry/fapigo/resource" +) + +func TestVerifyRequestFromHTTP(t *testing.T) { + target, err := url.Parse("https://api.example.com/accounts") + if err != nil { + t.Fatal(err) + } + cert := &x509.Certificate{Raw: []byte("client certificate")} + r := httptest.NewRequest("POST", "https://attacker.example/accounts?x=1", nil) + r.Header.Set("Authorization", "DPoP token-1") + r.Header.Add("DPoP", "proof-1") + r.Header.Add("DPoP", "proof-2") + r.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{cert}} + + got := resource.VerifyRequestFromHTTP(r, target) + + if got.Method != "POST" || got.Authorization != "DPoP token-1" { + t.Errorf("Method %q, Authorization %q", got.Method, got.Authorization) + } + // Every DPoP header, so Verify can refuse more than one. + if !slices.Equal(got.DPoPProofs, []string{"proof-1", "proof-2"}) { + t.Errorf("DPoPProofs = %v, want both headers", got.DPoPProofs) + } + if got.PeerCertificate != cert { + t.Error("PeerCertificate isn't the connection's client certificate") + } + // The endpoint's own URL, never the request's Host. + if got.URL.String() != "https://api.example.com/accounts" { + t.Errorf("URL = %s, want the target", got.URL) + } + got.URL.Path = "/changed" + if target.Path != "/accounts" { + t.Error("VerifyRequestFromHTTP's URL aliases target") + } +} + +func TestVerifyRequestFromHTTPWithoutTLSOrTarget(t *testing.T) { + got := resource.VerifyRequestFromHTTP(httptest.NewRequest("GET", "/accounts", nil), nil) + if got.PeerCertificate != nil || got.URL != nil || len(got.DPoPProofs) != 0 { + t.Errorf("got %+v, want no certificate, URL or proofs", got) + } +}