Files
summercms/modules/bouncer
..

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":...}, 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).
  • 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

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.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.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

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.