From 99a703ccb117386e4999f65a81e9165d9413a1ac Mon Sep 17 00:00:00 2001 From: Oscar Sanderson Date: Fri, 2 Oct 2026 15:03:03 +0800 Subject: [PATCH 1/2] docs: lead ARCHITECTURE's request boundary with the FromHTTP constructors The raw request boundary section described FormRequestFromHTTP and the DPoP and peer-certificate helpers, but not the per-request constructors that read them all at once. Those are what a net/http adapter should use, because a struct filled field by field compiles with a field left out. Rule 8 showed VerifyRequest without PeerCertificate and didn't mention VerifyRequestFromHTTP. Both now do, along with the 400 invalid_request a malformed request gets. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0c502b5c..adc1acb8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -320,6 +320,22 @@ both did, byte-for-byte, before this was centralized). A caller fronted by something other than `*http.Request` builds a `FormRequest` by hand instead. +A `net/http` adapter rarely calls those helpers one by one. Each request +type has a constructor that reads everything it needs from the +`*http.Request` at once — the form, every DPoP header, the client +attestation headers and the TLS client certificate: +`server.PushAuthorizationRequestFromHTTP`, `TokenEndpointRequestFromHTTP`, +`BeginBackchannelAuthenticationRequestFromHTTP`, +`BeginAuthorizationRequestFromHTTP` and `resource.VerifyRequestFromHTTP`. +Filling a request struct field by field compiles just as well with one +left out, and then fails only for the clients that need it — typically +those using mTLS or attestation — so the constructors are the +recommended path; the fields stay public for adapters fronted by +something else. A request they can't read (a malformed or oversized +body, a repeated `client_id`) is a 400 `invalid_request` `*Error`, +never a 500; more than one `Authorization` header is marked by +`VerifyRequestFromHTTP` for `Verify` to refuse the same way. + The same reasoning applies to the DPoP header specifically: `DPoPProofs []string` on every request type that accepts a DPoP proof (`server`'s six token/PAR/CIBA-begin request types, and @@ -499,8 +515,12 @@ specific DCR plan) has no DCR module at all. If it is added: ### 8. Resource server verifies in HTTP context, not in isolation -`Verifier.Verify(ctx, VerifyRequest{Method, URL, Authorization, DPoPProofs})` -→ `AuthorizationContext{Subject, ClientID, Scopes, Claims}`. Token +`Verifier.Verify(ctx, VerifyRequest{Method, URL, Authorization, +DPoPProofs, PeerCertificate})` → `AuthorizationContext{Subject, +ClientID, Scopes, Claims}`, with the request usually built by +`resource.VerifyRequestFromHTTP(r, target)` — `target` being the +endpoint's own fixed external URL, never one built from the `Host` +header. Token verification is inseparable from method/URL/DPoP context, so there is no bare `VerifyJWT` entry point — and, symmetrically, no bare `VerifyDPoP` entry point either, since a DPoP proof can't be judged valid without the From bf3d27a4853d9e35500d1a1997c1d246c2cd6ebd Mon Sep 17 00:00:00 2001 From: Oscar Sanderson Date: Fri, 2 Oct 2026 15:05:02 +0800 Subject: [PATCH 2/2] docs: lead GETTING_STARTED's consent section with interactioncookie The section explained binding the handle and keeping the interaction by hand, and only then mentioned that server/interactioncookie does both. It now leads with the package and a short sketch of New/Set/Read/Clear. The by-hand rules follow, for a server-side session, including tying the form to the handle it was rendered for. Co-Authored-By: Claude Opus 5.5 --- GETTING_STARTED.md | 54 ++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index b54130d9..1c6bca05 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -253,6 +253,38 @@ case server.AuthorizationLocalError: } ``` +**Carry the interaction to the form's submission.** The consent form's +submission needs two things from `/authorize`: the handle, bound to this +browser, and `a.Interaction`. `server/interactioncookie` carries both in +one encrypted cookie: + +```go +cookie, err := interactioncookie.New(keys, interactioncookie.Options{}) // once; keys shared by every instance + +// GET /authorize, on server.InteractionRequired: +tag, err := cookie.Set(w, a, now) // render tag in the form, as interactioncookie.FormField + +// POST, the form's submission (behind your CSRF protection): +handle, interaction, err := cookie.Read(r, now, r.PostFormValue(interactioncookie.FormField)) +// ...CompleteAuthorization with handle, then cookie.Clear(w) +``` + +The cookie expires with the handle (`a.ExpiresAt`), so pass `now` from +the clock the server uses. The tag matters because a browser holds one +cookie of a name: a second authorization in the same browser — another +tab, or a page that sends the browser to `/authorize` for a client of +its own — replaces the first's cookie, and without the tag the first +page's form would approve the second authorization. With it, that form +gets `ErrNoInteraction` and the user starts again. It's AES-GCM under +keys every instance shares (the first seals, all open, so keys rotate), +with the `__Host-` prefix. An interaction too large for a cookie — +large authorization details, say — gets `ErrTooLarge`: keep that one in +a server-side session instead. It's no CSRF defence: the form still +needs one. The demos under `examples/` use it. + +Keeping the interaction somewhere else — a server-side session — means +doing yourself the two things the package does: + **Bind the interaction handle to the browser.** `CompleteAuthorization` accepts a handle from whoever presents it, so a login UI that carries it in a form field and signs users in from an existing session cookie is @@ -262,7 +294,10 @@ signed-in victim's browser approves it, sending that client a code for the victim's account. Set the handle (`a.Handle.String()`) in an HttpOnly, Secure, SameSite cookie when the browser reaches `/authorize`, read it back with `server.ParseInteractionHandle` when the form is -submitted, and protect that form with your usual CSRF defence. See +submitted, and protect that form with your usual CSRF defence. Tie the +form to the handle it was rendered for, too, as the package's tag does: +a second authorization in the same browser replaces the cookie, and the +first page's form mustn't complete the second. See `server.InteractionHandle`'s doc comment. **Keep the interaction until the form comes back.** The submission @@ -276,23 +311,6 @@ authorization. A modified copy can't widen the grant, since `CompleteAuthorization` checks it against the request the server stored. -`server/interactioncookie` does both in one encrypted cookie: -`Set(w, a, now)` at `/authorize` returns a tag for the form (a hidden -`interactioncookie.FormField`), `Read(r, now, tag)` takes it back when -the form comes back, and `Clear(w)` ends it. The cookie expires with the -handle (`a.ExpiresAt`), so pass `now` from the clock the server uses. -The tag matters because a browser holds one cookie of a name: a second -authorization in the same browser — another tab, or a page that sends -the browser to `/authorize` for a client of its own — replaces the -first's cookie, and without the tag the first page's form would approve -the second authorization. With it, that form gets `ErrNoInteraction` -and the user starts again. It's AES-GCM -under keys every instance shares (the first seals, all open, so keys -rotate), with the `__Host-` prefix. An interaction too large for a -cookie — large authorization details, say — gets `ErrTooLarge`: keep -that one in a server-side session instead. It's no CSRF defence: the -form still needs one. The demos under `examples/` use it. - `cmd/conformance-as/authorize.go` is a complete, working version of exactly this — read it for the full picture of the GET (render the form) and POST (handle the submission) halves of a real HTTP flow. Its