feat(10-01): harden the admin cookie session and prefix boot guards

- refresh and logout read the Bearer header first, then the summer_admin
  cookie; a cookie refresh rotates the cookie without a token in the body and
  logout always expires the cookie
- backend.cookie_secure (default true) may drop Secure outside production only
- activation rejects controller vendor segments api, assets, login, settings
- BuildRouter rejects non-cabana routes at or under the admin prefix
- SPA single-flights refresh on 401, replays once, and refreshes proactively
  at 80 percent of expires_in; dist rebuilt
- scripts/check-admin-dist.sh rebuilds the SPA and fails on dist drift
- tests: TestPhase10CookieAuth, TestPhase10CSRF, TestPhase10Prefix,
  TestPhase10AdminPrefixCollision, boardwalk serving and header tests
This commit is contained in:
Jakub Zych
2026-09-27 15:34:19 +02:00
parent 5f9353841b
commit dafdb18234
15 changed files with 1283 additions and 18 deletions

View File

@@ -194,19 +194,28 @@ func cookieLoginData(ttl time.Duration) AdminLoginData {
// Max-Age is the refresh window, because refresh accepts an expired access
// token until iat plus refresh_ttl.
func (s *service) writeSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
http.SetCookie(w, s.sessionCookie(token, int(s.refreshTTL/time.Second)))
}
// expireSessionCookie tells the browser to drop the admin cookie.
func (s *service) expireSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, s.sessionCookie("", -1))
}
func (s *service) sessionCookie(value string, maxAge int) *http.Cookie {
return &http.Cookie{
Name: AdminCookieName,
Value: token,
Value: value,
Path: s.adminPrefix(),
MaxAge: int(s.refreshTTL / time.Second),
MaxAge: maxAge,
HttpOnly: true,
Secure: true,
Secure: !s.insecureCookie,
SameSite: http.SameSiteStrictMode,
})
}
}
func (s *service) refresh(w http.ResponseWriter, r *http.Request) {
raw := bearerToken(r)
raw, fromCookie := sessionToken(r)
if raw == "" {
s.logAuth(r, "failed", 0)
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
@@ -219,14 +228,22 @@ func (s *service) refresh(w http.ResponseWriter, r *http.Request) {
return
}
s.logAuth(r, "success", 0)
if fromCookie {
// A cookie-authenticated request never receives a token in its body.
s.writeSessionCookie(w, next)
WriteData(w, http.StatusOK, cookieLoginData(s.ttl), map[string]any{})
return
}
WriteData(w, http.StatusOK, map[string]string{
"access_token": next,
"token_type": "bearer",
}, map[string]any{})
}
// logout blacklists the presented token's jti and always expires the admin
// cookie, so a browser session ends even when only the Bearer was revoked.
func (s *service) logout(w http.ResponseWriter, r *http.Request) {
raw := bearerToken(r)
raw, _ := sessionToken(r)
_, iat, exp, jti, err := bouncer.VerifyClaimsAudience(raw, s.secret, bouncer.AudienceBackend)
if err != nil || jti == "" {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
@@ -247,9 +264,27 @@ func (s *service) logout(w http.ResponseWriter, r *http.Request) {
id = principal.ID
}
s.logAuth(r, "success", id)
s.expireSessionCookie(w)
WriteData(w, http.StatusOK, map[string]any{"status": "logged_out"}, map[string]any{})
}
// sessionToken returns the admin JWT the same way the backend guard reads it:
// the Authorization Bearer header first, then the summer_admin cookie.
func sessionToken(r *http.Request) (token string, fromCookie bool) {
if raw := bearerToken(r); raw != "" {
return raw, false
}
if r == nil {
return "", false
}
if c, err := r.Cookie(AdminCookieName); err == nil {
if raw := strings.TrimSpace(c.Value); raw != "" {
return raw, true
}
}
return "", false
}
func (s *service) me(w http.ResponseWriter, r *http.Request) {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil {
@@ -425,6 +460,22 @@ func adminLoginWindow(app *backpack.App) (int, int) {
return maxAttempts, decayMinutes
}
// adminCookieSecure reads backend.cookie_secure (default true). false drops
// the Secure attribute for plain-http development and is refused in the
// production environment.
func adminCookieSecure(app *backpack.App) (bool, error) {
if app == nil || app.Config == nil || !app.Config.Has("backend.cookie_secure") {
return true, nil
}
if app.Config.Bool("backend.cookie_secure") {
return true, nil
}
if strings.EqualFold(strings.TrimSpace(app.Config.Environment()), "production") {
return false, errors.New("cabana: backend.cookie_secure: false is not allowed in the production environment")
}
return false, nil
}
// adminIssuer is app.url plus the admin API login path. JWT verification does
// not check iss, so tokens minted under an earlier prefix stay valid until
// they expire.