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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user