docs(quick-261022-tto): user-plugin session routes accept the session cookies

This commit is contained in:
Jakub Zych
2026-10-22 21:52:03 +02:00
parent b70abe48e6
commit 98a95c9c92
4 changed files with 476 additions and 4 deletions

View File

@@ -5,10 +5,10 @@ current_phase: "19.7"
current_phase_name: Generic MCP module (INSERTED)
status: executing
stopped_at: Completed 19.7-01-PLAN.md
last_updated: "2026-10-22T19:32:24.420Z"
last_updated: "2026-10-22T19:51:56.252Z"
last_activity: 2026-10-22
last_activity_desc: Phase 19.7 execution started
state_head: cb33c497be31f678b474e71c8c4e3fbed3baa44e
last_activity_desc: "Completed quick task 261022-tto: user-plugin session routes accept the token/auth_token cookies"
state_head: b70abe48e6c5b5df1a0f26096379e2ca62cafcf1
progress:
total_phases: 45
completed_phases: 37
@@ -32,7 +32,7 @@ See: .planning/PROJECT.md (updated 2026-10-22)
Phase: 19.7 (Generic MCP module (INSERTED)) — EXECUTING
Plan: 2 of 5
Status: Ready to execute
Last activity: 2026-10-22 — Phase 19.7 execution started
Last activity: 2026-10-22 — Completed quick task 261022-tto: user-plugin session routes accept the token/auth_token cookies
Progress: [████████░░] 82%
@@ -1045,6 +1045,7 @@ Recent decisions affecting current work:
| 261022-4z8 | Fix the open 19.8 re-review findings (WR-09..WR-14, IN-14, IN-15) | 2026-10-22 | b1fba7e | [261022-4z8-fix-the-open-19-8-re-review-findings-wr-](./quick/261022-4z8-fix-the-open-19-8-re-review-findings-wr-/) |
| 261022-k9k | Admin keyboard shortcuts: Ctrl+S, Ctrl+Enter, Ctrl+Shift+S (Create and new), / search, Esc, ? help | 2026-10-22 | 604fee7 | [261022-k9k-keyboard-shortcuts-in-the-admin-spa-ctrl](./quick/261022-k9k-keyboard-shortcuts-in-the-admin-spa-ctrl/) |
| 261022-tb4 | Password /login refuses a not-activated account while require_activation is on (sm-user-plugin 15ff979, unreleased: hold until the escalation batch lands) | 2026-10-22 | 15ff979 (sm-user-plugin) | [261022-tb4-password-login-must-refuse-is-activated-](./quick/261022-tb4-password-login-must-refuse-is-activated-/) |
| 261022-tto | User plugin /fetch, /refresh, /update, /change-password accept token/auth_token session cookies (sm-user-plugin 8e4b863, ships v0.5.0) | 2026-10-22 | 8e4b863 (sm-user-plugin) | [261022-tto-fetch-refresh-update-change-password-mus](./quick/261022-tto-fetch-refresh-update-change-password-mus/) |
## Deferred Items

View File

