Files
summercms/modules/bouncer
Jakub Zych 8d80580083 test(19.3-06): unit tests for the review fixes
- TestCORSCredentialedOriginRules, TestCORSAnchoredPatternNoSuffixMatch (D-04)
- TestAPIDocsCSPNoHostSource (D-17)
- TestLoadCookieConfigProductionInferredSecure, TestZeroCookieConfigDefaults (D-03)
- TestSiteCachePurgeWaitAndClose, TestSiteCacheBulkCommitCoalesced (D-10)
2026-10-15 22:45:16 +02:00
..

bouncer

Authentication for SummerCMS: HS256 JWT minting, verification and refresh, request guards, a revoked-token blacklist and bcrypt password helpers.

import "git.golem15.com/golem15/summercms/modules/bouncer"

Name: Checks IDs at the beach club door; it authenticates requests and turns away the ones without a valid token.

Overview

bouncer decides who is making a request. Guards (bouncer.Guard, bouncer.CredentialGuard) turn an *http.Request into a bouncer.Principal; a bouncer.Registry holds named guards that plugins register and turns each one into HTTP middleware that stores the principal on the request context. The JWT side issues and checks HS256 tokens for two audiences, frontend users (bouncer.AudienceUser) and admin users (bouncer.AudienceBackend), with a refresh flow and a jti blacklist compatible with tokens issued by the PHP jwt-auth library. It is the counterpart of WinterCMS's Auth and BackendAuth facades and the JWT auth layer used by API plugins.

Role in the framework

A dependency-free security primitive. surf uses it for request guards, cabana for admin sign-in, and conga and lighthouse for authenticated workers and realtime tokens. It is separate so authentication code can be reviewed and tested on its own, without the HTTP or admin layers around it.

Use cases:

  • Issue and refresh JWTs for a single-page or mobile frontend.
  • Protect API routes with a guard and read the authenticated user downstream.
  • Revoke tokens on sign-out and hash and check passwords with bcrypt.

Features

  • Token minting with bouncer.Mint (frontend audience) and bouncer.MintAudience (any audience), each returning the signed token and its random jti.
  • Verification with HS256 pinned and exp and sub required: bouncer.Verify and bouncer.VerifyClaims accept frontend tokens, including legacy tokens with no audience claim; bouncer.VerifyClaimsAudience requires an explicit audience, so a backend token cannot pass a frontend check and the other way round.
  • Refresh with bouncer.Refresh, bouncer.RefreshAudience and bouncer.RefreshAudienceFor: an expired token can be reissued while its iat is inside the refresh window; the old jti is blacklisted after a grace period. bouncer.RefreshAudienceFor also reloads the user and refuses deleted users and tokens issued before bouncer.Principal.TokensValidAfter, reporting bouncer.ErrSubjectRejected.
  • JWT guards: bouncer.NewJWTGuard (frontend) and bouncer.NewBackendJWTGuard (admin audience, optional custom 401 writer) read the bearer token first and then any configured cookies, load the user through a bouncer.UserProvider, check the blacklist and the bouncer.Principal.TokensValidAfter cutoff, and write a JSON 401 body ({"error":true,"message":...}, with Cache-Control: no-cache, private) on failure.
  • Named guard registry: bouncer.Registry.Register accepts any bouncer.Guard or bouncer.CredentialGuard; bouncer.Registry.Middleware derives middleware that stores the principal (and credential, if any) on the context. Guards that do not implement bouncer.UnauthorizedWriter let unauthenticated requests through so later middleware can decide.
  • Standalone bearer middleware: bouncer.Middleware.
  • 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, 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. A hand-built config with SameSite unset writes SameSite=Lax, and the zero bouncer.CookieConfig writes token on / with HttpOnly, SameSite=Lax and Secure. 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).

Usage

package blog

import (
	"context"
	"fmt"
	"net/http"
	"time"

	"git.golem15.com/golem15/summercms/modules/bouncer"
)

type users struct{}

// FindByID loads the user behind a token subject; nil means "not found".
func (users) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
	return &bouncer.Principal{ID: id, PreferredLocale: "en"}, nil
}

func Routes(secret string) (http.Handler, error) {
	guards := bouncer.NewRegistry()
	guard := bouncer.NewJWTGuard(secret, users{}, bouncer.NewMemoryBlacklist(), "token")
	if err := guards.Register("acme.blog", "jwt", guard); err != nil {
		return nil, err
	}
	auth, err := guards.Middleware("jwt")
	if err != nil {
		return nil, err
	}

	mux := http.NewServeMux()
	mux.Handle("GET /api/me", auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		user, _ := bouncer.User(r.Context())
		fmt.Fprintf(w, "user %d", user.ID)
	})))
	return mux, nil
}

func Login(secret string) (string, error) {
	token, _, err := bouncer.Mint(secret, "42", "https://example.com/api/login", time.Hour)
	return token, err
}

An application that keeps the token in an httpOnly cookie, for a frontend on www.example.com calling the API on api.example.com, loads the cookie config at boot and mounts the refresh handler on a POST route:

package acme

import (
	"context"
	"encoding/json"
	"net/http"
	"time"

	"git.golem15.com/golem15/summercms/modules/bouncer"
)

// RefreshRoute rotates the cookie's token. cfg is the app config
// (app.Config); users, secret and bl are the guard's; refreshTTL is its
// refresh_ttl.
func RefreshRoute(cfg bouncer.CookieConfigSource, users bouncer.UserProvider, secret string, bl bouncer.BlacklistStore, refreshTTL time.Duration) (http.Handler, error) {
	cookie, err := bouncer.LoadCookieConfig(cfg) // a bad auth.cookie block fails boot
	if err != nil {
		return nil, err
	}
	return bouncer.CookieRefreshHandler(bouncer.CookieRefreshOptions{
		Cookie:     cookie,
		RefreshTTL: refreshTTL,
		Refresh: func(ctx context.Context, token string) (string, error) {
			return bouncer.RefreshAudienceFor(ctx, users, secret, token, bouncer.AudienceUser, refreshTTL, bl, 0, "https://api.example.com/api/auth/refresh")
		},
		Write: func(w http.ResponseWriter, r *http.Request, res bouncer.CookieResult) {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(res.Status)
			if res.Err != nil {
				_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "error": "UNAUTHENTICATED"})
				return
			}
			_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
		},
	})
}

API reference

