|
|
|
|
@@ -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>
|