@@ -0,0 +1,247 @@
---
phase: quick-261022-tto
plan: 01
quick_id: 261022-tto
slug: fetch-refresh-update-change-password-mus
type: execute
mode: quick
wave: 1
depends_on: []
autonomous: true
requirements:
- tto-1 # G3: GET /fetch, POST /refresh, POST /update, POST /change-password accept the token/auth_token session cookies exactly as jwt.auth and EndSession do; bearer keeps precedence; no credentials stays 401; Logout unchanged
files_modified:
# sm-user-plugin (module golem15.user), worked in the sm-demo-app submodule checkout
- ../sm-demo-app/plugins/golem15/user/classes/session_user.go
- ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
- ../sm-demo-app/plugins/golem15/user/session_cookie_test.go
- ../sm-demo-app/plugins/golem15/user/logout_test.go
- ../sm-demo-app/plugins/golem15/user/README.md
# summercms.go planning docs (separate docs commit)
- .planning/notes/2026-10-22-external-user-plugin-escalations.md
- .planning/todos/pending/2026-10-22-user-session-routes-ignore-cookie.md
- .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md
estimate:
tokens: 120000
raw_tokens: 120000
tasks: 3
confidence: low # estimate-calibration: factor 1, sample_count 0
must_haves:
truths:
- "A request that carries the session JWT only in the token cookie, or only in the auth_token cookie, and that the jwt.auth guard accepts (GET /_user/api/v1/tokens answers 200) gets 200 from GET /fetch (that user's payload), POST /refresh (a new token in the body), POST /update (the profile is saved) and POST /change-password (the password is replaced)."
- "A request with neither an Authorization bearer nor a session cookie still gets 401 with today's bodies: {\"error\":true,\"message\":\"Unauthorized\"} from /fetch, /update and /change-password, {\"error\":\"Token not found\"} from /refresh."
- "The bearer header keeps precedence exactly as in the jwt guard: with a valid bearer and a cookie of another user the bearer's user is served; with a non-empty invalid bearer and a valid cookie the answer is 401 on these routes as on jwt.auth."
- "A cookie-carried token is refused on these routes for the same reasons as a bearer one: blacklisted by logout, issued before tokens_valid_after, user missing or deactivated; /refresh keeps its c3712dc refusals (deactivated, banned, cut off) with the same 401 bodies."
- "POST /refresh answers a cookie-carried request with the rotated token in the body only and sets no cookie (PHP parity); the old token is blacklisted, so the caller that wrote the cookie stores the new token. Bearer-only clients see no change on any route, and POST /logout and classes.EndSession are untouched."
- "One extractor: the controllers read the session token only through classes.SessionToken and build the session guard with classes.SessionCookieNames(); the controllers' private bearer-header helper is gone."
- "The plugin's go vet, go test -short and the full Docker-backed go test are green at one fix commit on sm-user-plugin master; nothing is pushed, tagged or pointer-bumped (SummerCMS v0.5.0 release hold); the escalation note's G3 row reads done (unreleased) with that commit -> v0.5.0 and its reply line follows the G2 'Ships in SummerCMS v0.5.0' form."
artifacts:
- path: ../sm-demo-app/plugins/golem15/user/classes/session_user.go
provides: "SessionToken(r) (exported form of the existing extractor, RequestUser updated) and SessionCookieNames() (copy of the token, auth_token list)"
contains: "func SessionCookieNames"
- path: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
provides: "sessionGuard helper with the cookie names; authenticate, Refresh and ChangePassword read the token like the jwt guard"
contains: "SessionCookieNames()..."
- path: ../sm-demo-app/plugins/golem15/user/session_cookie_test.go
provides: "TestSessionRoutesAcceptCookies: cookie-only success on the four routes under both cookie names, parity with jwt.auth, no-credentials 401, bearer precedence, refusals through the cookie"
contains: "func TestSessionRoutesAcceptCookies"
- path: ../sm-demo-app/plugins/golem15/user/README.md
provides: "JWT-sessions bullet and API reference name the cookie carriers; classes list names SessionToken and SessionCookieNames; Testing entry"
contains: "TestSessionRoutesAcceptCookies"
key_links:
- from: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (sessionGuard, used by authenticate and ChangePassword)
to: ../sm-demo-app/plugins/golem15/user/classes/session_user.go (sessionCookieNames)
via: "bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, classes.SessionCookieNames()...), the same cookie list and order plugin.go:99 registers for jwt.auth"
pattern: "SessionCookieNames\\(\\)\\.\\.\\."
- from: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (Refresh, ChangePassword)
to: ../sm-demo-app/plugins/golem15/user/classes/session_user.go (SessionToken -> sessionTokens)
via: "raw token read through the extractor EndSession and RequestUser already use (bearer, then token, then auth_token)"
pattern: "classes\\.SessionToken\\(r\\)"
- from: ../sm-demo-app/plugins/golem15/user/session_cookie_test.go
to: GET /_user/api/v1/tokens (jwt.auth)
via: "each cookie subtest first asserts e.guarded(t, token, name) == 200, so 'a caller jwt.auth accepts' is checked, not assumed"
pattern: "e\\.guarded\\(t, "
---
<objective>
Close escalation G3 for the remaining four routes. GET /fetch, POST /refresh, POST /update and POST /change-password of the user plugin answer 401 to a caller whose session JWT travels only in the token or auth_token cookie, although the jwt.auth guard (plugin.go:99) and POST /logout (classes.EndSession, 358ee5d) accept those cookies. After this plan the four read the token exactly as the jwt guard does: the Authorization bearer header first, then the token cookie, then the auth_token cookie.
Purpose: a browser frontend whose session lives in a cookie (golem15.sociallogin's token_cookie, or an app's own bridge) can fetch, refresh, update and change the password without turning the cookie into a header.
Output: one fix commit on sm-user-plugin master (code, tests, README together), then one docs commit in summercms.go that fills G3 in the escalation note, records the outcome on the todo (which stays pending until the v0.5.0 release, as G2's does) and leaves a cross-reference on the login-cookie todo. No push, tag or pointer bump.
Decisions (planner's, within the brief; the executor does not revisit them):
- QD-1 One extractor. classes owns the cookie list and the extraction. Rename the unexported sessionToken in classes/session_user.go to the exported SessionToken (update its only caller, RequestUser) and add SessionCookieNames(), which returns a copy of sessionCookieNames. sessionTokens stays unexported (EndSession uses it). The controllers read the raw token only through classes.SessionToken and build the guard with classes.SessionCookieNames(). The controllers' private bearer-header helper (api_controller.go:1138) has exactly three callers, all changed here, so it is deleted. plugin.go:99 keeps its literal "token", "auth_token": escalation G4 (logout blacklist under serve) rewires those Boot lines next and the six quicks must not overlap on plugin.go; the parity subtests pin that the controllers and jwt.auth accept the same carriers. Record "plugin.go:99 could use classes.SessionCookieNames()" as a follow-up for the G4 session in the SUMMARY.
- QD-2 authenticate (api_controller.go:880). One call to a new helper sessionGuard(app, secret) bouncer.Guard (it looks up the published bouncer.BlacklistStore as today and returns bouncer.NewJWTGuard with the cookie names). authenticate takes the user id from the returned *bouncer.Principal and loads the user with the existing scoped Take. The separate bouncer.VerifyClaims of a bearer-only token is removed, so authenticate no longer fails closed on a missing header before the cookies are tried and can never read a different token than the guard verified. Every refusal still answers writeUnauthorized.
- QD-3 Refresh (api_controller.go:192). raw := classes.SessionToken(r); empty keeps the existing 401 {"error":"Token not found"}. Everything after it is unchanged (bouncer.Refresh, refreshSubjectRefused, TouchLastSeen), so the c3712dc refusals hold for cookie-carried tokens. The answer stays {"token": next} in the body and sets no cookie: the PHP reference (examples/golem15-wintercms-starter plugins/golem15/user/controllers/ApiController.php:115-130) answers the body only, and the cookie belongs to whoever wrote it (today the app's bridge or sociallogin's token_cookie). bouncer.Refresh blacklists the old jti after golem15.user.jwt.blacklist_grace (default 0), so the README says the caller stores the new token. Whether a server-written password-login cookie is rotated by /refresh (as bouncer.CookieRefreshHandler does) is left to the opt-in /login cookie todo, which gets a cross-reference in Task 3.
- QD-4 ChangePassword (api_controller.go:588). raw := classes.SessionToken(r); bouncer.VerifyClaims(raw, secret) gives sub and iat as today; then sessionGuard(app, secret).Authenticate(r) (blacklist, cutoff, missing user). If the principal's ID formatted as a decimal string differs from sub, answer writeUnauthorized (both read the same token by construction; the check makes a future divergence fail closed). Load the user by that id as today. The cutoff stays iat minus one second, so the calling token, bearer or cookie, stays valid. Response unchanged, no cookie written.
- QD-5 Scope. The contract is the four named routes. The shared authenticate also serves POST /activate, /marketing-consent, /avatar and /avatar/remove, which gain the same cookie carriers. That is intended: every signed-in route of the plugin then reads the token as jwt.auth does. The README names all of them; one /marketing-consent subtest pins the claim.
- QD-6 Unchanged: controllers.Logout, classes.EndSession, classes.RequestUser behaviour, sessionTokens' order and trimming, the 401 bodies, bearer-only behaviour on every route. bouncer's extractToken and classes.sessionTokens were compared at planning time: both take the header only with the exact "Bearer " prefix and a non-empty trimmed value, then each cookie in order with a trimmed non-empty value, so a non-empty invalid bearer wins over a valid cookie in both.
- QD-7 CSRF. No new mechanism. The four routes get the exposure POST /tokens (jwt.auth) and POST /logout already have: a cookie-carried session relies on the cookie's SameSite attribute (bouncer.LoadCookieConfig default lax, which a browser does not send on a cross-site POST). The README states this. /change-password still requires current_password.
- QD-8 Versioning and the release hold. A plugin release is a git tag (latest v0.1.8 = 930b158, which carries 358ee5d); there is no version file or updates entry. Since commit 3ebd783 (21:37 on 2026-10-22, observed at planning time) the escalation note holds every user-plugin fix for the SummerCMS v0.5.0 wave: plugin commits stay local, and no mid-wave plugin tag is written. G2 (15ff979) set the recording pattern this plan copies: tracker Status "done (unreleased)", Shipped "`sha` → v0.5.0", the Quick/todo cell links the quick directory and the todo, the reply line reads "Ships in SummerCMS v0.5.0 (sm-user-plugin sha, tagged with that wave): …", and the todo stays in todos/pending with an "## Outcome (2026-10-22)" section (76694c7, 3ebd783). The brief said to move the todo to done. The plan follows the newer G2 precedent instead, because G15OFFICE.md ties closing a todo to moving its task to Live, and the fix is not live until v0.5.0. This quick does not tag, push or bump.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/STATE.md
@.planning/todos/pending/2026-10-22-user-session-routes-ignore-cookie.md
@.planning/notes/2026-10-22-external-user-plugin-escalations.md
@../sm-demo-app/plugins/golem15/user/README.md
Working copies (observed at planning time, 2026-10-22):
- sm-user-plugin: ../sm-demo-app/plugins/golem15/user (absolute /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user). On branch master at 15ff979 (G2 fix), ahead of origin/master by 1, working tree clean, latest tag v0.1.8. Do not fetch, merge, rebase or reset. If master has moved past 15ff979, another escalation quick landed: build on top of it and record the base and git log --oneline 15ff979..master in the SUMMARY. If the tree is dirty, stop: another session is working there.
- Go: sm-demo-app's go.work does not list the plugin under use, so every go command in the plugin runs with GOWORK=off. The plugin's go.mod:176 replaces summercms with ../../../../summercms.go (builds against summercms.go's working tree). GOWORK=off go vet ./... was green at 15ff979.
- Docker: docker info answered (server 29.9.1). The adminEnv harness (adminHarnessDB, admin_harness_test.go:93) skips under -short, so the new tests only run in the full go test. If the Docker socket is denied, run go test through sudo -u golem (CLAUDE.md).
- sm-demo-app (../sm-demo-app): another session has uncommitted edits (main.go and the plugins/golem15/user pointer). Leave that repo alone; no pointer bump.
- summercms.go: STATE.md and state.json carry other sessions' edits, .msd/ and the 261022-tb4 quick directory are untracked. Stage only the paths named in Task 3, never git add -A, never STATE.md or this quick directory. The escalation note was last changed by 3ebd783 (release hold) and 76694c7 (G2 filled); re-read it right before editing in Task 3, because another session may have filled another row since then. Edit only the G3 row and the G3 reply line.
Source facts (file:line in the plugin checkout at 15ff979):
- classes/session_user.go: sessionCookieNames :21 ("token", "auth_token", "same order as plugin.go Boot"); RequestUser :34 calls sessionToken :38; EndSession :101 loops sessionTokens :119; forgetSessionCookies :147; sessionToken :214-219; sessionTokens :224-240 (bearer with exact "Bearer " prefix, then each cookie, trimmed, empty skipped, deduplicated).
- controllers/api_controller.go: Logout :149-171 (unchanged); Fetch :174-187 (authenticate); Refresh :192-242 (bearer-only raw at :198, Token not found :199-202, bouncer.Refresh :207, refreshSubjectRefused :222, TouchLastSeen :235); refreshSubjectRefused :249-292; Activate :530, Update :524-529 (authenticate at :529), ChangePassword :586-670 (bearer-only raw :593, VerifyClaims :594, cookie-less guard :599-606, user Take :612, HasSelfSetPassword false answers 500 :616-620, cutoff iat minus 1s :656); MarketingConsent :693 (authenticate); authenticate :880-909 (bearer-only raw :885, VerifyClaims fails closed :886-890, cookie-less guard :896-903); sessionDeps :911; bearer-header helper :1138-1146 (callers :198, :593, :885 only); UploadAvatar :1256 and RemoveAvatar :1359 (authenticate); writeUnauthorized writes {"error":true,"message":"Unauthorized"}.
- plugin.go:99 registers the jwt guard as bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, "token", "auth_token"); routes.go: /fetch, /refresh, /update, /change-password are mounted without jwt.auth; /tokens routes use jwt.auth.
- summercms.go modules/bouncer/jwt.go: NewJWTGuard :86 (empty cookieNames = bearer only), Authenticate :106 returns *Principal (ID uint), extractToken :273-290, bearerToken :314-325; bouncer.CookieRefreshHandler (cookie.go:219) is the framework's cookie-rotating refresh, not used by the user plugin.
- Tests (package user, plugin root): logout_test.go consts unauthorizedRaw, blacklistedBody, userTokensPath, userLogoutPath, userRefreshPath :16-23; TestLogoutEndsTheSession :33; sessionLogin :207 (seedUser + userLogin, password "correct-horse"); guarded :219 (GET /tokens with bearer or only the named cookie); assertSessionEnded :229-242 (jwt.auth through every carrier, but /fetch and /refresh with the bearer only); userAudienceToken(t, sub, jti, iat, exp) in logout_test.go. admin_harness_test.go: newAdminEnv :131, call :242, send :256 (method, path, bearer token, raw body, content type, extra headers such as Cookie), seedUser :468 (IsActivated true, password "correct-horse", HasSelfSetPassword left false), seedThrottle :567, userLogin :637. offboarding_test.go: fetchCode :45, refresh :50, assertRefreshRefused and refusedRefreshBody :16 ({"error":"Could not refresh token","msg":"User not found"}); its "banned" and "issued before tokens_valid_after" subtests :163-176 show the refusal setup. session_test.go parseToken :644. Names sessionGuard, cookieSend, SessionToken, SessionCookieNames and TestSessionRoutesAcceptCookies are unused today.
- README.md: JWT-sessions bullet :20, Password reset and change :21, Profile :25, API reference table :86-108, classes list with EndSession and RequestUser :189-190, Testing section :244 (TestLogoutEndsTheSession sentence :259).
- Consumers: no local frontend calls the user /refresh with cookies; every sociallogin config has token_cookie false. Other app checkouts of the plugin (sm-grzybyfunkcjonalne-app, sm-summercmsio-app, fonoteka.go) are pinned separately and not touched.
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1 (tracer): GET /fetch accepts the session cookies through classes.SessionToken and a cookie-aware guard, end to end on Postgres</name>
<files>../sm-demo-app/plugins/golem15/user/classes/session_user.go, ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go, ../sm-demo-app/plugins/golem15/user/session_cookie_test.go</files>
<precondition>Docker is reachable for testcontainers (directly or through sudo -u golem), git -C ../sm-demo-app/plugins/golem15/user status --short is empty and its branch is master.</precondition>
<behavior>
New session_cookie_test.go (package user) with TestSessionRoutesAcceptCookies on one newAdminEnv. Add an adminEnv helper cookieSend(t, method, path, cookie, token string, body any) that marshals body (nil means no body) and calls e.send with no bearer, Content-Type application/json and only the header Cookie: cookie=token. Each subtest seeds its own user (labels prefixed "cookie-"), so subtests do not share sessions.
- fetch via the token cookie and via the auth_token cookie (loop over both names): token := e.sessionLogin; e.guarded(t, token, name) is 200 (jwt.auth accepts it); GET /_user/api/v1/fetch with only that cookie is 200 and the body's user.id equals the sub claim of parseToken(t, token).
- fetch without credentials: no header, no cookie: 401 and the body equals unauthorizedRaw.
- bearer wins: tokens A and B of two users; bearer A plus cookie token=B answers 200 with A's id. A non-empty invalid bearer (the string not-a-token) plus a valid cookie token=B answers 401 on /fetch, and the same request to userTokensPath (jwt.auth) is 401 too.
- cutoff through the cookie: a seeded user with tokens_valid_after set to now (truncated to the second) and a token from userAudienceToken with iat ten seconds earlier: GET /fetch with that token only in the auth_token cookie is 401 (the guard runs on the cookie path).
</behavior>
<action>
RED: write the behavior block's subtests. Run them; the cookie subtests must fail against today's code (authenticate answers 401 to a cookie-only request). Do not commit a red test: the project keeps go test green at every commit, and the single commit comes at the end of Task 2.
GREEN:
- classes/session_user.go, per QD-1: rename sessionToken to SessionToken and update RequestUser. Its doc comment says it returns the session JWT the plugin's "jwt" guard reads from r (the Authorization bearer header, then each cookie of SessionCookieNames in order, trimmed, empty values skipped) or "" when r carries none, and that a controller building its own guard passes SessionCookieNames() so both read the same token. Add SessionCookieNames() []string returning slices.Clone(sessionCookieNames), with a doc comment naming plugin.go Boot's jwt guard as the list's other user. Keep sessionTokens, EndSession and forgetSessionCookies unchanged.
- controllers/api_controller.go, per QD-2: add sessionGuard(app *backpack.App, secret string) bouncer.Guard next to authenticate. It looks up bouncer.BlacklistStore as the two current call sites do and returns bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, classes.SessionCookieNames()...). Rewrite authenticate: after sessionDeps, call sessionGuard(app, secret).Authenticate(r); an error, a nil principal or ID 0 answers writeUnauthorized; then load models.User by the principal's ID with the existing scoped Take (failure answers writeUnauthorized). Remove authenticate's bouncer.VerifyClaims step. Give authenticate a doc comment: it reads the token as the jwt guard does (bearer, then the token and auth_token cookies), applies the blacklist and tokens_valid_after, and answers 401 otherwise; it serves /fetch, /update, /activate, /marketing-consent, /avatar and /avatar/remove (QD-5).
- Leave Refresh and ChangePassword for Task 2. The bearer-header helper still has two callers after this task; it is deleted in Task 2.
Run TestSessionRoutesAcceptCookies until green, then the existing TestLogoutEndsTheSession, TestOffboarding and TestRequestUser, which must stay green unchanged. No commit in this task.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && GOWORK=off go vet ./... && GOWORK=off go test -count=1 -run '^(TestSessionRoutesAcceptCookies|TestLogoutEndsTheSession|TestOffboarding|TestRequestUser|TestEndRequestSession)$' .</automated>
</verify>
<done>A cookie-only session that jwt.auth accepts gets 200 from GET /fetch under both cookie names; no credentials stays 401 with the same body; the bearer header wins over a cookie as in the guard; a cut-off token is refused through the cookie. classes exports SessionToken and SessionCookieNames; authenticate uses one cookie-aware guard call. Changes are uncommitted on sm-user-plugin master.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: /refresh, /update and /change-password through the cookies, logout regression across carriers, README, one plugin commit</name>
<files>../sm-demo-app/plugins/golem15/user/controllers/api_controller.go, ../sm-demo-app/plugins/golem15/user/session_cookie_test.go, ../sm-demo-app/plugins/golem15/user/logout_test.go, ../sm-demo-app/plugins/golem15/user/README.md</files>
<precondition>Task 1 is done: TestSessionRoutesAcceptCookies (fetch subtests) passes on the uncommitted sm-user-plugin master.</precondition>
<behavior>
Added to TestSessionRoutesAcceptCookies (cookieSend from Task 1; every route subtest loops over both cookie names unless stated):
- refresh via the cookie: login token; e.guarded is 200; POST userRefreshPath with only the cookie answers 200 with a non-empty token different from the old one; the response sets no cookie (rec.Result().Cookies() is empty, QD-3); the new token answers 200 on /fetch as a bearer; the old token in the same cookie now answers 401 on /fetch (blacklisted, the harness has no blacklist_grace).
- refresh refusals through the cookie (token cookie): a banned user (e.seedThrottle(t, user, "", true, nil)) and a token issued before tokens_valid_after (the offboarding_test.go :163-176 setups, token from userAudienceToken) each get assertRefreshRefused.
- refresh without credentials: 401 and the body is exactly {"error":"Token not found"}.
- update via the cookie: seedUser plus userLogin; POST /_user/api/v1/update with only the cookie and body name "Cookie", surname "Carrier", email the user's current email answers 200; the users row's name is now Cookie.
- change-password via the cookie: seedUser with a mutate that sets HasSelfSetPassword true (otherwise the route answers 500 by design), plus userLogin; POST /_user/api/v1/change-password with only the cookie and current_password correct-horse, password and password_confirmation cookie-next-pass answers 200; the same cookie token still answers 200 on /fetch (the cutoff is iat minus one second); userLogin with cookie-next-pass answers 200.
- marketing-consent via the token cookie (QD-5 smoke): POST /_user/api/v1/marketing-consent with marketing_consent true answers 200.
- update and change-password without credentials: 401 and the body equals unauthorizedRaw.
logout_test.go assertSessionEnded: /fetch and /refresh are now checked through every carrier ("", "token", "auth_token"), like the jwt.auth check above them: /fetch 401, /refresh 401 with blacklistedBody. Add the cookie forms with e.send and a Cookie header; keep the existing bearer checks.
</behavior>
<action>
RED: add the behavior block's subtests and the assertSessionEnded extension. Run them; the refresh and change-password cookie subtests must fail against the Task 1 state (update and marketing-consent already pass through authenticate, which is expected).
GREEN, in controllers/api_controller.go:
- Refresh, per QD-3: read raw with classes.SessionToken(r); keep the empty-token 401 body and everything after it unchanged; set no cookie. Rewrite its doc comment: it slides a still-refreshable session token read as the jwt guard reads it (bearer, then the token and auth_token cookies), answers the rotated token in the body only and writes no cookie; the old token is blacklisted after golem15.user.jwt.blacklist_grace, so a caller that keeps the session in a cookie stores the new token; deactivated and banned users and tokens issued before tokens_valid_after are refused with the token-failure 401 body.
- ChangePassword, per QD-4: read raw with classes.SessionToken(r); keep the bouncer.VerifyClaims step for sub and iat; replace the blacklist lookup and the guard construction with sessionGuard(app, secret).Authenticate(r), keeping the principal; answer writeUnauthorized when the principal's ID, formatted with strconv.FormatUint, differs from sub; the rest is unchanged. Extend its doc comment: the token is read as the jwt guard reads it, and the token that made the call (header or cookie) stays valid.
- Delete the controllers package's private bearer-header helper (old :1138-1146); it has no callers left. No guard built without the cookie names may remain in the controllers package. Remove imports that become unused.
- Do not touch Logout, classes.EndSession, plugin.go or routes.go (QD-1, QD-6).
README.md, in the same commit as the code:
- JWT-sessions bullet (:20): add that GET /fetch, POST /refresh, POST /update and POST /change-password, like the other signed-in routes, read the session token as the jwt guard does (the bearer header first, then the token and auth_token cookies), so a cookie-only session works on them and a bearer header wins when both are sent; that a cookie-carried POST /refresh answers the rotated token in the body and writes no cookie, while the old token is blacklisted after golem15.user.jwt.blacklist_grace, so the caller that wrote the cookie stores the new token; and that a cookie-carried session relies on the cookie's SameSite attribute against cross-site requests (the auth.cookie default is lax), as on the jwt.auth routes (QD-7).
- API reference: one sentence under the route table: /fetch, /refresh, /update, /change-password, /activate, /marketing-consent, /avatar, /avatar/remove and the jwt.auth routes read the token from the bearer header, then the token and auth_token cookies.
- classes list (near EndSession and RequestUser, :189-190): SessionToken(r), the session JWT the jwt guard reads, and SessionCookieNames(), the cookie list to pass to bouncer.NewJWTGuard so a handler's own guard reads the same token.
- Testing: one sentence on TestSessionRoutesAcceptCookies and what it pins; update the TestLogoutEndsTheSession sentence (:259) so it says /fetch and /refresh are checked through every carrier.
- Every identifier the README names must exist in the plugin; config keys are checked by hand.
Then run go vet, go test -short and the full Docker-backed suite. A failure unrelated to this change is not patched here: stop and report it.
Commit in the plugin checkout, staging by explicit path: classes/session_user.go, controllers/api_controller.go, session_cookie_test.go, logout_test.go, README.md. Subject: fix: /fetch, /refresh, /update and /change-password accept the session cookies the jwt guard reads. Body, two or three plain lines: the four routes (and the other signed-in routes through authenticate) read the bearer header, then the token and auth_token cookies, through classes.SessionToken and one cookie-aware guard; a bearer header still wins and no credentials stays 401; a cookie-carried /refresh answers the token in the body and writes no cookie. No Co-Authored-By or any other trailer. No push, no tag, no sm-demo-app pointer bump.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && GOWORK=off go vet ./... && GOWORK=off go test -short -count=1 ./... && GOWORK=off go test -count=1 -run '^(TestSessionRoutesAcceptCookies|TestLogoutEndsTheSession|TestOffboarding|TestRequestUser|TestEndRequestSession)$' . && GOWORK=off go test -count=1 ./... && test "$(grep -c 'func bearerToken' controllers/api_controller.go)" = 0 && test "$(grep -c 'bl).Authenticate' controllers/api_controller.go)" = 0 && grep -q 'SessionCookieNames()\.\.\.' controllers/api_controller.go && test "$(grep -c 'classes.SessionToken(r)' controllers/api_controller.go)" -ge 2 && grep -q 'TestSessionRoutesAcceptCookies' README.md && grep -q 'SessionCookieNames' README.md && ST=$(git status --short) && test -z "$ST" && S=$(git log -1 --format=%s) && grep -q 'accept the session cookies the jwt guard reads' <<<"$S" && B=$(git log -1 --format=%b) && ! grep -qi 'co-authored-by' <<<"$B"</automated>
</verify>
<done>go vet, go test -short and the full Docker-backed plugin suite are green. TestSessionRoutesAcceptCookies covers cookie-only success on /fetch, /refresh, /update and /change-password under both cookie names, no-credentials 401, bearer precedence and the refusals through the cookie; assertSessionEnded checks /fetch and /refresh through every carrier. The plugin tree is clean with one new fix commit on master (code, tests, README), no trailer, nothing pushed or tagged.</done>
</task>
<task type="auto">
<name>Task 3: fill G3 in the escalation note the way G2 was filled, record the outcome on the todo, cross-reference the login-cookie todo (summercms.go docs commit)</name>
<files>.planning/notes/2026-10-22-external-user-plugin-escalations.md, .planning/todos/pending/2026-10-22-user-session-routes-ignore-cookie.md, .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md</files>
<precondition>Task 2's fix commit is the tip of sm-user-plugin master (git -C ../sm-demo-app/plugins/golem15/user log -1 --format=%s names the session cookies).</precondition>
<action>
Work in summercms.go (/media/nvme/dev/golem15/summercms.io/summercms/summercms.go). SHA below means the output of git -C ../sm-demo-app/plugins/golem15/user log -1 --format=%h master, written exactly as printed. Follow the G2 precedent from QD-8 (commits 76694c7 and 3ebd783). Re-read the note first and change only the G3 row and the G3 reply line.
1. Todo, .planning/todos/pending/2026-10-22-user-session-routes-ignore-cookie.md: it stays in todos/pending, because the release hold keeps it open until v0.5.0. Append an "## Outcome (2026-10-22)" section shaped like G2's: Quick `261022-tto`. Fix commit `SHA` on sm-user-plugin master (base `15ff979`). Add one sentence on what changed: the four routes, and the other signed-in routes through authenticate, read the bearer header, then the token and auth_token cookies, through classes.SessionToken and one cookie-aware guard; a cookie-carried /refresh answers the token in the body and writes no cookie. End with: Reply note G3 filled. Not tagged, not pushed: the v0.5.0 wave (these six quicks + admin keyboard shortcuts + MCP) is the first release.
2. Note, .planning/notes/2026-10-22-external-user-plugin-escalations.md:
- Tracker row 2 (G3). The Quick / todo cell becomes "[261022-tto](../quick/261022-tto-fetch-refresh-update-change-password-mus/) · [todo](../todos/pending/2026-10-22-user-session-routes-ignore-cookie.md)". Status becomes "done (unreleased)". Shipped becomes "`SHA` → v0.5.0". Same shapes as row 1.
- Under Reply to send, in the G3 paragraph, replace its TODO line with this one line: "Ships in SummerCMS v0.5.0 (sm-user-plugin SHA, tagged with that wave): GET /fetch, POST /refresh, POST /update and POST /change-password (and the other signed-in routes: /activate, /marketing-consent, /avatar, /avatar/remove) read the session token like jwt.auth: the Authorization header first, then the token and auth_token cookies. A cookie-carried /refresh returns the rotated token in the body and does not rewrite the cookie; the previous token is revoked, so store the new one in your cookie." Leave the paragraph's first two lines unchanged, and every other row and paragraph too.
3. In .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md, append a short "## Note from G3 (2026-10-22)" section. Since sm-user-plugin SHA, POST /refresh accepts the token and auth_token cookies, answers the rotated token in the body only and writes no cookie. When /login can set the HttpOnly cookie, decide in --discuss whether a cookie-carried /refresh rotates that cookie, as bouncer.CookieRefreshHandler does. Otherwise the blacklisted old token stays in an HttpOnly cookie the browser cannot rewrite.
4. Commit in summercms.go, staging exactly the three paths above. Message: docs: fill G3 in the external-escalation reply (the G2 commit's form). No trailer. Do not stage .planning/STATE.md, .planning/state.json, .msd/ or any .planning/quick/ directory; the quick workflow commits its own artifacts.
5. g15office (CLAUDE.md rule 4, .planning/G15OFFICE.md). If a g15office tool is available, comment on task kkknstxl: "Fixed in sm-user-plugin SHA (local, ships with SummerCMS v0.5.0)". Leave its status alone: the todo is not closed until the release. Otherwise record the comment as pending in the SUMMARY.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && N=.planning/notes/2026-10-22-external-user-plugin-escalations.md && T=.planning/todos/pending/2026-10-22-user-session-routes-ignore-cookie.md && S=$(git -C ../sm-demo-app/plugins/golem15/user log -1 --format=%h master) && test -f "$T" && grep -q "Outcome (2026-10-22)" "$T" && grep -q "$S" "$T" && grep -Eq "^\| 2 \| G3 .*261022-tto.*\| done \(unreleased\) \| .*$S.*v0\.5\.0" "$N" && ! grep -q 'those four accept the same cookies as jwt.auth' "$N" && grep -q "Ships in SummerCMS v0.5.0 (sm-user-plugin $S" "$N" && grep -Eq "^\| 1 \| G2 .*15ff979" "$N" && grep -q "$S" .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md && test -z "$(git status --short -- "$N" "$T" .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md)" && L=$(git log -1 --format=%s) && grep -q 'docs: fill G3 in the external-escalation reply' <<<"$L"</automated>
</verify>
<done>The G3 tracker row reads done (unreleased) with the quick and todo links and `SHA` → v0.5.0, as G2's does. The G3 reply line is in the "Ships in SummerCMS v0.5.0 (sm-user-plugin SHA, …)" form and describes the shipped behaviour. The todo stays in todos/pending with an Outcome section. The login-cookie todo carries the /refresh cross-reference. One docs commit in summercms.go holds exactly those three files. g15office has the comment, or it is recorded as pending.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser cookie jar -> signed-in user routes | a session JWT in the token or auth_token cookie now authenticates /fetch, /refresh, /update, /change-password (and /activate, /marketing-consent, /avatar, /avatar/remove) |
| other site -> state-changing POSTs | a cross-site request may carry the session cookie, depending on its SameSite attribute |
| request with several carriers -> controllers | bearer header and cookies may hold different tokens; the routes must pick the one the jwt guard picks |
## STRIDE Threat Register (ASVS L1, block on high)
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-tto-01 | E | cookie path skipping checks the bearer path enforces (blacklist after logout, tokens_valid_after, missing or deactivated user, /refresh ban and cutoff refusals) | high | mitigate | QD-2/QD-4: the cookie path goes through the same bouncer jwt guard (sessionGuard) as the bearer path, and Refresh keeps bouncer.Refresh plus refreshSubjectRefused; Task 1 cutoff subtest, Task 2 refresh-refusal subtests and assertSessionEnded through every carrier |
| T-tto-02 | S | carrier confusion: the user id taken from one token while the guard verified another (invalid bearer plus a valid cookie of another user) | high | mitigate | QD-1/QD-2: one extractor (classes.SessionToken) with the guard's order; authenticate uses the guard's principal, ChangePassword refuses when sub and principal differ; Task 1 "bearer wins" subtest asserts parity with jwt.auth |
| T-tto-03 | T | cross-site POST /update with a cookie-carried session (email change) | medium | accept | QD-7: same exposure as the existing jwt.auth POST /tokens and POST /logout; bouncer.LoadCookieConfig writes SameSite=Lax by default, which browsers do not send on cross-site POSTs; the README states the SameSite reliance; no new CSRF mechanism per the brief |
| T-tto-04 | T | cross-site POST /change-password | low | accept | Requires current_password; SameSite as T-tto-03 |
| T-tto-05 | D | cookie-only /refresh revokes the cookie's token without rewriting the cookie, ending a session whose writer does not store the body token (or a forced cross-site refresh under SameSite=None) | low | accept | QD-3: PHP parity and cookie ownership; README tells the caller to store the new token; the login-cookie todo gets the rotation decision (Task 3) |
| T-tto-06 | I | GET /fetch payload with a cookie on a cross-site navigation | low | accept | The browser sends a Lax cookie on a top-level GET, but another origin cannot read the JSON without a CORS grant (surf CORS policy unchanged) |
| T-tto-07 | E | /activate, /marketing-consent, /avatar, /avatar/remove gain cookie carriers through the shared authenticate | low | accept | QD-5: same guard and checks as jwt.auth; documented in the README; marketing-consent subtest pins it |
| T-tto-08 | T | unreviewed publish of a core plugin | medium | mitigate | No push, tag or pointer bump in this plan (QD-8, SummerCMS v0.5.0 release hold); the release stays a separate user decision |
| T-tto-SC | T | npm/pip/cargo/go installs | low | accept | No new dependency; only packages the plugin already imports (slices is stdlib) |
</threat_model>
<verification>
- sm-user-plugin (../sm-demo-app/plugins/golem15/user): GOWORK=off go vet ./..., GOWORK=off go test -short ./... and the full Docker-backed GOWORK=off go test -count=1 ./... green at the fix commit.
- Cookie-only success on /fetch, /refresh, /update and /change-password with both cookie names, each preceded by a jwt.auth 200 for the same cookie; no-credentials 401 with the unchanged bodies; bearer precedence; refusals through the cookie.
- The controllers package has no private bearer-header helper and no guard built without the cookie names; Logout, EndSession, plugin.go and routes.go are unchanged (git show --stat lists only the five Task 2 paths).
- summercms.go gets one docs commit with the note and the two pending todos only; no STATE.md, quick directory, admin/ or modules/ file. G2's row and reply line are unchanged.
- No push, tag, changelog post or sm-demo-app commit.
</verification>
<success_criteria>
- tto-1: a session that jwt.auth accepts through the token or auth_token cookie alone is served by GET /fetch, POST /refresh, POST /update and POST /change-password; no credentials still answers 401; the bearer header keeps precedence; Logout is unchanged.
- Existing bearer clients see byte-identical behaviour on every route, and the full plugin suite passes.
- G3 is filled in the escalation note in the G2 form (done (unreleased), `sha` → v0.5.0, "Ships in SummerCMS v0.5.0" reply line). The todo records the outcome and stays pending until the release. The login-cookie todo knows about /refresh cookie rotation.
</success_criteria>
<output>
Create `.planning/quick/261022-tto-fetch-refresh-update-change-password-mus/261022-tto-SUMMARY.md` when done. It records the plugin base commit and the fix sha; the subtests added and the assertSessionEnded change; the docs commit sha; follow-ups: plugin.go:99 could use classes.SessionCookieNames() in the G4 session; the plugin tag and app pointer bumps for 15ff979 and this fix go out with SummerCMS v0.5.0, and the todo moves to done then; other apps' plugin checkouts are not bumped. Also record the g15office status, and that the todo stays pending under the release hold (a deviation from the brief, per QD-8).
</output>

View File

@@ -0,0 +1,128 @@
---
phase: quick-261022-tto
plan: 01
quick_id: 261022-tto
subsystem: sm-user-plugin (golem15.user) session routes
tags: [user-plugin, jwt, cookies, escalation-G3, v0.5.0-hold]
status: complete
requires:
- sm-user-plugin 358ee5d (logout reads the session cookies, classes.sessionTokens)
- sm-user-plugin 15ff979 (G2, base of this fix)
provides:
- classes.SessionToken(r) and classes.SessionCookieNames()
- cookie-carried sessions on /fetch, /refresh, /update, /change-password (and /activate, /marketing-consent, /avatar, /avatar/remove)
affects:
- escalation note G3 row and reply line
- login-cookie todo (refresh cookie rotation decision)
tech-stack:
added: []
patterns:
- "one extractor: controllers read the raw token only through classes.SessionToken and build the guard with classes.SessionCookieNames()"
key-files:
created:
- ../sm-demo-app/plugins/golem15/user/session_cookie_test.go
modified:
- ../sm-demo-app/plugins/golem15/user/classes/session_user.go
- ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
- ../sm-demo-app/plugins/golem15/user/logout_test.go
- ../sm-demo-app/plugins/golem15/user/README.md
- .planning/notes/2026-10-22-external-user-plugin-escalations.md
- .planning/todos/pending/2026-10-22-user-session-routes-ignore-cookie.md
- .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md
decisions:
- "Session routes read the token as the jwt guard does (bearer, then token, then auth_token) through classes.SessionToken and one cookie-aware sessionGuard"
- "A cookie-carried POST /refresh answers the rotated token in the body only and writes no cookie (PHP parity); the caller that wrote the cookie stores the new token"
- "G3 todo stays in todos/pending with an Outcome section under the SummerCMS v0.5.0 release hold (G2 precedent), not moved to done as the brief said"
metrics:
duration: ~8min
completed: 2026-10-22
commits: 1
plan_head_before: 15ff979
plan_head_after: 8e4b863a3fcc04a5541a836211a1f1f5a51dd693
actuals:
tokens: 9550
tasks: 3
commits: 2
---
# Quick 261022-tto: session cookies on /fetch, /refresh, /update and /change-password
The four signed-in user routes, and every other route behind the shared `authenticate` helper, now read the session JWT exactly as the plugin's `jwt` guard does: the bearer header first, then the `token` cookie, then the `auth_token` cookie. They use `classes.SessionToken` and a single cookie-aware `sessionGuard`. A cookie-carried `/refresh` returns the rotated token in the body and does not write a cookie.
## Commits
| Repo | Commit | Message |
|------|--------|---------|
| sm-user-plugin (master, local only) | `8e4b863` | fix: /fetch, /refresh, /update and /change-password accept the session cookies the jwt guard reads |
| summercms.go | `b70abe4` | docs: fill G3 in the external-escalation reply |
Plugin base: `15ff979` (master had not moved; `git log 15ff979..master` before the fix was empty). The plugin tree was clean at the start and is clean after the commit. No push, tag or sm-demo-app pointer bump.
The frontmatter `commits: 1` is measured from the plugin ledger, `git rev-list --count 15ff979..8e4b863`. `actuals.commits: 2` also counts the summercms.go docs commit.
## What changed (plugin)
- `classes/session_user.go`: `sessionToken` is renamed to the exported `SessionToken`, and its one caller, `RequestUser`, is updated. A new `SessionCookieNames()` returns `slices.Clone(sessionCookieNames)`. `sessionTokens`, `EndSession` and `forgetSessionCookies` are unchanged.
- `controllers/api_controller.go`:
- New `sessionGuard(app, secret)`: it looks up `bouncer.BlacklistStore` and returns `bouncer.NewJWTGuard(..., classes.SessionCookieNames()...)`.
- `authenticate`: one guard call; it takes the user ID from the principal and loads the user with the scoped Take. The bearer-only `VerifyClaims` step that failed closed is gone.
- `Refresh`: reads the token with `classes.SessionToken(r)`. The c3712dc refusals and the response body are unchanged, and it writes no cookie.
- `ChangePassword`: reads the token with `classes.SessionToken(r)`, then runs `VerifyClaims` for sub and iat and `sessionGuard(...).Authenticate`. If the principal's ID differs from sub, it answers 401.
- The private `bearerToken` helper is deleted.
- `Logout`, `EndSession`, `plugin.go` and `routes.go` are untouched.
- `README.md`:
- The JWT-sessions bullet describes the cookie carriers, `/refresh` with no cookie write, the store-the-new-token duty and the SameSite reliance.
- A sentence under the API reference table names the routes that accept the cookies.
- The classes list adds `SessionToken` and `SessionCookieNames`.
- The Testing section adds a `TestSessionRoutesAcceptCookies` paragraph and reworded the `TestLogoutEndsTheSession` sentence.
## Tests
`TestSessionRoutesAcceptCookies` in the new `session_cookie_test.go` uses one `newAdminEnv` and new helpers `cookieSend`, `login`, `cutOff`, `userSub` and `fetchedID`. Its subtests:
- `/fetch` with only the `token` or `auth_token` cookie, each preceded by a 200 from `e.guarded` (jwt.auth): 200 with the token's user.
- `/fetch` with no credentials: 401 with `unauthorizedRaw`.
- Bearer wins: with bearer A and cookie B, A's user is served. An invalid bearer plus a valid cookie gives 401 on `/fetch` and on `/tokens` (jwt.auth).
- A cut-off token in the `auth_token` cookie gets 401 on `/fetch`.
- `/refresh` through either cookie: 200 with a new body token and no Set-Cookie. The new token works as a bearer; the old cookie token now gets 401 on `/fetch`.
- `/refresh` through the cookie refuses a banned user and a cut-off token (`assertRefreshRefused`). With no credentials it answers 401 with exactly `{"error":"Token not found"}`.
- `/update` through either cookie: 200, and the name is stored.
- `/change-password` through either cookie (user with `HasSelfSetPassword`): 200, the calling cookie token still works on `/fetch`, and login with the new password succeeds.
- `/marketing-consent` through the `token` cookie: 200 (the QD-5 smoke check).
- `/update` and `/change-password` with no credentials: 401 with `unauthorizedRaw`.
`logout_test.go` `assertSessionEnded` now also checks `/fetch` (401) and `/refresh` (401 with `blacklistedBody`) through the `token` and `auth_token` cookies, alongside the bearer checks.
RED was observed before the change: 11 cookie subtests failed against 15ff979. After Task 1 the fetch, cut-off, bearer-wins and marketing-consent/update subtests went green. After Task 2 everything is green.
Verification at `8e4b863`: `GOWORK=off go vet ./...`, `GOWORK=off go test -short -count=1 ./...` and the full Docker-backed `GOWORK=off go test -count=1 ./...` all pass. All the grep gates in the Task 2 and Task 3 verify blocks pass.
## Deviations from Plan
1. **Minor placement:** the todo's "## Outcome (2026-10-22)" and the login-cookie todo's "## Note from G3 (2026-10-22)" sections go just before each file's `## Invoke` block, not at the very end of the file. This mirrors where G2's Outcome section sits (76694c7).
2. **Brief deviation (planned, QD-8):** the G3 todo stays in `todos/pending` under the SummerCMS v0.5.0 release hold. It was not moved to done as the original brief said.
Otherwise the plan was executed as written.
## Follow-ups
- **G4 session:** `plugin.go:99` could register the jwt guard with `classes.SessionCookieNames()...` instead of the literal `"token", "auth_token"`. It was left alone here so it does not overlap with G4's Boot rewiring. The parity subtests pin the two lists together until then.
- **Release:** the plugin tag and the app pointer bumps for `15ff979` and `8e4b863` go out with SummerCMS v0.5.0. The G3 todo moves to `todos/done` at that point. Other apps' plugin checkouts (sm-grzybyfunkcjonalne-app, sm-summercmsio-app, fonoteka.go) are not bumped.
- **Login-cookie todo (item 7):** decide in `--discuss` whether a cookie-carried `/refresh` rotates a server-written HttpOnly cookie, as `bouncer.CookieRefreshHandler` does. The cross-reference is now in that todo.
- **g15office:** PENDING. No g15office tool was available in this session. Post on task `kkknstxl`: "Fixed in sm-user-plugin 8e4b863 (local, ships with SummerCMS v0.5.0)". Leave its status as it is.
## Threat model
- T-tto-01 and T-tto-02 are mitigated as planned: there is one extractor, the cookie path goes through the same guard, ChangePassword checks that the principal matches sub, and the tests pin each of these.
- T-tto-03 through T-tto-07 are accepted, and the README documents the SameSite reliance.
- T-tto-08 is mitigated: nothing was pushed or tagged.
- No new threat surface beyond the register.
## Known Stubs
None.
## Self-Check: PASSED
- FOUND: ../sm-demo-app/plugins/golem15/user/session_cookie_test.go
- FOUND: sm-user-plugin commit 8e4b863 on master (clean tree)
- FOUND: summercms.go commit b70abe4 (three docs paths only)

View File

@@ -0,0 +1,96 @@
---
phase: quick-261022-tto
verified: 2026-10-22T22:05:00+02:00
status: passed
score: 7/7 must-haves verified
covered_files:
- .planning/quick/261022-tto-fetch-refresh-update-change-password-mus/261022-tto-PLAN.md
- .planning/quick/261022-tto-fetch-refresh-update-change-password-mus/261022-tto-SUMMARY.md
covered_digest: "v2:sha256:31e248110224527435feeac56ee1e8e1627257f312ae22cc6fe05f25a50675e0"
covered_files_note: "The implementation lives in sm-user-plugin (../sm-demo-app/plugins/golem15/user, commit 8e4b863 on base 15ff979), outside this project root, so verification.fingerprint refuses those paths. Verified files there: classes/session_user.go, controllers/api_controller.go, session_cookie_test.go, logout_test.go, README.md."
behavior_unverified: 0
overrides_applied: 0
---
# Quick 261022-tto: Verification Report
**Goal:** GET /fetch, POST /refresh, POST /update and POST /change-password of the user plugin accept the `token` / `auth_token` cookies the same way `jwt.auth` and `EndSession` already do. Logout stays as it is.
**Verified:** 2026-10-22
**Status:** passed
**Re-verification:** No (initial verification)
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | A request that `jwt.auth` accepts with only the `token` or only the `auth_token` cookie gets 200 from /fetch (that user's payload), /refresh (a new token in the body), /update (the profile is saved) and /change-password (the password is replaced). | VERIFIED | `authenticate`, `Refresh` and `ChangePassword` read the token through `classes.SessionToken` and `sessionGuard`, which is built with `classes.SessionCookieNames()...`. The subtests for fetch, refresh, update and change-password, each run under both cookie names, first check `e.guarded(...)==200`, then the route's result: fetch matches the user id from the token's sub, refresh returns a new body token that works as a bearer, update stores the new name, and the new password logs in. All pass at 8e4b863. Against the 15ff979 code with the same test, 11 subtests fail. |
| 2 | With no credentials, the routes still answer 401 with the old bodies: `unauthorizedRaw` from /fetch, /update and /change-password, `{"error":"Token not found"}` from /refresh. | VERIFIED | Subtests "fetch without credentials", "refresh without credentials" and "/update and /change-password without credentials" compare the bodies byte for byte. They pass. |
| 3 | The bearer header wins exactly as in the jwt guard: bearer A with cookie B serves A; an invalid non-empty bearer with a valid cookie is 401 here and on jwt.auth. | VERIFIED | `classes.sessionTokens` matches bouncer `extractToken`/`bearerToken` (modules/bouncer/jwt.go:273-325): trimmed header, exact `Bearer ` prefix, a non-empty value wins, then each cookie in order with trimmed non-empty values. Subtest "bearer wins" covers /fetch and /tokens and passes. |
| 4 | A cookie-carried token is refused for the same reasons as a bearer token: blacklisted, issued before the cutoff, user missing or deactivated. /refresh keeps the c3712dc refusals. | VERIFIED | After extraction the path is the same guard (blacklist, `issuedBeforeCutoff`, `PrincipalByID` soft-delete scope) or the unchanged `bouncer.Refresh` + `refreshSubjectRefused`. Tests through the cookie: "fetch refuses a cut-off cookie token", "refresh refuses a banned user / cut-off token through the cookie" (`assertRefreshRefused`), and `assertSessionEnded` (/fetch and /refresh blacklisted through both cookies). All pass. Deactivated or missing users are not tested through the cookie specifically; that code is independent of the carrier and covered by TestOffboarding over the bearer. |
| 5 | A cookie-carried /refresh returns the rotated token in the body only and sets no cookie. The old token is blacklisted. Bearer-only clients see no change. Logout and EndSession are untouched. | VERIFIED | The refresh subtests assert `len(rec.Result().Cookies())==0` and that the old cookie token gets 401 on /fetch. The diff 15ff979..8e4b863 touches neither `Logout`, `EndSession`, plugin.go nor routes.go. The existing bearer tests (TestOffboarding, TestRefreshSubjectChecks, TestLogoutEndsTheSession) pass. |
| 6 | There is one extractor. The controllers read the token only through `classes.SessionToken` and build the guard with `classes.SessionCookieNames()`. The private bearer helper is gone. | VERIFIED | grep: `func bearerToken` is gone from controllers. `classes.SessionToken(r)` is called at api_controller.go:203 (Refresh) and :600 (ChangePassword). `SessionCookieNames()...` is used at :922 (sessionGuard). The only `Authorization` header read left is classes/session_user.go:242 (sessionTokens). |
| 7 | vet, `-short` and the full Docker test run are green at one fix commit. Nothing is pushed, tagged or pointer-bumped. G3 is recorded as done (unreleased) in the G2 form. | VERIFIED | I ran `GOWORK=off go vet ./...`, `go test -short ./...` and the full `go test -count=1 ./...` (Docker): all green, tree clean. master is ahead of origin/master by 2 (origin = 930b158). No tag contains 15ff979 or 8e4b863. The committed sm-demo-app submodule pointer is still 930b158. b70abe4 fills G3: the row reads "done (unreleased)", "`8e4b863` → v0.5.0", and the reply line starts "Ships in SummerCMS v0.5.0 (sm-user-plugin 8e4b863, tagged with that wave)". |
**Score:** 7/7 truths verified (0 present, behavior-unverified)
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `sm-user-plugin/classes/session_user.go` | `SessionToken`, `SessionCookieNames` | VERIFIED | Both exported. `SessionCookieNames` returns `slices.Clone`. `RequestUser` calls `SessionToken`. |
| `sm-user-plugin/controllers/api_controller.go` | `sessionGuard` with the cookie names; authenticate, Refresh and ChangePassword converted | VERIFIED | `sessionGuard` is at :916-923. `authenticate` makes one guard call followed by a scoped Take. ChangePassword checks that the principal ID equals sub. |
| `sm-user-plugin/session_cookie_test.go` | `TestSessionRoutesAcceptCookies` | VERIFIED | 253 lines, 17 subtests, all pass. The RED run against 15ff979 code fails 11 of them. |
| `sm-user-plugin/README.md` | cookie carriers, classes list, Testing entry | VERIFIED | JWT-sessions bullet, a sentence under the API table, the `SessionToken`/`SessionCookieNames` bullet, and a `TestSessionRoutesAcceptCookies` paragraph. |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| api_controller.go `sessionGuard` | classes `SessionCookieNames` | `bouncer.NewJWTGuard(..., classes.SessionCookieNames()...)` | WIRED | Used by `authenticate` and `ChangePassword`. |
| api_controller.go Refresh / ChangePassword | classes `SessionToken` -> `sessionTokens` | `classes.SessionToken(r)` | WIRED | Lines 203 and 600. |
| session_cookie_test.go | GET /tokens (jwt.auth) | `e.guarded(t, token, name)` | WIRED | Checked before every cookie success subtest. |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Cookie routes, refusals, bearer precedence | `GOWORK=off go test -count=1 -run '^TestSessionRoutesAcceptCookies$' .` | 17/17 subtests pass | PASS |
| The test discriminates (RED) | same test in a scratch copy with controllers/ and classes/ from 15ff979 | 11 subtests FAIL (fetch, refresh, update, change-password, consent through cookies, refresh refusals) | PASS (discriminating) |
| Regression on bearer and logout | `-run '^(TestLogoutEndsTheSession|TestOffboarding|TestRefreshSubjectChecks)$'` | pass | PASS |
| Whole plugin | `GOWORK=off go vet ./...`; `go test -short ./...`; `go test -count=1 ./...` | all ok | PASS |
### Probe Execution
None declared for this quick task.
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|----------|
| tto-1 | 261022-tto-PLAN | G3: the four routes accept the token/auth_token cookies like jwt.auth and EndSession; bearer precedence; no credentials stays 401; Logout unchanged | SATISFIED | Truths 1-7 |
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| (8e4b863 diff) | - | no TBD/FIXME/XXX/TODO/HACK added | - | none |
| session_cookie_test.go | 24 | test-local `sessionCookieNames` literal duplicates classes' list | Info | The tests pin the list independently, which is deliberate parity checking. |
| plugin.go | 99 | jwt guard still uses the literal `"token", "auth_token"` instead of `classes.SessionCookieNames()` | Info | Planned follow-up (QD-1) for the G4 session. The parity subtests guard against drift. |
| - | - | g15office comment on task kkknstxl marked PENDING in SUMMARY | Info | Process item (CLAUDE.md rule 4), not a code must-have. |
Note on test strength: subtest "fetch refuses a cut-off cookie token" also passes against the pre-fix code, because there the cookie was ignored and the answer was 401 anyway. Against the fixed code it still catches a regression where the cookie is read but the cutoff is skipped.
### Human Verification Required
None.
### Gaps Summary
No gaps. The four named routes, and the other `authenticate` routes, read the session token through the same extractor and cookie list as the jwt guard. The behaviour is pinned by a test that fails on the pre-fix code and passes at 8e4b863. The release hold is respected.
---
_Verified: 2026-10-22_
_Verifier: Claude (msd-verifier)_