Files
summercms/docs/services/authentication.md
Jakub Zych e7659f2f4b feat(19.3-02): cookie token refresh across subdomains with hardened credentialed CORS
- bouncer: CookieConfig from auth.cookie.* (LoadCookieConfig validates domain,
  same_site none without Secure, secure false in production), TokenCookie with
  Max-Age = refresh window, ForgetTokenCookie, CookieRefreshHandler and
  CookieLogoutHandler with an app-supplied writer
- surf: http.cors refuses credentials with a * origin and bad or PHP-delimited
  patterns at boot, turns wildcard origins into anchored patterns, echoes the
  exact origin and sends Vary: Origin on every dynamic CORS answer; the
  non-credentialed * answer is unchanged
- smoke tests for the www -> api refresh and the refused wildcard config
2026-10-15 17:49:35 +02:00

12 KiB

title, description, section, order
title description section order
Authentication Mint and verify JWTs with bouncer, turn guards into route middleware, revoke tokens through a jti blacklist and hash passwords with bcrypt. services 50

Authentication

WinterCMS reads the current user through the Auth and BackendAuth facades, and API plugins add a JWT layer on top. SummerCMS has no facades: bouncer turns a request into a bouncer.Principal through a guard, stores it on the request context, and issues and checks the tokens. The frontend user model and its login endpoints belong to the application's user plugin; bouncer supplies the building blocks.

Tokens

bouncer issues HS256 JSON Web Tokens for two audiences: frontend users (bouncer.AudienceUser) and admins (bouncer.AudienceBackend). bouncer.Mint signs a frontend token for a subject, the user ID as a string, and returns the token and its random jti. bouncer.MintAudience signs one for any audience.

bouncer.VerifyClaims checks the signature with HS256 pinned, requires exp and sub, and returns the claims; bouncer.Verify returns only the subject. Both accept frontend tokens, including older tokens without an audience claim. bouncer.VerifyClaimsAudience requires the audience you name, so a frontend token never passes an admin check and the other way round:

const issuer = "http://127.0.0.1:8080/api/login"
token, _, err := bouncer.Mint(testSecret, "42", issuer, time.Hour)
if err != nil {
	fmt.Println(err)
	return
}
sub, iat, exp, _, err := bouncer.VerifyClaims(token, testSecret)
fmt.Println(sub, exp.Sub(iat), err)

// A frontend token never passes a backend check, and a wrong secret fails.
_, _, _, _, err = bouncer.VerifyClaimsAudience(token, testSecret, bouncer.AudienceBackend)
fmt.Println(err != nil)
_, err = bouncer.Verify(token, "another-secret-with-at-least-32-bytes")
fmt.Println(err != nil)

// Refresh reissues the token and blacklists the old jti after the grace.
bl := bouncer.NewMemoryBlacklist()
fresh, err := bouncer.Refresh(testSecret, token, 14*24*time.Hour, bl, 0, issuer)
fmt.Println(fresh != token, err)
_, _, _, jti, _ := bouncer.VerifyClaims(token, testSecret)
revoked, _ := bl.IsBlacklisted(context.Background(), jti)
fmt.Println(revoked)
// Output:
// 42 1h0m0s <nil>
// true
// true
// true <nil>
// true

The secret in these examples is a test value. In an application, read the signing secret from configuration set through an environment variable, use at least 32 random bytes and never commit it.

Refreshing and revoking

bouncer.Refresh reissues a token while its iat is inside the refresh window, even when it has expired, and blacklists the old jti after a grace period, so requests already in flight with the old token still succeed. bouncer.RefreshAudienceFor also reloads the user and refuses one who was deleted, or whose tokens were issued before bouncer.Principal.TokensValidAfter, with bouncer.ErrSubjectRejected. The rules match the PHP jwt-auth library, so tokens issued by a WinterCMS application keep working after a port.