Identifier Description
bouncer.Principal The authenticated identity: user ID, locale override, token cutoff, admin permission grants and optional Scope.
bouncer.ScopeLimit A personal API token's method-filtered scope: Full or one permission-pattern slice per allowed scope.
bouncer.Principal.Scope Set only for personal API token principals; nil for JWT sessions.
bouncer.Guard Resolves the bouncer.Principal for a request.
bouncer.CredentialGuard Resolves the principal and its underlying credential in one pass.
bouncer.UnauthorizedWriter Optional guard interface for writing its own 401 response.
bouncer.UserProvider Loads a user by numeric token subject.
bouncer.Registry Named guard registry; bouncer.NewRegistry creates one.
bouncer.Registry.Register Registers a guard under a name on behalf of a plugin; duplicate names fail.
bouncer.Registry.Middleware Returns HTTP middleware for a registered guard; unknown names fail.
bouncer.Registry.Owner The plugin ID that registered a named guard.
bouncer.NewJWTGuard Frontend JWT guard reading the bearer header and optional cookies.
bouncer.NewBackendJWTGuard Admin JWT guard that requires the backend audience.
bouncer.Middleware Standalone middleware that validates a bearer token and loads the user.
bouncer.Mint Signs a frontend-audience token; returns the token and its jti.
bouncer.MintAudience Signs a token for a given audience.
bouncer.Verify Verifies a frontend token and returns its subject.
bouncer.VerifyClaims bouncer.Verify plus iat, exp and jti.
bouncer.VerifyClaimsAudience bouncer.VerifyClaims with a required audience.
bouncer.Refresh Reissues a frontend token inside the refresh window and blacklists the old jti.
bouncer.RefreshAudience Refresh for a token that carries the given audience.
bouncer.VerifyRefreshableClaimsAudience Verifies signature and audience without checking exp, while the refresh window is open; for revoking a refreshable token on logout.
bouncer.RefreshAudienceFor bouncer.RefreshAudience plus the guard's user checks.
bouncer.ErrSubjectRejected The token subject is not a loadable user, or the token predates the user's cutoff.
bouncer.AudienceUser, bouncer.AudienceBackend The frontend and admin audience values.
bouncer.WithUser, bouncer.User Store and read the principal on a context.
bouncer.WithCredential, bouncer.Credential Store and read the resolved credential on a context.
bouncer.BlacklistStore Revoked-jti store with a grace window and sweeping.
bouncer.NewMemoryBlacklist In-process blacklist for tests.
bouncer.NewPostgresBlacklist Blacklist over a *sql.DB and a table name.
bouncer.CookieConfig Token cookie attributes: Name, Domain, Path, Secure, SameSite. An unset SameSite is Lax; the zero config is also Secure, while a config with any field set keeps its Secure value.
bouncer.CookieConfigSource The config methods bouncer.LoadCookieConfig reads (Lookup, String, Environment); *compass.Config satisfies it.
bouncer.LoadCookieConfig Reads and validates auth.cookie.*; every error names the key.
bouncer.DefaultCookieName token, the cookie name when auth.cookie.name is unset.
bouncer.CookieConfig.TokenCookie The httpOnly cookie carrying a token, Max-Age the refresh window passed in.
bouncer.CookieConfig.ForgetTokenCookie The same cookie with an empty value and a past expiry, which deletes it.
bouncer.CookieRefreshHandler Reads the token cookie, calls the application's refresh func, sets the rotated cookie and hands a bouncer.CookieResult to the writer.
bouncer.CookieRefreshOptions Cookie config, refresh window, refresh func and writer of bouncer.CookieRefreshHandler.
bouncer.CookieLogoutHandler Revokes the cookie's token through the application's func, clears the cookie and writes 200.
bouncer.CookieLogoutOptions Cookie config, revoke func and writer of bouncer.CookieLogoutHandler.
bouncer.CookieResult What the cookie handlers pass to the writer: status, rotated token and error.
bouncer.ErrNoTokenCookie The refresh request carried no token cookie (401).
bouncer.Turnstile Cloudflare Turnstile verifier: Secret, Production, Endpoint, Client.
bouncer.NewTurnstile A verifier with the default endpoint and a client with bouncer.TurnstileTimeout.
bouncer.Turnstile.Verify Checks a token for a client IP in open or closed mode; returns a bouncer.TurnstileVerdict and an error for logging.
bouncer.Turnstile.Middleware Middleware that reads the token field, verifies it and answers 503 or 422 through a bouncer.TurnstileWriter.
bouncer.TurnstileVerdict bouncer.TurnstilePassed, bouncer.TurnstileFailed or bouncer.TurnstileUnavailable.
bouncer.TurnstileWriter Writes a Turnstile refusal in the application's envelope; publish one on the app to replace the neutral default.
bouncer.TurnstileSiteverifyURL Cloudflare's siteverify endpoint.
bouncer.TurnstileTimeout 5 seconds, the bound on one siteverify call.
bouncer.TurnstileMaxTokenLength 2048, Cloudflare's token limit; longer tokens fail without a call.
bouncer.DefaultTurnstileField turnstile_token, the default request field.
bouncer.HashPassword Returns a bcrypt hash at the given cost.
bouncer.CheckPassword Reports whether a password matches a hash.
bouncer.NeedsRehash Reports whether a hash was made below the configured cost.

Configuration

bouncer.LoadCookieConfig reads these keys; nothing else in bouncer reads config (surf builds the Turnstile verifier from turnstile.secret and turnstile.field, see the surf README). The per-guard token lifetimes (ttl, refresh_ttl) stay with the guard's own config, such as the user plugin's jwt block or admin.jwt.*.

Key Default Controls
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. 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.

Dependencies

  • SummerCMS modules: none.
  • Third-party: github.com/golang-jwt/jwt/v5, golang.org/x/crypto/bcrypt.
  • Standard library: bytes, context, crypto/rand, database/sql, encoding/hex, encoding/json, errors, fmt, io, log/slog, math, mime, net, net/http, net/url, reflect, regexp, strconv, strings, sync, time.
  • Tests additionally use github.com/testcontainers/testcontainers-go with its modules/postgres package, and github.com/jackc/pgx/v5/stdlib.

Testing

go test ./modules/bouncer/...

The Postgres blacklist concurrency test starts a PostgreSQL container through testcontainers-go and needs Docker. Run go test -short ./modules/bouncer/... to skip it; the remaining tests need no external services (Turnstile tests use an httptest siteverify fake through Endpoint).