diff --git a/docs/services/authentication.md b/docs/services/authentication.md index 3b31195..45a7ef1 100644 --- a/docs/services/authentication.md +++ b/docs/services/authentication.md @@ -75,10 +75,10 @@ This fragment belongs in `config/auth.yaml`. With the block unset the cookie is | `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.secure` | true when `app.url` is https | In the production environment a value that resolves to false is refused, whether `false` is set or inferred from an http or unset `app.url`. | | `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. +An invalid domain, `same_site: none` without Secure, or a cookie that is not Secure in production makes `bouncer.LoadCookieConfig` return an error naming the key. In production that covers `secure: false` and an unset `auth.cookie.secure` with an http or missing `app.url`; that error names `auth.cookie.secure` and `app.url`, and you fix it by setting `app.url` to the public https URL or `auth.cookie.secure: true`. Return the error from your plugin's boot so the application does not start with a cookie browsers would drop or send in clear. `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. diff --git a/modules/bouncer/README.md b/modules/bouncer/README.md index b974caa..0e36a48 100644 --- a/modules/bouncer/README.md +++ b/modules/bouncer/README.md @@ -31,7 +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. +- Cookie transport: `bouncer.LoadCookieConfig` reads the `auth.cookie.*` block into a `bouncer.CookieConfig` and refuses an invalid domain, `same_site: none` without Secure and, in the production environment, a Secure that resolves to false (set to `false`, or inferred from an http or unset `app.url`); `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. - Cloudflare Turnstile: `bouncer.Turnstile.Verify` checks a token against siteverify with a 5 second timeout, in PHP's open mode (an empty secret passes) or closed mode (an empty secret is `bouncer.TurnstileUnavailable` in production); `bouncer.Turnstile.Middleware` reads the token from the query, a JSON body or a form body, restores the body and writes refusals through a `bouncer.TurnstileWriter`. surf registers it as `turnstile:open` and `turnstile:closed`. - Passwords: `bouncer.HashPassword`, `bouncer.CheckPassword` and `bouncer.NeedsRehash` (bcrypt, cost chosen by the caller). @@ -191,7 +191,7 @@ func RefreshRoute(cfg bouncer.CookieConfigSource, users bouncer.UserProvider, se | `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.secure` | true when `app.url` is https | Sends the cookie over https only. In the production environment a value that resolves to false is refused, whether `false` is set or inferred from an http or unset `app.url`. | | `auth.cookie.same_site` | `lax` | `lax`, `strict` or `none`; `none` requires Secure. | Environment variables follow the compass mapping, for example `SUMMER_AUTH__COOKIE__DOMAIN`. diff --git a/modules/bouncer/cookie.go b/modules/bouncer/cookie.go index 662f617..a5e1ef2 100644 --- a/modules/bouncer/cookie.go +++ b/modules/bouncer/cookie.go @@ -52,8 +52,9 @@ type CookieConfigSource interface { // 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. +// - auth.cookie.secure: unset means true exactly when app.url is https. In +// the production environment a Secure that resolves to false is refused, +// whether set explicitly or inferred from an http or unset app.url. // // same_site none without Secure is refused, as browsers drop such cookies. // Every error names the key. @@ -101,8 +102,11 @@ func LoadCookieConfig(src CookieConfigSource) (CookieConfig, error) { 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") + if !secure && strings.EqualFold(strings.TrimSpace(src.Environment()), "production") { + if explicit { + return cfg, errors.New("bouncer: auth.cookie.secure: false is not allowed in the production environment") + } + return cfg, errors.New("bouncer: auth.cookie.secure resolves to false in the production environment: it is unset and app.url is not an https URL; set app.url to an https URL or auth.cookie.secure: true") } cfg.Secure = secure if cfg.SameSite == http.SameSiteNoneMode && !cfg.Secure { diff --git a/modules/bouncer/cookie_test.go b/modules/bouncer/cookie_test.go index 75e7a1d..f068ef3 100644 --- a/modules/bouncer/cookie_test.go +++ b/modules/bouncer/cookie_test.go @@ -39,16 +39,19 @@ func TestLoadCookieConfigDefaults(t *testing.T) { t.Fatalf("nil source = %+v", cfg) } + // An http or unset app.url infers Secure false outside production only; + // production refuses it (TestLoadCookieConfigErrors). for _, tc := range []struct { url string + env string secure bool }{ - {"https://app.example.com", true}, - {"HTTPS://app.example.com", true}, - {"http://localhost:8000", false}, - {"", false}, + {"https://app.example.com", "production", true}, + {"HTTPS://app.example.com", "production", true}, + {"http://localhost:8000", "development", false}, + {"", "development", false}, } { - cfg, err := LoadCookieConfig(cookieSource{values: map[string]any{"app.url": tc.url}, env: "production"}) + cfg, err := LoadCookieConfig(cookieSource{values: map[string]any{"app.url": tc.url}, env: tc.env}) if err != nil { t.Fatalf("app.url %q: %v", tc.url, err) } @@ -104,6 +107,8 @@ func TestLoadCookieConfigErrors(t *testing.T) { {"same_site none on http app", map[string]any{"auth.cookie.same_site": "none", "app.url": "http://localhost"}, "", "auth.cookie.same_site"}, {"secure false in production", map[string]any{"auth.cookie.secure": false}, "production", "auth.cookie.secure"}, {"secure string false in production", map[string]any{"auth.cookie.secure": "false"}, "Production", "auth.cookie.secure"}, + {"secure inferred from http app.url in production", map[string]any{"app.url": "http://api.example.com"}, "production", "app.url"}, + {"secure inferred from unset app.url in production", map[string]any{}, "production", "auth.cookie.secure resolves to false"}, {"secure not a bool", map[string]any{"auth.cookie.secure": "maybe"}, "", "auth.cookie.secure"}, {"secure wrong type", map[string]any{"auth.cookie.secure": 1}, "", "auth.cookie.secure"}, {"unknown same_site", map[string]any{"auth.cookie.same_site": "relaxed"}, "", "auth.cookie.same_site"},