Revoked token IDs are kept in a bouncer.BlacklistStore:

  • bouncer.NewMemoryBlacklist keeps them in the process, for tests.
  • bouncer.NewPostgresBlacklist keeps them in a table you name, with jti, expires_at and valid_until columns. It rejects table names that are not plain identifiers.

Logging out is blacklisting the token's jti. Setting a user's TokensValidAfter to now revokes all their tokens at once, for example after a password change.

A browser frontend can keep the token in an httpOnly cookie instead of JavaScript-readable storage. The cookie attributes come from one block, auth.cookie.*, which bouncer.LoadCookieConfig reads into a bouncer.CookieConfig at boot:

cookie:
  domain: example.com
  same_site: lax

This fragment belongs in config/auth.yaml. With the block unset the cookie is named token, has Path=/, HttpOnly and SameSite=Lax, no Domain (host-only) and Secure exactly when app.url is https. The keys:

Key Default Controls
auth.cookie.name token Cookie name.
auth.cookie.domain empty (host-only) A parent domain shares the cookie with its subdomains.
auth.cookie.path / Cookie path.
auth.cookie.secure true when app.url is https false is refused in the production environment.
auth.cookie.same_site lax lax, strict or none; none requires Secure.

An invalid domain, same_site: none without Secure or secure: false in production makes bouncer.LoadCookieConfig return an error naming the key; return it from your plugin's boot so the application does not start with a cookie browsers would drop.

bouncer.CookieConfig.TokenCookie builds the cookie for a token and a lifetime. Pass the guard's refresh window (refresh_ttl, 20160 minutes by default), not its access ttl: the access token inside expires after ttl, the frontend's 401 handling then calls the refresh endpoint, and that endpoint needs the cookie to still be there. A cookie that died with the token would force a new sign-in every hour. bouncer.CookieConfig.ForgetTokenCookie builds the matching cookie that deletes it; it carries the same name, path and domain, or the browser would keep the original.

bouncer.CookieRefreshHandler is a ready-made refresh endpoint. It reads the token from the cookie (only from the cookie), calls your refresh func, which wraps bouncer.Refresh or bouncer.RefreshAudienceFor and so rotates the token and blacklists the old jti, sets the rotated cookie and passes a bouncer.CookieResult to your writer, which answers in the application's own envelope. A missing cookie (bouncer.ErrNoTokenCookie) or a refused refresh reaches the writer with status 401 and leaves the cookie alone. bouncer.CookieLogoutHandler revokes the cookie's token through your revoke func, always clears the cookie and answers 200. The bouncer README has a complete example.

Subdomains. For a frontend on www.example.com calling an API on api.example.com, set auth.cookie.domain: example.com, list https://www.example.com in the API's CORS origins with supports_credentials: true (see Routing), and send requests with credentials. A Domain cookie reaches every subdomain of example.com, so every host under it is inside the trust boundary: an application on another subdomain receives the token too. Keep the default host-only cookie when frontend and API share one host.

CSRF. CORS controls who may read a response, not who may send a request. SameSite=Lax treats sibling subdomains as the same site, so a page on any *.example.com host can make the browser send the cookie. Mount the refresh and logout handlers on POST routes only, and for routes that act on the cookie prefer a JSON body or a custom header, which a cross-site form cannot send without a CORS preflight. The refresh answer is unreadable to an origin CORS does not allow, and a forced logout is the worst a cross-site POST to the logout route can do.

Guards and middleware

A guard implements bouncer.Guard: it turns a request into a principal or an error. bouncer.NewJWTGuard is the frontend guard. It reads the bearer token, then any cookie names you give it, verifies the token, checks the blacklist and the user's cutoff, and loads the user through your bouncer.UserProvider. On failure it answers 401 with {"error":true,"message":...}. bouncer.NewBackendJWTGuard is the same for the admin audience.

The admin API's backend guard is composite: an Authorization: Bearer value that starts with g15_ is looked up as a personal API token (see API tokens); every other request uses the unchanged JWT path (Bearer JWT, then the admin cookie). A g15_ value is never read from a cookie or the query string.

