--- phase: 07-user-plugin-and-authentication plan: 01 subsystem: auth tags: [jwt, bcrypt, blacklist, locale, validation] requires: - phase: 06-http-routing-auth-groups-and-rate-limiting provides: Bearer JWT guard, Principal, lagoon.Validate, surf middleware registration provides: - bouncer.Mint, Refresh, BlacklistStore, VerifyClaims - bcrypt HashPassword/CheckPassword/NeedsRehash - Principal.PreferredLocale and TokensValidAfter - surf locale.from-principal middleware - lagoon email, confirmed, different, and mimes rules affects: [07-02, 07-03, 07-04] tech-stack: added: [golang.org/x/crypto v0.57.0] patterns: [HS256 mint with hardcoded prv hash, refresh without exp validation, grace-windowed jti blacklist] key-files: created: - bouncer/mint.go - bouncer/refresh.go - bouncer/blacklist.go - bouncer/password.go - surf/locale_from_principal.go modified: - bouncer/jwt.go - bouncer/context.go - surf/router.go - lagoon/validate.go - go.mod key-decisions: - "Blacklist storage expiry follows PHP jwt-auth: later of exp and iat+refreshTTL, plus one minute" - "A blacklisted jti reuses the existing bad-signature 401 text" - "Refresh rebuilds the access TTL from the old token's exp-iat because the signature has no separate ttl argument" - "golang.org/x/crypto was promoted with go get @latest (v0.57.0) after the human checkpoint" patterns-established: - "Pattern: Mint stamps iss from the calling endpoint URL and prv from the hardcoded User class hash" - "Pattern: only Refresh uses jwt.WithoutClaimsValidation; Verify and the guard still require exp" requirements-completed: [AUTH-01, I18N-02] duration: 12min completed: 2026-09-22 --- # Phase 7 Plan 01: Framework auth primitives Summary **JWT mint, sliding refresh, and a grace-windowed jti blacklist, plus bcrypt, a post-auth locale override, and email/confirmed/different/mimes validation.** ## Performance - **Duration:** 12 min - **Started:** 2026-09-22T11:28:00Z - **Completed:** 2026-09-22T11:39:34Z - **Tasks:** 3 - **Files modified:** 20 ## Accomplishments - `bouncer.Mint` / `Refresh` / `BlacklistStore` / `VerifyClaims` are in place for the user plugin's login, refresh, and logout handlers. - `Principal` now carries `PreferredLocale` and `TokensValidAfter`, and `surf` registers `locale.from-principal`. - `lagoon.Validate` accepts `email`, `confirmed`, `different:field`, and `mimes:list`. `golang.org/x/crypto` is a direct dependency, and a real PHP `$2y$` hash verifies. ## Task Commits 1. **Task 1: Approve golang.org/x/crypto** — human checkpoint, approved. Promotion landed in the Task 3 commit. 2. **Task 2: JWT lifecycle primitives** — `251f3cc` (test), `cad445a` (feat) 3. **Task 3: Password hashing, locale override, validation** — `bccd7f8` (test), `8fcaff7` (feat) ## Files Created/Modified - `bouncer/mint.go` — HS256 mint with the hardcoded `prv` hash - `bouncer/refresh.go` — sliding refresh that skips `exp` and blacklists the old jti - `bouncer/blacklist.go` — memory and Postgres stores with a grace window - `bouncer/password.go` — bcrypt hash, check, and rehash - `bouncer/jwt.go` — cookie fallback, blacklist check, `TokensValidAfter` cutoff, `VerifyClaims` - `bouncer/context.go` — `PreferredLocale` and `TokensValidAfter` - `surf/locale_from_principal.go` — post-auth locale override - `surf/router.go` — registers `locale.from-principal` - `lagoon/validate.go` — `email`, `confirmed`, `different`, `mimes` - `go.mod` — direct `golang.org/x/crypto v0.57.0` ## Decisions Made Blacklist rows live until the later of the old `exp` and `iat+refreshTTL`, plus one minute, matching PHP `Blacklist::getMinutesUntilExpired`. A blacklisted token returns the existing "Token Signature could not be verified." body. `Refresh` copies the previous access lifetime (`exp-iat`) onto the new token. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] Blacklist storage expiry was the raw access `exp`** - **Found during:** Task 2 (JWT lifecycle primitives) - **Issue:** The plan set `expiresAt` to the old token's `exp`. For a token that is already expired but still inside `refreshTTL`, that timestamp is in the past, so lazy expiry and `Sweep` would drop the row and a logged-out token could be refreshed again. - **Fix:** Storage expiry is the later of `exp` and `iat+refreshTTL`, plus one minute. `validUntil` is still `now+grace`. - **Files modified:** `bouncer/refresh.go` - **Verification:** `TestRefreshBlacklistsOldJTI` (expired access token, grace 0, still blacklisted; grace window still open otherwise) - **Committed in:** `cad445a` --- **Total deviations:** 1 auto-fixed (Rule 1) **Impact on plan:** Correctness fix so logout and refresh revocation survive the refresh window. No new API surface. ## Issues Encountered None ## User Setup Required None - no external service configuration required. ## Next Phase Readiness Ready for 07-02. The user plugin can import `Mint`, `Refresh`, `NewPostgresBlacklist`, `HashPassword`, and the new `Principal` fields. AUTH-01 and I18N-02 are not fully delivered yet: the session routes, locale endpoints, and must-change-password exemption are still 07-02 through 07-04. ## Self-Check: PASSED - `bouncer/mint.go`, `bouncer/refresh.go`, `bouncer/blacklist.go`, `bouncer/password.go`, and `surf/locale_from_principal.go` exist. - `git log --oneline --grep=07-01` shows the test and feat commits above. - `go vet ./...` and `go test ./... -short` passed. `go test ./bouncer/... ./surf/... ./lagoon/... -race -short` passed.