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