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
38 changes: 19 additions & 19 deletions GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 2 additions & 14 deletions cmd/conformance-as/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions examples/decoupled-checkout/checkout/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions examples/identity-check/identity/bank.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions examples/linked-accounts/linked/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions examples/payment-consent/payment/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 1 addition & 6 deletions examples/payroll-run/payroll/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions resource/verify_request_http.go
Original file line number Diff line number Diff line change
@@ -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),
}
}
53 changes: 53 additions & 0 deletions resource/verify_request_http_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading