feat(19.3-02): cookie token refresh across subdomains with hardened credentialed CORS

- bouncer: CookieConfig from auth.cookie.* (LoadCookieConfig validates domain,
  same_site none without Secure, secure false in production), TokenCookie with
  Max-Age = refresh window, ForgetTokenCookie, CookieRefreshHandler and
  CookieLogoutHandler with an app-supplied writer
- surf: http.cors refuses credentials with a * origin and bad or PHP-delimited
  patterns at boot, turns wildcard origins into anchored patterns, echoes the
  exact origin and sends Vary: Origin on every dynamic CORS answer; the
  non-credentialed * answer is unchanged
- smoke tests for the www -> api refresh and the refused wildcard config
This commit is contained in:
Jakub Zych
2026-10-15 17:49:35 +02:00
parent 866e4fd3e0
commit e7659f2f4b
7 changed files with 748 additions and 54 deletions

View File

@@ -31,6 +31,7 @@ Use cases:
- Context helpers: `bouncer.WithUser` and `bouncer.User` for the principal, `bouncer.WithCredential` and `bouncer.Credential` for the credential behind it (for example an API token record).
- Scope limits for personal API tokens: `bouncer.ScopeLimit` on `bouncer.Principal.Scope` (nil means unrestricted, as for a JWT session). A token guard sets it after filtering the token's scopes by the request method; `Full` means the token acts as the owner on every method (`*`) or on GET/HEAD (`admin:read`).
- Blacklist stores behind `bouncer.BlacklistStore`: `bouncer.MemoryBlacklist` for tests and `bouncer.PostgresBlacklist` for production, which works on a caller-supplied table with `jti`, `expires_at` and `valid_until` columns and rejects unsafe table names.
- Cookie transport: `bouncer.LoadCookieConfig` reads the `auth.cookie.*` block into a `bouncer.CookieConfig` and refuses an invalid domain, `same_site: none` without Secure and `secure: false` in the production environment; `bouncer.CookieConfig.TokenCookie` builds the httpOnly token cookie whose Max-Age is the refresh window, and `bouncer.CookieConfig.ForgetTokenCookie` the matching cookie that deletes it. `bouncer.CookieRefreshHandler` and `bouncer.CookieLogoutHandler` are ready-made handlers for a cookie-based refresh and logout; the application supplies the refresh or revoke call and a writer for its own response envelope.
- Passwords: `bouncer.HashPassword`, `bouncer.CheckPassword` and `bouncer.NeedsRehash` (bcrypt, cost chosen by the caller).
## Usage
@@ -79,6 +80,47 @@ func Login(secret string) (string, error) {
}
```
An application that keeps the token in an httpOnly cookie, for a frontend on `www.example.com` calling the API on `api.example.com`, loads the cookie config at boot and mounts the refresh handler on a POST route:
```go
package acme
import (
"context"
"encoding/json"
"net/http"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
// RefreshRoute rotates the cookie's token. cfg is the app config
// (app.Config); users, secret and bl are the guard's; refreshTTL is its
// refresh_ttl.
func RefreshRoute(cfg bouncer.CookieConfigSource, users bouncer.UserProvider, secret string, bl bouncer.BlacklistStore, refreshTTL time.Duration) (http.Handler, error) {
cookie, err := bouncer.LoadCookieConfig(cfg) // a bad auth.cookie block fails boot
if err != nil {
return nil, err
}
return bouncer.CookieRefreshHandler(bouncer.CookieRefreshOptions{
Cookie: cookie,
RefreshTTL: refreshTTL,
Refresh: func(ctx context.Context, token string) (string, error) {
return bouncer.RefreshAudienceFor(ctx, users, secret, token, bouncer.AudienceUser, refreshTTL, bl, 0, "https://api.example.com/api/auth/refresh")
},
Write: func(w http.ResponseWriter, r *http.Request, res bouncer.CookieResult) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(res.Status)
if res.Err != nil {
_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "error": "UNAUTHENTICATED"})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
},
})
}
```
## API reference
| Identifier | Description |
@@ -113,15 +155,41 @@ func Login(secret string) (string, error) {
| `bouncer.BlacklistStore` | Revoked-jti store with a grace window and sweeping. |
| `bouncer.NewMemoryBlacklist` | In-process blacklist for tests. |
| `bouncer.NewPostgresBlacklist` | Blacklist over a `*sql.DB` and a table name. |
| `bouncer.CookieConfig` | Token cookie attributes: `Name`, `Domain`, `Path`, `Secure`, `SameSite`. |
| `bouncer.CookieConfigSource` | The config methods `bouncer.LoadCookieConfig` reads (`Lookup`, `String`, `Environment`); `*compass.Config` satisfies it. |
| `bouncer.LoadCookieConfig` | Reads and validates `auth.cookie.*`; every error names the key. |
| `bouncer.DefaultCookieName` | `token`, the cookie name when `auth.cookie.name` is unset. |
| `bouncer.CookieConfig.TokenCookie` | The httpOnly cookie carrying a token, Max-Age the refresh window passed in. |
| `bouncer.CookieConfig.ForgetTokenCookie` | The same cookie with an empty value and a past expiry, which deletes it. |
| `bouncer.CookieRefreshHandler` | Reads the token cookie, calls the application's refresh func, sets the rotated cookie and hands a `bouncer.CookieResult` to the writer. |
| `bouncer.CookieRefreshOptions` | Cookie config, refresh window, refresh func and writer of `bouncer.CookieRefreshHandler`. |
| `bouncer.CookieLogoutHandler` | Revokes the cookie's token through the application's func, clears the cookie and writes 200. |
| `bouncer.CookieLogoutOptions` | Cookie config, revoke func and writer of `bouncer.CookieLogoutHandler`. |
| `bouncer.CookieResult` | What the cookie handlers pass to the writer: status, rotated token and error. |
| `bouncer.ErrNoTokenCookie` | The refresh request carried no token cookie (401). |
| `bouncer.HashPassword` | Returns a bcrypt hash at the given cost. |
| `bouncer.CheckPassword` | Reports whether a password matches a hash. |
| `bouncer.NeedsRehash` | Reports whether a hash was made below the configured cost. |
## Configuration
`bouncer.LoadCookieConfig` reads these keys; nothing else in bouncer reads config. The per-guard token lifetimes (`ttl`, `refresh_ttl`) stay with the guard's own config, such as the user plugin's `jwt` block or `admin.jwt.*`.
| Key | Default | Controls |
|-----|---------|----------|
| `auth.cookie.name` | `token` | Cookie name; must be a valid cookie name. |
| `auth.cookie.domain` | empty (host-only) | Cookie domain. Set a parent domain such as `example.com` to share the cookie between `api.example.com` and `www.example.com`; every subdomain then receives it. An invalid domain fails boot. |
| `auth.cookie.path` | `/` | Cookie path; must start with `/`. |
| `auth.cookie.secure` | true when `app.url` is https | Sends the cookie over https only. `false` is refused in the production environment. |
| `auth.cookie.same_site` | `lax` | `lax`, `strict` or `none`; `none` requires Secure. |
Environment variables follow the compass mapping, for example `SUMMER_AUTH__COOKIE__DOMAIN`.
## Dependencies
- SummerCMS modules: none.
- Third-party: `github.com/golang-jwt/jwt/v5`, `golang.org/x/crypto/bcrypt`.
- Standard library: `context`, `crypto/rand`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `math`, `net/http`, `reflect`, `regexp`, `strconv`, `strings`, `sync`, `time`.
- Standard library: `context`, `crypto/rand`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `math`, `net/http`, `net/url`, `reflect`, `regexp`, `strconv`, `strings`, `sync`, `time`.
- Tests additionally use `github.com/testcontainers/testcontainers-go` with its `modules/postgres` package, and `github.com/jackc/pgx/v5/stdlib`.
## Testing

280
modules/bouncer/cookie.go Normal file
View File

@@ -0,0 +1,280 @@
package bouncer
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// DefaultCookieName is the token cookie name when auth.cookie.name is unset.
const DefaultCookieName = "token"
// ErrNoTokenCookie is reported by CookieRefreshHandler when the request has
// no token cookie or an empty one.
var ErrNoTokenCookie = errors.New(msgTokenNotProvided)
// CookieConfig holds the attributes of the httpOnly token cookie. Every guard
// shares one config; the lifetime is passed per call (the guard's
// refresh_ttl), so the cookie outlives the access token it carries.
type CookieConfig struct {
// Name is the cookie name (auth.cookie.name, default "token").
Name string
// Domain is empty for a host-only cookie (the default); a parent domain
// such as example.com shares the cookie with every subdomain.
Domain string
// Path is the cookie path (auth.cookie.path, default "/").
Path string
// Secure sends the cookie over https only.
Secure bool
// SameSite is http.SameSiteLaxMode unless auth.cookie.same_site says
// otherwise.
SameSite http.SameSite
}
// CookieConfigSource is the part of the application config LoadCookieConfig
// reads. *compass.Config satisfies it, so bouncer imports no framework module.
type CookieConfigSource interface {
Lookup(path string) (any, bool)
String(path string) string
Environment() string
}
// LoadCookieConfig reads the auth.cookie block:
//
// - auth.cookie.name: default DefaultCookieName; must be a valid cookie name.
// - auth.cookie.path: default "/"; must start with "/".
// - auth.cookie.domain: default empty (host-only); a non-empty value must
// be a valid cookie domain, so it is never dropped silently when the
// cookie is written.
// - auth.cookie.same_site: lax (default), strict or none, case-insensitive.
// - auth.cookie.secure: unset means true exactly when app.url is https; an
// explicit false is refused in the production environment.
//
// same_site none without Secure is refused, as browsers drop such cookies.
// Every error names the key.
func LoadCookieConfig(src CookieConfigSource) (CookieConfig, error) {
cfg := CookieConfig{Name: DefaultCookieName, Path: "/", SameSite: http.SameSiteLaxMode}
if src == nil {
return cfg, nil
}
if raw, ok := src.Lookup("auth.cookie.name"); ok && raw != nil {
name := strings.TrimSpace(fmt.Sprint(raw))
if name == "" || (&http.Cookie{Name: name, Value: "x"}).Valid() != nil {
return cfg, fmt.Errorf("bouncer: auth.cookie.name %q is not a valid cookie name", name)
}
cfg.Name = name
}
if raw, ok := src.Lookup("auth.cookie.path"); ok && raw != nil {
p := strings.TrimSpace(fmt.Sprint(raw))
if !strings.HasPrefix(p, "/") || (&http.Cookie{Name: "x", Value: "x", Path: p}).Valid() != nil {
return cfg, fmt.Errorf("bouncer: auth.cookie.path %q must be an absolute URL path starting with /", p)
}
cfg.Path = p
}
if raw, ok := src.Lookup("auth.cookie.domain"); ok && raw != nil {
d := strings.TrimSpace(fmt.Sprint(raw))
if d != "" {
if (&http.Cookie{Name: "x", Value: "x", Domain: d}).Valid() != nil {
return cfg, fmt.Errorf("bouncer: auth.cookie.domain %q is not a valid cookie domain", d)
}
cfg.Domain = d
}
}
if raw, ok := src.Lookup("auth.cookie.same_site"); ok && raw != nil {
switch v := strings.ToLower(strings.TrimSpace(fmt.Sprint(raw))); v {
case "", "lax":
cfg.SameSite = http.SameSiteLaxMode
case "strict":
cfg.SameSite = http.SameSiteStrictMode
case "none":
cfg.SameSite = http.SameSiteNoneMode
default:
return cfg, fmt.Errorf("bouncer: auth.cookie.same_site %q must be lax, strict or none", v)
}
}
secure, explicit, err := cookieSecure(src)
if err != nil {
return cfg, err
}
if explicit && !secure && strings.EqualFold(strings.TrimSpace(src.Environment()), "production") {
return cfg, errors.New("bouncer: auth.cookie.secure: false is not allowed in the production environment")
}
cfg.Secure = secure
if cfg.SameSite == http.SameSiteNoneMode && !cfg.Secure {
return cfg, errors.New("bouncer: auth.cookie.same_site none requires auth.cookie.secure true (browsers drop SameSite=None cookies without Secure)")
}
return cfg, nil
}
// cookieSecure reads auth.cookie.secure; unset falls back to the app.url
// scheme. explicit reports whether the key was set.
func cookieSecure(src CookieConfigSource) (secure, explicit bool, err error) {
raw, ok := src.Lookup("auth.cookie.secure")
if !ok || raw == nil {
u, perr := url.Parse(strings.TrimSpace(src.String("app.url")))
return perr == nil && strings.EqualFold(u.Scheme, "https"), false, nil
}
switch v := raw.(type) {
case bool:
return v, true, nil
case string:
b, perr := strconv.ParseBool(strings.TrimSpace(v))
if perr != nil {
return false, true, fmt.Errorf("bouncer: auth.cookie.secure %q must be true or false", v)
}
return b, true, nil
default:
return false, true, fmt.Errorf("bouncer: auth.cookie.secure must be true or false, got %T", raw)
}
}
// TokenCookie returns the httpOnly cookie carrying token. Max-Age is
// refreshTTL in whole seconds: the refresh window, never the access ttl, so
// the refresh endpoint still has a cookie to read after the token expired.
func (c CookieConfig) TokenCookie(token string, refreshTTL time.Duration) *http.Cookie {
c = c.withDefaults()
return &http.Cookie{
Name: c.Name,
Value: token,
Path: c.Path,
Domain: c.Domain,
MaxAge: int(refreshTTL / time.Second),
Expires: time.Now().Add(refreshTTL),
HttpOnly: true,
Secure: c.Secure,
SameSite: c.SameSite,
}
}
// ForgetTokenCookie returns a cookie with the same name, path, domain and
// flags as TokenCookie, an empty value and an expiry in the past, so the
// browser deletes the token cookie.
func (c CookieConfig) ForgetTokenCookie() *http.Cookie {
c = c.withDefaults()
return &http.Cookie{
Name: c.Name,
Value: "",
Path: c.Path,
Domain: c.Domain,
MaxAge: -1,
Expires: time.Unix(0, 0).UTC(),
HttpOnly: true,
Secure: c.Secure,
SameSite: c.SameSite,
}
}
// CookieResult is what the cookie handlers pass to the application's writer.
// Status is the HTTP status to answer with; Token is the rotated token on a
// successful refresh; Err is the refusal (refresh) or the revoke failure
// (logout, which still answers 200 and clears the cookie).
type CookieResult struct {
Status int
Token string
Err error
}
// CookieRefreshOptions configures CookieRefreshHandler.
type CookieRefreshOptions struct {
// Cookie names the cookie to read and the attributes of the new one.
Cookie CookieConfig
// RefreshTTL is the guard's refresh window (refresh_ttl); it becomes the
// new cookie's Max-Age.
RefreshTTL time.Duration
// Refresh rotates the token, typically by calling Refresh or
// RefreshAudienceFor, which blacklist the old jti.
Refresh func(ctx context.Context, token string) (string, error)
// Write answers the request in the application's response envelope.
Write func(w http.ResponseWriter, r *http.Request, res CookieResult)
}
// CookieLogoutOptions configures CookieLogoutHandler.
type CookieLogoutOptions struct {
// Cookie names the cookie to read and clear.
Cookie CookieConfig
// Revoke blacklists the token, for example through
// VerifyRefreshableClaimsAudience and BlacklistStore.Add.
Revoke func(ctx context.Context, token string) error
// Write answers the request in the application's response envelope.
Write func(w http.ResponseWriter, r *http.Request, res CookieResult)
}
// CookieRefreshHandler returns a handler that reads the token cookie, calls
// opts.Refresh and, on success, sets the rotated cookie and writes 200 with
// the new token. A missing or empty cookie writes 401 with ErrNoTokenCookie,
// and a refused refresh writes 401 with the refusal; both leave the cookie
// alone. A nil Refresh or Write, or a non-positive RefreshTTL, is an error.
// Mount it on a POST route.
func CookieRefreshHandler(opts CookieRefreshOptions) (http.Handler, error) {
if opts.Refresh == nil {
return nil, errors.New("bouncer: CookieRefreshOptions.Refresh is nil")
}
if opts.Write == nil {
return nil, errors.New("bouncer: CookieRefreshOptions.Write is nil")
}
if opts.RefreshTTL <= 0 {
return nil, errors.New("bouncer: CookieRefreshOptions.RefreshTTL must be positive")
}
cookie := opts.Cookie.withDefaults()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := readTokenCookie(r, cookie.Name)
if token == "" {
opts.Write(w, r, CookieResult{Status: http.StatusUnauthorized, Err: ErrNoTokenCookie})
return
}
next, err := opts.Refresh(r.Context(), token)
if err != nil {
opts.Write(w, r, CookieResult{Status: http.StatusUnauthorized, Err: err})
return
}
http.SetCookie(w, cookie.TokenCookie(next, opts.RefreshTTL))
opts.Write(w, r, CookieResult{Status: http.StatusOK, Token: next})
}), nil
}
// CookieLogoutHandler returns a handler that revokes the token in the cookie
// (when there is one) through opts.Revoke, always clears the cookie and
// writes 200; a revoke failure is passed to Write in CookieResult.Err. A nil
// Revoke or Write is an error. Mount it on a POST route.
func CookieLogoutHandler(opts CookieLogoutOptions) (http.Handler, error) {
if opts.Revoke == nil {
return nil, errors.New("bouncer: CookieLogoutOptions.Revoke is nil")
}
if opts.Write == nil {
return nil, errors.New("bouncer: CookieLogoutOptions.Write is nil")
}
cookie := opts.Cookie.withDefaults()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var revokeErr error
if token := readTokenCookie(r, cookie.Name); token != "" {
revokeErr = opts.Revoke(r.Context(), token)
}
http.SetCookie(w, cookie.ForgetTokenCookie())
opts.Write(w, r, CookieResult{Status: http.StatusOK, Err: revokeErr})
}), nil
}
// withDefaults fills an empty Name and Path, so a zero CookieConfig writes
// the default token cookie on "/".
func (c CookieConfig) withDefaults() CookieConfig {
if strings.TrimSpace(c.Name) == "" {
c.Name = DefaultCookieName
}
if c.Path == "" {
c.Path = "/"
}
return c
}
func readTokenCookie(r *http.Request, name string) string {
c, err := r.Cookie(name)
if err != nil {
return ""
}
return strings.TrimSpace(c.Value)
}