diff --git a/modules/bouncer/README.md b/modules/bouncer/README.md index 8ca9907..8a68673 100644 --- a/modules/bouncer/README.md +++ b/modules/bouncer/README.md @@ -13,7 +13,7 @@ bouncer decides who is making a request. Guards (`bouncer.Guard`, `bouncer.Crede - 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. +- 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). diff --git a/modules/bouncer/jwt.go b/modules/bouncer/jwt.go index 94e60f6..b0f60ee 100644 --- a/modules/bouncer/jwt.go +++ b/modules/bouncer/jwt.go @@ -366,6 +366,9 @@ func mapJWTError(err error) error { func write401(w http.ResponseWriter, message string) { w.Header().Set("Content-Type", "application/json") + // The reference backend (Laravel) sends this on every response, and a + // replayed 401 compares it. + w.Header().Set("Cache-Control", "no-cache, private") w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]any{"error": true, "message": message}) } diff --git a/modules/bouncer/jwt_test.go b/modules/bouncer/jwt_test.go index 149fd25..e97d8d1 100644 --- a/modules/bouncer/jwt_test.go +++ b/modules/bouncer/jwt_test.go @@ -99,6 +99,9 @@ func TestMiddlewareStatusBodies(t *testing.T) { if rec.Header().Get("X-Hit") != "" { t.Fatal("handler ran") } + if got := rec.Header().Get("Cache-Control"); got != "no-cache, private" { + t.Fatalf("Cache-Control = %q", got) + } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err)