# 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"` ## 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. ## 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":...}`) 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). - 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. - Passwords: `bouncer.HashPassword`, `bouncer.CheckPassword` and `bouncer.NeedsRehash` (bcrypt, cost chosen by the caller). ## Usage ```go 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 } ``` ## API reference | Identifier | Description | |------------|-------------| | `bouncer.Principal` | The authenticated identity: user ID, locale override, token cutoff and admin permission grants. | | `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.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.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.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. | ## Dependencies - SummerCMS modules: none. - Third-party: `github.com/golang-jwt/jwt/v5`, `golang.org/x/crypto/bcrypt`. - Standard library: `context`, `crypto/rand`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `math`, `net/http`, `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 ```sh 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.