- LoadCookieConfig refuses a Secure that resolves to false in production, also when inferred from an http or unset app.url, naming auth.cookie.secure and app.url
285 lines
10 KiB
Go
285 lines
10 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.
|
|
Secure bool
|
|
// SameSite is http.SameSiteLaxMode unless auth.cookie.same_site says
|
|
// otherwise.
|
|
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, so a zero CookieConfig writes
|
|
// the default token cookie on "/".
|
|
func (c CookieConfig) withDefaults() CookieConfig {
|
|
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)
|
|
}
|