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:
@@ -58,6 +58,36 @@ Revoked token IDs are kept in a `bouncer.BlacklistStore`:
|
||||
|
||||
Logging out is blacklisting the token's `jti`. Setting a user's `TokensValidAfter` to now revokes all their tokens at once, for example after a password change.
|
||||
|
||||
## Cookie transport
|
||||
|
||||
A browser frontend can keep the token in an httpOnly cookie instead of JavaScript-readable storage. The cookie attributes come from one block, `auth.cookie.*`, which `bouncer.LoadCookieConfig` reads into a `bouncer.CookieConfig` at boot:
|
||||
|
||||
```yaml
|
||||
cookie:
|
||||
domain: example.com
|
||||
same_site: lax
|
||||
```
|
||||
|
||||
This fragment belongs in `config/auth.yaml`. With the block unset the cookie is named `token`, has `Path=/`, `HttpOnly` and `SameSite=Lax`, no `Domain` (host-only) and `Secure` exactly when `app.url` is https. The keys:
|
||||
|
||||
| Key | Default | Controls |
|
||||
|-----|---------|----------|
|
||||
| `auth.cookie.name` | `token` | Cookie name. |
|
||||
| `auth.cookie.domain` | empty (host-only) | A parent domain shares the cookie with its subdomains. |
|
||||
| `auth.cookie.path` | `/` | Cookie path. |
|
||||
| `auth.cookie.secure` | true when `app.url` is https | `false` is refused in the production environment. |
|
||||
| `auth.cookie.same_site` | `lax` | `lax`, `strict` or `none`; `none` requires Secure. |
|
||||
|
||||
An invalid domain, `same_site: none` without Secure or `secure: false` in production makes `bouncer.LoadCookieConfig` return an error naming the key; return it from your plugin's boot so the application does not start with a cookie browsers would drop.
|
||||
|
||||
`bouncer.CookieConfig.TokenCookie` builds the cookie for a token and a lifetime. Pass the guard's refresh window (`refresh_ttl`, 20160 minutes by default), not its access `ttl`: the access token inside expires after `ttl`, the frontend's 401 handling then calls the refresh endpoint, and that endpoint needs the cookie to still be there. A cookie that died with the token would force a new sign-in every hour. `bouncer.CookieConfig.ForgetTokenCookie` builds the matching cookie that deletes it; it carries the same name, path and domain, or the browser would keep the original.
|
||||
|
||||
`bouncer.CookieRefreshHandler` is a ready-made refresh endpoint. It reads the token from the cookie (only from the cookie), calls your refresh func, which wraps `bouncer.Refresh` or `bouncer.RefreshAudienceFor` and so rotates the token and blacklists the old `jti`, sets the rotated cookie and passes a `bouncer.CookieResult` to your writer, which answers in the application's own envelope. A missing cookie (`bouncer.ErrNoTokenCookie`) or a refused refresh reaches the writer with status 401 and leaves the cookie alone. `bouncer.CookieLogoutHandler` revokes the cookie's token through your revoke func, always clears the cookie and answers 200. The [bouncer README](../../modules/bouncer/README.md) has a complete example.
|
||||
|
||||
**Subdomains.** For a frontend on `www.example.com` calling an API on `api.example.com`, set `auth.cookie.domain: example.com`, list `https://www.example.com` in the API's CORS origins with `supports_credentials: true` (see [Routing](routing.md#cors)), and send requests with credentials. A `Domain` cookie reaches every subdomain of `example.com`, so every host under it is inside the trust boundary: an application on another subdomain receives the token too. Keep the default host-only cookie when frontend and API share one host.
|
||||
|
||||
**CSRF.** CORS controls who may read a response, not who may send a request. `SameSite=Lax` treats sibling subdomains as the same site, so a page on any `*.example.com` host can make the browser send the cookie. Mount the refresh and logout handlers on POST routes only, and for routes that act on the cookie prefer a JSON body or a custom header, which a cross-site form cannot send without a CORS preflight. The refresh answer is unreadable to an origin CORS does not allow, and a forced logout is the worst a cross-site POST to the logout route can do.
|
||||
|
||||
## Guards and middleware
|
||||
|
||||
A guard implements `bouncer.Guard`: it turns a request into a principal or an error. `bouncer.NewJWTGuard` is the frontend guard. It reads the bearer token, then any cookie names you give it, verifies the token, checks the blacklist and the user's cutoff, and loads the user through your `bouncer.UserProvider`. On failure it answers 401 with `{"error":true,"message":...}`. `bouncer.NewBackendJWTGuard` is the same for the admin audience.
|
||||
|
||||
Reference in New Issue
Block a user