Register guards in a bouncer.Registry under a name, and turn one into middleware with bouncer.Registry.Middleware. The middleware stores the principal on the context, where handlers read it with bouncer.User:

guards := bouncer.NewRegistry()
guard := bouncer.NewJWTGuard(testSecret, users{}, bouncer.NewMemoryBlacklist(), "token")
if err := guards.Register("acme.blog", "acme.auth", guard); err != nil {
	fmt.Println(err)
	return
}
auth, err := guards.Middleware("acme.auth")
if err != nil {
	fmt.Println(err)
	return
}
me := auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	user, _ := bouncer.User(r.Context())
	fmt.Fprintf(w, "user %d, locale %s", user.ID, user.PreferredLocale)
}))

token, _, _ := bouncer.Mint(testSecret, "42", "http://127.0.0.1:8080/api/login", time.Hour)
for _, set := range []func(*http.Request){
	func(r *http.Request) {},
	func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) },
	func(r *http.Request) { r.AddCookie(&http.Cookie{Name: "token", Value: token}) },
} {
	req := httptest.NewRequest("GET", "/api/me", nil)
	set(req)
	rec := httptest.NewRecorder()
	me.ServeHTTP(rec, req)
	fmt.Println(rec.Code, strings.TrimSpace(rec.Body.String()))
}
// Output:
// 401 {"error":true,"message":"Token not provided"}
// 200 user 42, locale pl
// 200 user 42, locale pl

To protect routes, return that middleware from pact.HasMiddleware under a name and put the name on a route group. Routing shows the complete auth group. A guard that does not implement bouncer.UnauthorizedWriter lets an unauthenticated request through without a principal, for routes that behave differently for guests.

A guard that resolves more than a user, such as an API token record, implements bouncer.CredentialGuard; the middleware stores that record too, and handlers read it with bouncer.Credential.

Accepting admin tokens on plugin routes

Plugin routes that should accept an admin JWT or a personal API token call cabana.ResolveBackend. It reads only the Authorization Bearer header (never the admin cookie), applies the token's method filter, and for a scoped token requires a method-allowed scope that is full or matches every cover code you pass. On success it returns the request with bouncer.WithUser (and bouncer.WithCredential for a token). Failures are cabana.ErrUnauthenticated or cabana.ErrTokenScope. Routes registered before cabana.Activate must call it per request, because the resolver is published at activation.

handler := func(app *backpack.App, w http.ResponseWriter, r *http.Request) {
	_, _, err := cabana.ResolveBackend(app, r, "acme.blog.*")
	if err != nil {
		if errors.Is(err, cabana.ErrTokenScope) {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusForbidden)
			fmt.Fprint(w, `{"error":"Insufficient permissions"}`)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusUnauthorized)
		fmt.Fprint(w, `{"error":"Authentication required"}`)
		return
	}
	w.WriteHeader(http.StatusOK)
}

rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/_blog/api/v1/posts", nil)
r.Header.Set("Authorization", "Bearer unused")
handler(nil, rec, r)
fmt.Println(rec.Code, rec.Body.String())
// Output:
// 401 {"error":"Authentication required"}

Passwords

bouncer.HashPassword hashes with bcrypt at the cost you give it, bouncer.CheckPassword compares in constant time, and bouncer.NeedsRehash reports a hash made below your configured cost. WinterCMS stores bcrypt hashes too, so existing passwords keep working:

hash, err := bouncer.HashPassword(10, "correct horse battery staple")
if err != nil {
	fmt.Println(err)
	return
}
fmt.Println(bouncer.CheckPassword(hash, "correct horse battery staple"))
fmt.Println(bouncer.CheckPassword(hash, "wrong"))
// After raising the configured cost, rehash on the next successful login.
fmt.Println(bouncer.NeedsRehash(hash, 12))
// Output:
// true
// false
// true

Rehash a password on the next successful login when bouncer.NeedsRehash reports true.

Admin sign-in, admin permissions and the admin user commands are covered in Users and permissions.