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
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ browser URL, and persists correlation state.

`HandleAuthorizationResponse` internally validates: that the callback
belongs to the user agent that began the flow — the caller passes back
the `SessionHandle` it bound to that browser (an HttpOnly cookie), and a
the `SessionHandle` it bound to that browser (an HttpOnly cookie;
`client/sessioncookie` sets and reads one), and a
callback whose `state` doesn't match it is rejected before anything is
consumed, closing login CSRF ([RFC 9700 §4.7][bcp]) — then correlation
state, issuer, JARM signature and claims, audience, expiry, response
Expand Down
6 changes: 3 additions & 3 deletions GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,13 +259,13 @@ 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
cookie, err := interactioncookie.New(cookieKeys, interactioncookie.Options{}) // once; cookieKeys 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))
// POST, the form's submission (behind your CSRF protection), after r.ParseForm():
handle, interaction, err := cookie.Read(r, now, r.PostForm.Get(interactioncookie.FormField))
// ...CompleteAuthorization with handle, then cookie.Clear(w)
```

Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,22 @@ role-level types or behaviour. `serverresource` builds a `resource`
verifier matching a `server` in the same process, for an authorization
server that hosts its own protected endpoints.

A few helpers cover what every deployment otherwise writes by hand, and
gets wrong in the same ways:

- `server/interactioncookie` carries a pending authorization from
`/authorize` to the consent form's submission in one encrypted cookie,
tied to the form that was shown.
- `client/sessioncookie` binds a client's authorization to the browser
that began it (login CSRF, [RFC 9700 §4.7](https://www.rfc-editor.org/rfc/rfc9700#section-4.7)), with your own value
for it alongside.
- `client.TokenSetSealer` keeps tokens at rest, encrypted and bound to
their owner, for a client that refreshes after a restart or on
another instance.
- The `*FromHTTP` constructors (`server.PushAuthorizationRequestFromHTTP`,
`resource.VerifyRequestFromHTTP` and others) read every header and
certificate a request type needs from an `*http.Request` at once.

See [GETTING_STARTED.md](GETTING_STARTED.md) for a full walkthrough of
standing up an authorization server and resource server end to end,
including a runnable configuration you can start from.
Expand Down
22 changes: 14 additions & 8 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@ and `keys/ephemeral` needs only the steps not marked *production only*.
### Clients list the RAR types they may request (storage, server)

**Affects:** a server using Rich Authorization Requests (`Config.RAR`),
for every client that sends `authorization_details`; and
`federation.AutomaticRegistrationConfig` for automatically registered
clients that do.
for every client that sends `authorization_details`, including clients
registered automatically through OpenID Federation.

**Why:** RFC 9396 §10 defines `authorization_details_types`, the types a
client may use. The server now enforces it per client, before any
Expand All @@ -33,9 +32,13 @@ types. Before, any registered type reached the policy, which had to
check the client itself.

**What to change:** set `storage.RegisteredClientConfig.AuthorizationDetailsTypes`
to the types each client may request (and
`federation.AutomaticRegistrationConfig.AuthorizationDetailsTypes` for
automatically registered clients). A `RARPolicy` that only checked
to the types each client may request, and
`server.Config.AutomaticRegistration.AuthorizationDetailsTypes` to the
types every automatically registered client may (`New` refuses one
`Config.RAR` doesn't register). If you build a
`federation.NewAutomaticClientRepository` yourself rather than through
`server.Config`, set `federation.AutomaticRegistrationConfig.AuthorizationDetailsTypes`
there. A `RARPolicy` that only checked
which types a client may use can then become
`server.AllowRequestedAuthorizationDetails{}`; keep your own where it
checks the details themselves. Note one difference from a policy that
Expand All @@ -51,8 +54,11 @@ your `ClientRepository`. A client that's missing them is refused at
runtime, at PAR, CIBA or the token endpoint, with
`invalid_authorization_details`; the error's cause, for your logs, reads
`authorization_details type "…" is not registered for this client`.
Grants made before the upgrade aren't checked again: a refresh keeps the
authorization details the grant already holds.
`Server.CheckClientRegistration(client)` catches a registration listing a
type `Config.RAR` doesn't register (a typo, usually): call it when you
register a client, or over every client at startup. Grants made before
the upgrade aren't checked again: a refresh keeps the authorization
details the grant already holds.

### Custom `SessionStore`s persist an opaque `Record` (client)

Expand Down
3 changes: 2 additions & 1 deletion client/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@
// ARCHITECTURE.md, "Design rules"): AuthorizationSession is opaque with
// no public constructor, and a SessionHandle can only be recovered from
// its own String form (ParseSessionHandle) — the caller stores it with
// the user agent that began the flow, and HandleAuthorizationResponse
// the user agent that began the flow (package client/sessioncookie does
// this), and HandleAuthorizationResponse
// rejects a callback that doesn't carry the matching one;
// HandleAuthorizationResponse returns a closed sum type
// rather than one struct with optional fields, so a caller can't assume
Expand Down
16 changes: 12 additions & 4 deletions client/sessioncookie/sessioncookie.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@
// with it have expired. It is HttpOnly, Secure and SameSite=Lax — the
// authorization response arrives as a top-level GET from the
// authorization server, which Lax lets the cookie ride along on — with
// the __Host- prefix by default.
// the __Host- prefix by default. Give it keys of its own, apart from any
// interactioncookie's or client.TokenSetSealer's.
//
// A browser keeps one cookie of a name, so a second authorization begun
// in another tab replaces the first's cookie. The first tab's callback
// then fails, as one that no longer matches the browser's session, and
// the user starts again: that is the binding working, not a fault.
package sessioncookie

import (
Expand Down Expand Up @@ -46,11 +52,13 @@ var (

// Options configures a Cookie.
type Options struct {
// Name is the cookie's name: DefaultName if empty.
// Name is the cookie's name: DefaultName if empty. Keep the __Host-
// prefix: without it, a sibling subdomain can set the cookie in the
// victim's browser, with a value it got sealed for itself.
Name string

// Path is the cookie's path: "/" if empty. It must be "/" for a
// __Host- name.
// Path is the cookie's path: "/" if empty. New refuses another path
// for a __Host- name, which the prefix forbids.
Path string
}

Expand Down
6 changes: 6 additions & 0 deletions examples/decoupled-checkout/checkout/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ func (a *api) pay(w http.ResponseWriter, r *http.Request) {
resource.WriteError(w, err)
return
}
// The client's next call carries a fresh DPoP nonce, when the
// verifier issues them.
authz.SetDPoPNonce(w.Header())
var order paymentOrder
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&order); err != nil {
resource.NewError(resource.ErrorInvalidRequest, http.StatusBadRequest, "malformed payment order").WriteJSON(w)
Expand Down Expand Up @@ -157,6 +160,9 @@ func (a *api) readAccount(w http.ResponseWriter, r *http.Request) {
resource.WriteError(w, err)
return
}
// The client's next call carries a fresh DPoP nonce, when the
// verifier issues them.
authz.SetDPoPNonce(w.Header())
iban, action := r.PathValue("iban"), accountEndpoints[r.PathValue("what")]
if action == "" {
http.NotFound(w, r)
Expand Down
12 changes: 6 additions & 6 deletions examples/federated-union/union/idp.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,17 +346,16 @@ func (p *identityProvider) authorize(w http.ResponseWriter, r *http.Request) {
// decide completes the interaction with the citizen chosen and the
// claims they agreed to share.
func (p *identityProvider) decide(w http.ResponseWriter, r *http.Request) {
tag := r.PostFormValue(interactioncookie.FormField)
if err := r.ParseForm(); err != nil {
p.w.renderError(w, http.StatusBadRequest, "Malformed form", formUnreadable)
return
}
tag := r.PostForm.Get(interactioncookie.FormField)
handle, interaction, err := p.interaction.Read(r, time.Now(), tag)
if err != nil {
p.w.renderError(w, http.StatusBadRequest, "Session expired", "This browser has no sign-in in progress.")
return
}
p.interaction.Clear(w)
if err := r.ParseForm(); err != nil {
p.w.renderError(w, http.StatusBadRequest, "Malformed form", formUnreadable)
return
}

var result server.InteractionResult
if r.PostForm.Get("decision") != "approve" {
Expand Down Expand Up @@ -393,6 +392,7 @@ func (p *identityProvider) decide(w http.ResponseWriter, r *http.Request) {
})
}

p.interaction.Clear(w)
outcome, err := p.srv.CompleteAuthorization(r.Context(), server.CompleteAuthorizationRequest{Handle: handle, Result: result})
if err != nil {
p.w.renderError(w, http.StatusInternalServerError, signInFailed, publicMessage(err, "Something went wrong. Please try again."))
Expand Down
10 changes: 5 additions & 5 deletions examples/identity-check/identity/bank.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,16 +384,16 @@ func (b *bank) consentPage(in server.InteractionRequest, tag, problem string) co
// decide signs the customer in, the way they chose, and records which
// claims they approved for release.
func (b *bank) decide(w http.ResponseWriter, r *http.Request) {
tag := r.PostFormValue(interactioncookie.FormField)
if err := r.ParseForm(); err != nil {
b.w.renderError(w, bankHost, http.StatusBadRequest, "Malformed form", formUnreadable)
return
}
tag := r.PostForm.Get(interactioncookie.FormField)
handle, in, err := b.interaction.Read(r, time.Now(), tag)
if err != nil {
b.w.renderError(w, bankHost, http.StatusBadRequest, "Session expired", "This browser has no sign-in in progress.")
return
}
if err := r.ParseForm(); err != nil {
b.w.renderError(w, bankHost, http.StatusBadRequest, "Malformed form", formUnreadable)
return
}
result := server.Deny("the customer declined")
if r.PostForm.Get("decision") == "approve" {
c, ok := customerByName(r.PostForm.Get("username"))
Expand Down
3 changes: 3 additions & 0 deletions examples/linked-accounts/linked/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ func (a *api) accounts(w http.ResponseWriter, r *http.Request) {
resource.WriteError(w, err)
return
}
// The client's next call carries a fresh DPoP nonce, when the
// verifier issues them.
authz.SetDPoPNonce(w.Header())
details, err := grantedAccess(authz)
if err != nil {
resource.NewError(resource.ErrorInvalidToken, http.StatusUnauthorized, "the token's authorization details are malformed").WriteJSON(w)
Expand Down
10 changes: 5 additions & 5 deletions examples/linked-accounts/linked/bank.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,16 +296,16 @@ func (b *bank) consentPage(in server.InteractionRequest, tag, problem string) co
// decide signs the customer in and records which accounts they chose
// to share, under a grant ID the bank keeps on its Connected apps page.
func (b *bank) decide(w http.ResponseWriter, r *http.Request) {
tag := r.PostFormValue(interactioncookie.FormField)
if err := r.ParseForm(); err != nil {
b.w.renderError(w, bankHost, http.StatusBadRequest, "Malformed form", formUnreadable)
return
}
tag := r.PostForm.Get(interactioncookie.FormField)
handle, in, err := b.interaction.Read(r, b.w.clock.Now(), tag)
if err != nil {
b.w.renderError(w, bankHost, http.StatusBadRequest, "Session expired", "This browser has no sign-in in progress.")
return
}
if err := r.ParseForm(); err != nil {
b.w.renderError(w, bankHost, http.StatusBadRequest, "Malformed form", formUnreadable)
return
}
result := server.Deny("the customer declined")
if r.PostForm.Get("decision") == "approve" {
c, ok := customerByName(r.PostForm.Get("username"))
Expand Down
3 changes: 3 additions & 0 deletions examples/payment-consent/payment/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ func (a *api) pay(w http.ResponseWriter, r *http.Request) {
resource.WriteError(w, err)
return
}
// The client's next call carries a fresh DPoP nonce, when the
// verifier issues them.
authz.SetDPoPNonce(w.Header())
var order paymentOrder
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&order); err != nil {
resource.NewError(resource.ErrorInvalidRequest, http.StatusBadRequest, "malformed payment order").WriteJSON(w)
Expand Down
10 changes: 5 additions & 5 deletions examples/payment-consent/payment/bank.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,16 +258,16 @@ func (b *bank) consentPage(in server.InteractionRequest, tag, problem string) co
// decide signs the customer in and records their decision. The payment
// is granted exactly as asked: the server refuses anything else.
func (b *bank) decide(w http.ResponseWriter, r *http.Request) {
tag := r.PostFormValue(interactioncookie.FormField)
if err := r.ParseForm(); err != nil {
b.w.renderError(w, bankHost, http.StatusBadRequest, "Malformed form", formUnreadable)
return
}
tag := r.PostForm.Get(interactioncookie.FormField)
handle, in, err := b.interaction.Read(r, time.Now(), tag)
if err != nil {
b.w.renderError(w, bankHost, http.StatusBadRequest, "Session expired", "This browser has no payment approval in progress.")
return
}
if err := r.ParseForm(); err != nil {
b.w.renderError(w, bankHost, http.StatusBadRequest, "Malformed form", formUnreadable)
return
}
result := server.Deny("the customer declined")
if r.PostForm.Get("decision") == "approve" {
c, ok := customerByName(r.PostForm.Get("username"))
Expand Down
3 changes: 3 additions & 0 deletions examples/payroll-run/payroll/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ func (a *api) submit(w http.ResponseWriter, r *http.Request) {
resource.WriteError(w, err)
return
}
// The client's next call carries a fresh DPoP nonce, when the
// verifier issues them.
authz.SetDPoPNonce(w.Header())
var b batch
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&b); err != nil {
resource.NewError(resource.ErrorInvalidRequest, http.StatusBadRequest, "malformed payroll batch").WriteJSON(w)
Expand Down
4 changes: 3 additions & 1 deletion internal/sealedcookie/sealedcookie.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ type envelope struct {
// opened until expiresAt; it refuses a value already expired, and one
// too large for a cookie (ErrTooLarge), setting nothing.
func (j *Jar) Set(w http.ResponseWriter, value any, expiresAt, now time.Time) error {
if !now.Before(expiresAt) {
// Max-Age counts whole seconds, and 0 would make a session cookie:
// less than a second left is as good as expired.
if expiresAt.Sub(now) < time.Second {
return fmt.Errorf("%s: already expired", j.pkg)
}
encoded, err := json.Marshal(value)
Expand Down
12 changes: 8 additions & 4 deletions server/interactioncookie/interactioncookie.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
// and Read refuses a form whose tag isn't the cookie's: that page's
// interaction is gone, and the user starts again.
//
// Give it keys of its own, apart from any client/sessioncookie's or
// client.TokenSetSealer's.
//
// The cookie is not a CSRF defence. The consent form's submission still
// needs one, such as net/http's CrossOriginProtection.
package interactioncookie
Expand Down Expand Up @@ -66,12 +69,13 @@ var (

// Options configures a Cookie.
type Options struct {
// Name is the cookie's name: DefaultName if empty. A name with the
// __Host- prefix gets Path=/, as the prefix requires.
// Name is the cookie's name: DefaultName if empty. Keep the __Host-
// prefix: without it, a sibling subdomain can set the cookie in the
// victim's browser, with a value it got sealed for itself.
Name string

// Path is the cookie's path: "/" if empty. It must be "/" for a
// __Host- name.
// Path is the cookie's path: "/" if empty. New refuses another path
// for a __Host- name, which the prefix forbids.
Path string
}

Expand Down
2 changes: 1 addition & 1 deletion server/interactioncookie/interactioncookie_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ func TestCookieExpiresWithTheInteraction(t *testing.T) {
t.Errorf("Read(at ExpiresAt) = %v, want ErrNoInteraction", err)
}

for name, expiresAt := range map[string]time.Time{"expired": now.Add(-time.Second), "unset": {}} {
for name, expiresAt := range map[string]time.Time{"expired": now.Add(-time.Second), "unset": {}, "under a second left": now.Add(500 * time.Millisecond)} {
a.ExpiresAt = expiresAt
if _, err := c.Set(httptest.NewRecorder(), a, now); err == nil {
t.Errorf("Set(%s interaction) = nil error", name)
Expand Down
Loading