- withDefaults gives the zero config Secure and every unset SameSite Lax; a config with any field set keeps its Secure value
296 lines
11 KiB
Go
296 lines
11 KiB
Go
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)
|
|
}
|