package bouncer import ( "context" "errors" "fmt" "net/http" "net/url" "strconv" "strings" "time" ) // DefaultCookieName is the token cookie name when auth.cookie.name is unset. const DefaultCookieName = "token" // ErrNoTokenCookie is reported by CookieRefreshHandler when the request has // no token cookie or an empty one. var ErrNoTokenCookie = errors.New(msgTokenNotProvided) // CookieConfig holds the attributes of the httpOnly token cookie. Every guard // shares one config; the lifetime is passed per call (the guard's // refresh_ttl), so the cookie outlives the access token it carries. type CookieConfig struct { // Name is the cookie name (auth.cookie.name, default "token"). Name string // Domain is empty for a host-only cookie (the default); a parent domain // such as example.com shares the cookie with every subdomain. Domain string // Path is the cookie path (auth.cookie.path, default "/"). Path string // Secure sends the cookie over https only. It has no default except for // the zero CookieConfig, which writes a Secure cookie; LoadCookieConfig // derives it from app.url unless auth.cookie.secure is set. Secure bool // SameSite is http.SameSiteLaxMode when unset (also on a hand-built // config) and otherwise what auth.cookie.same_site says. SameSite http.SameSite } // CookieConfigSource is the part of the application config LoadCookieConfig // reads. *compass.Config satisfies it, so bouncer imports no framework module. type CookieConfigSource interface { Lookup(path string) (any, bool) String(path string) string Environment() string } // LoadCookieConfig reads the auth.cookie block: // // - auth.cookie.name: default DefaultCookieName; must be a valid cookie name. // - auth.cookie.path: default "/"; must start with "/". // - auth.cookie.domain: default empty (host-only); a non-empty value must // 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. 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. func LoadCookieConfig(src CookieConfigSource) (CookieConfig, error) { cfg := CookieConfig{Name: DefaultCookieName, Path: "/", SameSite: http.SameSiteLaxMode} if src == nil { return cfg, nil } if raw, ok := src.Lookup("auth.cookie.name"); ok && raw != nil { name := strings.TrimSpace(fmt.Sprint(raw)) if name == "" || (&http.Cookie{Name: name, Value: "x"}).Valid() != nil { return cfg, fmt.Errorf("bouncer: auth.cookie.name %q is not a valid cookie name", name) } cfg.Name = name } if raw, ok := src.Lookup("auth.cookie.path"); ok && raw != nil { p := strings.TrimSpace(fmt.Sprint(raw)) if !strings.HasPrefix(p, "/") || (&http.Cookie{Name: "x", Value: "x", Path: p}).Valid() != nil { return cfg, fmt.Errorf("bouncer: auth.cookie.path %q must be an absolute URL path starting with /", p) } cfg.Path = p } if raw, ok := src.Lookup("auth.cookie.domain"); ok && raw != nil { d := strings.TrimSpace(fmt.Sprint(raw)) if d != "" { if (&http.Cookie{Name: "x", Value: "x", Domain: d}).Valid() != nil { return cfg, fmt.Errorf("bouncer: auth.cookie.domain %q is not a valid cookie domain", d) } cfg.Domain = d } } if raw, ok := src.Lookup("auth.cookie.same_site"); ok && raw != nil { switch v := strings.ToLower(strings.TrimSpace(fmt.Sprint(raw))); v { case "", "lax": cfg.SameSite = http.SameSiteLaxMode case "strict": cfg.SameSite = http.SameSiteStrictMode case "none": cfg.SameSite = http.SameSiteNoneMode default: return cfg, fmt.Errorf("bouncer: auth.cookie.same_site %q must be lax, strict or none", v) } } secure, explicit, err := cookieSecure(src) if err != nil { return cfg, err } 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 { return cfg, errors.New("bouncer: auth.cookie.same_site none requires auth.cookie.secure true (browsers drop SameSite=None cookies without Secure)") } return cfg, nil } // cookieSecure reads auth.cookie.secure; unset falls back to the app.url // scheme. explicit reports whether the key was set. func cookieSecure(src CookieConfigSource) (secure, explicit bool, err error) { raw, ok := src.Lookup("auth.cookie.secure") if !ok || raw == nil { u, perr := url.Parse(strings.TrimSpace(src.String("app.url"))) return perr == nil && strings.EqualFold(u.Scheme, "https"), false, nil } switch v := raw.(type) { case bool: return v, true, nil case string: b, perr := strconv.ParseBool(strings.TrimSpace(v)) if perr != nil { return false, true, fmt.Errorf("bouncer: auth.cookie.secure %q must be true or false", v) } return b, true, nil default: return false, true, fmt.Errorf("bouncer: auth.cookie.secure must be true or false, got %T", raw) } } // TokenCookie returns the httpOnly cookie carrying token. Max-Age is // refreshTTL in whole seconds: the refresh window, never the access ttl, so // the refresh endpoint still has a cookie to read after the token expired. func (c CookieConfig) TokenCookie(token string, refreshTTL time.Duration) *http.Cookie { c = c.withDefaults() return &http.Cookie{ Name: c.Name, Value: token, Path: c.Path, Domain: c.Domain, MaxAge: int(refreshTTL / time.Second), Expires: time.Now().Add(refreshTTL), HttpOnly: true, Secure: c.Secure, SameSite: c.SameSite, } } // ForgetTokenCookie returns a cookie with the same name, path, domain and // flags as TokenCookie, an empty value and an expiry in the past, so the // browser deletes the token cookie. func (c CookieConfig) ForgetTokenCookie() *http.Cookie { c = c.withDefaults() return &http.Cookie{ Name: c.Name, Value: "", Path: c.Path, Domain: c.Domain, MaxAge: -1, Expires: time.Unix(0, 0).UTC(), HttpOnly: true, Secure: c.Secure, SameSite: c.SameSite, } } // CookieResult is what the cookie handlers pass to the application's writer. // Status is the HTTP status to answer with; Token is the rotated token on a // successful refresh; Err is the refusal (refresh) or the revoke failure // (logout, which still answers 200 and clears the cookie). type CookieResult struct { Status int Token string Err error } // CookieRefreshOptions configures CookieRefreshHandler. type CookieRefreshOptions struct { // Cookie names the cookie to read and the attributes of the new one. Cookie CookieConfig // RefreshTTL is the guard's refresh window (refresh_ttl); it becomes the // new cookie's Max-Age. RefreshTTL time.Duration // Refresh rotates the token, typically by calling Refresh or // RefreshAudienceFor, which blacklist the old jti. Refresh func(ctx context.Context, token string) (string, error) // Write answers the request in the application's response envelope. Write func(w http.ResponseWriter, r *http.Request, res CookieResult) } // CookieLogoutOptions configures CookieLogoutHandler. type CookieLogoutOptions struct { // Cookie names the cookie to read and clear. Cookie CookieConfig // Revoke blacklists the token, for example through // VerifyRefreshableClaimsAudience and BlacklistStore.Add. Revoke func(ctx context.Context, token string) error // Write answers the request in the application's response envelope. Write func(w http.ResponseWriter, r *http.Request, res CookieResult) } // CookieRefreshHandler returns a handler that reads the token cookie, calls // opts.Refresh and, on success, sets the rotated cookie and writes 200 with // the new token. A missing or empty cookie writes 401 with ErrNoTokenCookie, // and a refused refresh writes 401 with the refusal; both leave the cookie // alone. A nil Refresh or Write, or a non-positive RefreshTTL, is an error. // Mount it on a POST route. func CookieRefreshHandler(opts CookieRefreshOptions) (http.Handler, error) { if opts.Refresh == nil { return nil, errors.New("bouncer: CookieRefreshOptions.Refresh is nil") } if opts.Write == nil { return nil, errors.New("bouncer: CookieRefreshOptions.Write is nil") } if opts.RefreshTTL <= 0 { return nil, errors.New("bouncer: CookieRefreshOptions.RefreshTTL must be positive") } cookie := opts.Cookie.withDefaults() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := readTokenCookie(r, cookie.Name) if token == "" { opts.Write(w, r, CookieResult{Status: http.StatusUnauthorized, Err: ErrNoTokenCookie}) return } next, err := opts.Refresh(r.Context(), token) if err != nil { opts.Write(w, r, CookieResult{Status: http.StatusUnauthorized, Err: err}) return } http.SetCookie(w, cookie.TokenCookie(next, opts.RefreshTTL)) opts.Write(w, r, CookieResult{Status: http.StatusOK, Token: next}) }), nil } // CookieLogoutHandler returns a handler that revokes the token in the cookie // (when there is one) through opts.Revoke, always clears the cookie and // writes 200; a revoke failure is passed to Write in CookieResult.Err. A nil // Revoke or Write is an error. Mount it on a POST route. func CookieLogoutHandler(opts CookieLogoutOptions) (http.Handler, error) { if opts.Revoke == nil { return nil, errors.New("bouncer: CookieLogoutOptions.Revoke is nil") } if opts.Write == nil { return nil, errors.New("bouncer: CookieLogoutOptions.Write is nil") } cookie := opts.Cookie.withDefaults() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var revokeErr error if token := readTokenCookie(r, cookie.Name); token != "" { revokeErr = opts.Revoke(r.Context(), token) } http.SetCookie(w, cookie.ForgetTokenCookie()) opts.Write(w, r, CookieResult{Status: http.StatusOK, Err: revokeErr}) }), nil } // withDefaults fills an empty Name and Path and an unset SameSite (Lax). The // zero CookieConfig also gets Secure, so it writes the default token cookie // on "/" with HttpOnly, SameSite=Lax and Secure; a config with any field set // keeps its Secure value (LoadCookieConfig derives it from app.url). // TokenCookie, ForgetTokenCookie and both handlers go through it. func (c CookieConfig) withDefaults() CookieConfig { if c == (CookieConfig{}) { c.Secure = true } if c.SameSite == 0 { c.SameSite = http.SameSiteLaxMode } if strings.TrimSpace(c.Name) == "" { c.Name = DefaultCookieName } if c.Path == "" { c.Path = "/" } return c } func readTokenCookie(r *http.Request, name string) string { c, err := r.Cookie(name) if err != nil { return "" } return strings.TrimSpace(c.Value) }