diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index e6a560e..fb266b1 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -277,7 +277,7 @@ Plans: Plans: **Wave 1** -- [ ] 07-01-PLAN.md — bouncer JWT lifecycle, password hashing, I18N-02 locale-from-principal, lagoon.Validate extensions +- [x] 07-01-PLAN.md — bouncer JWT lifecycle, password hashing, I18N-02 locale-from-principal, lagoon.Validate extensions **Wave 2** *(blocked on 07-01)* @@ -448,7 +448,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → | 4. CLI scaffolding, i18n and mail | 4/4 | Complete | 2026-09-18 | | 5. Data layer full fidelity | 6/6 | Complete | 2026-09-18 | | 6. HTTP routing, auth groups and rate limiting | 14/14 | Complete | 2026-09-21 | -| 7. User plugin and authentication | 0/TBD | Not started | - | +| 7. User plugin and authentication | 1/6 | In Progress| | | 8. OAuth2.1 authorization server | 0/TBD | Not started | - | | 9. Backend admin authentication and schema pipeline | 0/TBD | Not started | - | | 10. Admin Vue SPA | 0/TBD | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index 7940628..f306ab0 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,14 +3,14 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: milestone status: executing -stopped_at: Phase 7 context gathered -last_updated: "2026-09-22T10:39:04.511Z" -last_activity: 2026-09-22 -- Phase 7 planning complete +stopped_at: Completed 07-01-PLAN.md +last_updated: "2026-09-22T11:43:03.651Z" +last_activity: 2026-09-22 progress: total_phases: 15 completed_phases: 6 total_plans: 43 - completed_plans: 37 + completed_plans: 38 percent: 40 --- @@ -21,16 +21,16 @@ progress: See: .planning/PROJECT.md (updated 2026-09-16) **Core value:** An existing WinterCMS-shaped app can be ported plugin by plugin to a single Go binary without its frontend noticing: the PHP version's API contract is the acceptance test. -**Current focus:** Phase 7 — user plugin and authentication +**Current focus:** Phase 07 — user-plugin-and-authentication ## Current Position -Phase: 7 -Plan: Not started +Phase: 07 (user-plugin-and-authentication) — EXECUTING +Plan: 2 of 6 Status: Ready to execute -Last activity: 2026-09-22 -- Phase 7 planning complete +Last activity: 2026-09-22 -Progress: [██████████] 100% +Progress: [█████████░] 88% ## Performance Metrics @@ -81,6 +81,7 @@ Progress: [██████████] 100% | Phase 06 P09 | 4 min | 1 tasks | 2 files | | Phase 06 P10 | 3h 15m | 1 tasks | 2 files | | Phase 06 P11 | 12h 30m | 1 tasks | 1 files | +| Phase 07 P01 | 12 min | 3 tasks | 20 files | ## Accumulated Context @@ -191,6 +192,7 @@ Recent decisions affecting current work: - [Phase 06]: Assert exact denial bytes before JSON shape checks — Whitespace normalization would hide response-contract regressions. - [Phase 06]: Retain all four earlier accepted risks unchanged; T-06-23 through T-06-27 are mitigated, not accepted or deferred. — Both repositories' authoritative race and vet gates passed, and each new threat has concrete source and named regression evidence. - [Phase 06]: Anonymous inline limiter identity is documented only as inline:domainless|, excluding policy text and request or forwarded Host inputs. — The production resolver and three executed regressions prove Host rotation and inline-parameter changes cannot create fresh anonymous budgets while authenticated principals keep isolated u: keys. +- [Phase 07]: Blacklist storage expiry follows PHP jwt-auth (later of exp and iat+refreshTTL, plus one minute). — Using the raw access exp would drop a logged-out token that is still inside the refresh window. ### Pending Todos @@ -212,6 +214,6 @@ Items acknowledged and carried forward from previous milestone close: ## Session Continuity -Last session: 2026-09-21T22:24:22.108Z -Stopped at: Phase 7 context gathered -Resume file: .planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md +Last session: 2026-09-22T11:42:50.114Z +Stopped at: Completed 07-01-PLAN.md +Resume file: None diff --git a/.planning/phases/07-user-plugin-and-authentication/07-01-SUMMARY.md b/.planning/phases/07-user-plugin-and-authentication/07-01-SUMMARY.md new file mode 100644 index 0000000..08b8eed --- /dev/null +++ b/.planning/phases/07-user-plugin-and-authentication/07-01-SUMMARY.md @@ -0,0 +1,126 @@ +--- +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.