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

@@ -224,4 +224,25 @@ cors:
This fragment belongs in `config/http.yaml`. List the frontend's exact origin; `*` is for public, credential-free APIs only.
The rules surf applies to the origin answer:
- `supports_credentials: true` together with `*` in `allowed_origins` fails the start-up. A credentialed answer that trusted any origin would let every site read responses made with the user's cookies; Laravel's php-cors reflects the origin in that case, surf refuses it.
- An allowed origin is echoed exactly in `Access-Control-Allow-Origin`; `*` is sent only for `allowed_origins: ["*"]` without credentials, and that answer is unchanged from earlier releases.
- When the answer depends on the request's `Origin` (with `supports_credentials`, with any pattern, or with more than one listed origin), every response on a CORS path carries `Vary: Origin`, the preflight and a disallowed origin's response included, so a shared cache never serves one origin's answer to another.
- An `allowed_origins` entry with a `*` inside, such as `https://*.example.com`, matches like Laravel's `Str::is`: the `*` stands for any characters and the rest is literal.
- `allowed_origins_patterns` entries are Go regular expressions. One that does not compile fails the start-up, and so does one still wrapped in PHP delimiters, such as `#^https://.*\.example\.com$#`: drop the delimiters and any flags when porting it.
A frontend on `https://www.example.com` that calls an API on `api.example.com` with the session cookie (see [Cookie transport](authentication.md#cookie-transport)) needs the www origin listed and credentials on:
```yaml
cors:
paths: ["api/*"]
allowed_origins: ["https://www.example.com"]
allowed_methods: ["*"]
allowed_headers: ["*"]
supports_credentials: true
```
The browser sends the request with `credentials: "include"`; the answer carries `Access-Control-Allow-Origin: https://www.example.com`, `Access-Control-Allow-Credentials: true` and `Vary: Origin`.
surf answers every `OPTIONS` request on a CORS path itself, with 204, before any route runs, so a plugin never registers an `OPTIONS` route. A preflight gets the same headers Laravel's `HandleCors` sends: with `allowed_methods: ["*"]` the `Access-Control-Allow-Methods` value is the requested method, upper-cased, and with `allowed_headers: ["*"]` the `Access-Control-Allow-Headers` value is the requested header list.