|
|
|
|
@@ -0,0 +1,445 @@
|
|
|
|
|
---
|
|
|
|
|
phase: quick-261022-wah
|
|
|
|
|
plan: 01
|
|
|
|
|
quick_id: 261022-wah
|
|
|
|
|
slug: opt-in-httponly-session-cookie-on-user-p
|
|
|
|
|
type: execute
|
|
|
|
|
mode: quick
|
|
|
|
|
wave: 1
|
|
|
|
|
depends_on: []
|
|
|
|
|
autonomous: true
|
|
|
|
|
requirements:
|
|
|
|
|
- wah-1 # cookie-carried POST /refresh: when the token came from the auth.cookie cookie (no bearer), rewrite that cookie with the rotated token and leave the token out of the body, whatever token_cookie says; bearer and other-cookie refreshes stay body-only; refusals write and forget no cookie
|
|
|
|
|
- wah-2 # golem15.user.jwt.token_cookie (default false): POST /login, POST /register (token branch) and POST /activate-by-code set the auth.cookie HttpOnly cookie and omit body token; off = today's bodies byte-for-byte and no Set-Cookie; a LoadCookieConfig error is logged and answers the opaque 500
|
|
|
|
|
- wah-3 # escalation note item 7 (tracker, verdict, reply), the G3 verdict and reply sentence about /refresh reworded, the todo's Outcome; a separate docs commit in summercms.go
|
|
|
|
|
files_modified:
|
|
|
|
|
# sm-user-plugin (module git.golem15.com/golem15/sm-user-plugin), worked in the sm-demo-app submodule checkout. Commit there only.
|
|
|
|
|
- ../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/config/config.yaml
|
|
|
|
|
- ../sm-demo-app/plugins/golem15/user/README.md
|
|
|
|
|
- ../sm-demo-app/plugins/golem15/user/refresh_cookie_test.go
|
|
|
|
|
- ../sm-demo-app/plugins/golem15/user/session_cookie_test.go
|
|
|
|
|
- ../sm-demo-app/plugins/golem15/user/token_cookie_test.go
|
|
|
|
|
# summercms.go planning docs, edited and committed by Task 3 (pathspec commit of exactly these two files)
|
|
|
|
|
- .planning/notes/2026-10-22-external-user-plugin-escalations.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:
|
|
|
|
|
- "With golem15.user.jwt.token_cookie unset (the default) or false, POST /login and POST /register (auto mode) answer 200 with exactly the keys token and user, POST /activate-by-code with exactly message, token and user, and none of them sets a cookie; TestLogin, TestRegister and TestActivateByCode stay green unchanged."
|
|
|
|
|
- "With golem15.user.jwt.token_cookie true, those three routes answer 200 without a token key (user, and message for activate-by-code, stay) and set exactly one cookie: named auth.cookie.name, with the path, domain, SameSite (Lax by default) and Secure of bouncer.LoadCookieConfig, HttpOnly, Max-Age golem15.user.jwt.refresh_ttl in seconds; that cookie alone authenticates GET /fetch as the signed-in user."
|
|
|
|
|
- "A POST /refresh whose token came from the auth.cookie cookie with no bearer header answers 200 with the body {} and one Set-Cookie of that name carrying the rotated token, whether token_cookie is on or off; the rotated cookie authenticates /fetch and jwt.auth, and the old token is refused afterwards."
|
|
|
|
|
- "A POST /refresh carried by the bearer header (also when the auth.cookie cookie is sent too), or by the other session cookie, answers 200 with exactly the key token and sets no cookie, as at a484d0e."
|
|
|
|
|
- "A refused POST /refresh (401) sets and forgets no cookie. When bouncer.LoadCookieConfig refuses the auth.cookie block, a cookie-carried refresh answers the opaque 500 before rotating (the same token still refreshes by bearer), and with token_cookie on the three token routes answer the opaque 500 before they sign in, create or activate anything; a bearer refresh never reads auth.cookie."
|
|
|
|
|
- "POST /tokens (personal API tokens) is unchanged and sets no cookie when token_cookie is on."
|
|
|
|
|
- "Two local commits on sm-user-plugin master on top of a484d0e (the refresh rotation first, then token_cookie), each green on go vet, go test -short and the full Docker-backed suite; no trailer, not pushed, not tagged, go.mod and go.sum unchanged; the sm-demo-app pointer is neither committed nor staged."
|
|
|
|
|
- "Escalation note row 7 reads done (unreleased) with both short SHAs and v0.5.0; verdict 7, the G3 verdict and the G3 and Cookie-on-/login reply paragraphs describe the shipped behaviour and name golem15.user.jwt.token_cookie; only item 8's TODO line remains; the todo has an Outcome and stays in pending/; both files are committed in summercms.go by a commit touching only them."
|
|
|
|
|
artifacts:
|
|
|
|
|
- path: ../sm-demo-app/plugins/golem15/user/classes/session_user.go
|
|
|
|
|
provides: "SessionTokenCookie(r): the name of the session cookie that supplied SessionToken's token, \"\" when the bearer header did or there is none; bearer parsing shared with sessionTokens"
|
|
|
|
|
contains: "func SessionTokenCookie(r *http.Request) string"
|
|
|
|
|
- path: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
|
|
|
|
|
provides: "tokenCookieConfig, writeSession (cookie-or-body 200 writer) and sessionCookie (token_cookie flag); Refresh rewrites the auth.cookie cookie; Login, Register and ActivateByCode write through writeSession"
|
|
|
|
|
contains: "func writeSession(w http.ResponseWriter, app *backpack.App, cc *bouncer.CookieConfig, token string, body map[string]any)"
|
|
|
|
|
- path: ../sm-demo-app/plugins/golem15/user/config/config.yaml
|
|
|
|
|
provides: "jwt.token_cookie: false with a comment"
|
|
|
|
|
contains: "token_cookie: false"
|
|
|
|
|
- path: ../sm-demo-app/plugins/golem15/user/refresh_cookie_test.go
|
|
|
|
|
provides: "TestRefreshRewritesAuthCookie (Docker) and TestSessionTokenCookie (-short); assertSessionCookie and assertNoCookie helpers"
|
|
|
|
|
contains: "func TestRefreshRewritesAuthCookie"
|
|
|
|
|
- path: ../sm-demo-app/plugins/golem15/user/token_cookie_test.go
|
|
|
|
|
provides: "TestTokenCookie (Docker): off by default, on, configured auth.cookie, broken auth.cookie"
|
|
|
|
|
contains: "func TestTokenCookie"
|
|
|
|
|
- path: ../sm-demo-app/plugins/golem15/user/README.md
|
|
|
|
|
provides: "JWT sessions bullet (cookie refresh and token_cookie), SessionTokenCookie in the API reference, config table row, Testing paragraphs"
|
|
|
|
|
contains: "golem15.user.jwt.token_cookie"
|
|
|
|
|
- path: .planning/notes/2026-10-22-external-user-plugin-escalations.md
|
|
|
|
|
provides: "row 7 done (unreleased) with both SHAs; verdicts 7 and G3; G3 and Cookie-on-/login reply paragraphs filled"
|
|
|
|
|
contains: "golem15.user.jwt.token_cookie"
|
|
|
|
|
- path: .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md
|
|
|
|
|
provides: "## Outcome (2026-10-22) section before ## Invoke"
|
|
|
|
|
contains: "## Outcome"
|
|
|
|
|
key_links:
|
|
|
|
|
- from: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (Refresh)
|
|
|
|
|
to: ../sm-demo-app/plugins/golem15/user/classes/session_user.go (SessionTokenCookie, SessionToken)
|
|
|
|
|
via: "Refresh rotates the token SessionToken returns and rewrites the cookie only when SessionTokenCookie names the same cookie as auth.cookie.name"
|
|
|
|
|
pattern: "classes\\.SessionTokenCookie\\(r\\)"
|
|
|
|
|
- from: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (writeSession, tokenCookieConfig)
|
|
|
|
|
to: summercms.go modules/bouncer/cookie.go (LoadCookieConfig, CookieConfig.TokenCookie)
|
|
|
|
|
via: "auth.cookie attributes come only from bouncer.LoadCookieConfig(app.Config); the cookie is cc.TokenCookie(token, refreshTTL(app)), HttpOnly with Max-Age refresh_ttl"
|
|
|
|
|
pattern: "TokenCookie\\(token, refreshTTL\\(app\\)\\)"
|
|
|
|
|
- from: the cookie writeSession sets
|
|
|
|
|
to: "the plugin's jwt guard, authenticate and SessionToken (classes.SessionCookieNames, which lists token and auth_token)"
|
|
|
|
|
via: "the guard reads only the token and auth_token cookies, so the written cookie authenticates while auth.cookie.name is one of them (the default token)"
|
|
|
|
|
pattern: "SessionCookieNames"
|
|
|
|
|
- from: Task 1 and Task 2 commit SHAs (sm-user-plugin master)
|
|
|
|
|
to: .planning/notes/2026-10-22-external-user-plugin-escalations.md row 7, verdict 7 and the reply
|
|
|
|
|
via: "Task 3 reads each SHA with git log --format=%h --fixed-strings --grep on the commit subject"
|
|
|
|
|
pattern: "→ v0\\.5\\.0"
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
<objective>
|
|
|
|
|
Close external escalation item 7 in sm-user-plugin: an opt-in HttpOnly session cookie on the password session routes, and a cookie-carried POST /refresh that rewrites that cookie.
|
|
|
|
|
|
|
|
|
|
Today POST /login, POST /register (when it signs the new account in) and POST /activate-by-code return the session JWT only in the body, so the external app runs a Nuxt bridge to put it in a cookie. golem15.sociallogin already has the twin flag (golem15.sociallogin.token_cookie, its writeSession). G3 (8e4b863) made /refresh read the token and auth_token cookies but answer the rotated token in the body only while blacklisting the old jti, so an HttpOnly cookie (which JavaScript cannot rewrite) keeps a dead token after one refresh. Both halves land here.
|
|
|
|
|
|
|
|
|
|
Purpose: an application can keep the session in an HttpOnly cookie end to end (sign in, refresh, fetch, logout) without a bridge, and without any change for the many applications that use body tokens.
|
|
|
|
|
Output: two local commits on sm-user-plugin master (one logical change each, per CLAUDE.md), made in /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user with git -C; then the escalation note and the todo filled and committed in summercms.go as a separate docs commit. No push, no tag and no sm-demo-app pointer commit (SummerCMS v0.5.0 release hold). summercms.go code (modules/bouncer) is not modified.
|
|
|
|
|
|
|
|
|
|
Locked decisions from 261022-wah-CONTEXT.md (labelled here for traceability; the executor does not revisit them):
|
|
|
|
|
- L-1 Key golem15.user.jwt.token_cookie, default false, a twin of golem15.sociallogin.token_cookie. Cookie attributes come only from auth.cookie.* through bouncer.LoadCookieConfig; no second attributes block.
|
|
|
|
|
- L-2 When on: http.SetCookie(w, cc.TokenCookie(token, RefreshTTL(app))) and the token key is omitted from the body; user and any other keys stay. When off: today's bodies byte-for-byte and no Set-Cookie.
|
|
|
|
|
- L-3 A LoadCookieConfig error is logged and answers an opaque 500, like sociallogin writeSession.
|
|
|
|
|
- L-4 SameSite stays Lax (the auth.cookie default). No CSRF token. No switch to SameSite=None.
|
|
|
|
|
- L-5 All three token-minting session routes (/login, /register's {token, user} branch, /activate-by-code) get the same flag and helper. Personal API token creation (api_tokens.go) is untouched.
|
|
|
|
|
- L-6 /refresh rewrites the cookie when its token came from the cookie named auth.cookie.name (LoadCookieConfig(...).Name, default token) with no bearer header, regardless of token_cookie (a sociallogin-set cookie rotates too).
|
|
|
|
|
- L-7 A bearer-carried refresh stays body-only, also when the cookie is present (bearer wins, as in SessionToken).
|
|
|
|
|
- L-8 A refresh carried by another session cookie (e.g. an app-written auth_token) stays body-only; no second cookie is written.
|
|
|
|
|
- L-9 When the cookie is rewritten, the rotated token is omitted from the body; the body stays valid JSON.
|
|
|
|
|
- L-10 Failure paths (401) neither write nor forget the cookie.
|
|
|
|
|
|
|
|
|
|
Planner's choices (Claude's Discretion in CONTEXT, plus planning calls; the executor does not revisit them):
|
|
|
|
|
- QD-1 Two plugin commits, refresh first. Commit 1 (fix): the cookie-carried /refresh rewrite. It is coherent on its own, because a golem15.sociallogin token_cookie session now survives a refresh. Commit 2 (feat): golem15.user.jwt.token_cookie on the three routes. Each commit carries its own README and doc-comment changes (CLAUDE.md docs rule). Tracker row 7 lists both SHAs.
|
|
|
|
|
- QD-2 Helpers live in controllers/api_controller.go: tokenCookieConfig (a nil-safe bouncer.LoadCookieConfig), writeSession (the 200 writer: cookie and no body token, or body token and no cookie), and sessionCookie (the token_cookie flag plus the config, added in Task 2). Refresh and the three routes all answer through writeSession. Carrier detection is a new exported classes.SessionTokenCookie(r), next to SessionToken in classes/session_user.go, sharing one bearer parser with sessionTokens so the bearer-then-cookies order lives in one place. This is an additive export of a core plugin, documented in the README API reference.
|
|
|
|
|
- QD-3 The cookie-refresh body is {} (map[string]any{} through writeJSON), the plugin's existing token-less 200 shape (/register in admin mode, api_controller.go:816).
|
|
|
|
|
- QD-4 Config-error timing. The three routes call sessionCookie right after sessionDeps, before reading the body and before any side effect. A broken auth.cookie block therefore never restores or records a login, creates an account or consumes an activation code and then answers 500 (VerifyActivationCode activates the user and clears the code). Refresh loads auth.cookie only for a cookie-carried request, before bouncer.Refresh, so a 500 rotates and blacklists nothing; a bearer refresh never reads auth.cookie, so bearer clients keep working on a broken block. The log line goes through pluginLog.Error and the answer is writeOpaque500 (the plugin's opaque 500, {"error":true,"message":"Internal server error"}).
|
|
|
|
|
- QD-5 The guard reads only the token and auth_token cookies (classes.SessionCookieNames). An auth.cookie.name outside that list would set a cookie that no route reads, and SessionTokenCookie never names it, so /refresh never rewrites it. SessionCookieNames is not changed; the README and the config.yaml comment say to keep auth.cookie.name at token (the default) or auth_token.
|
|
|
|
|
- QD-6 Compatibility. Cookie-carried /refresh was never released: the last plugin tag v0.1.8 does not contain 8e4b863, and released versions read /refresh's token from the Authorization header only. Changing the token-cookie refresh answer changes no released contract. The existing TestSessionRoutesAcceptCookies subtest that pinned the unreleased body-only answer for the token cookie is updated, not deleted.
|
|
|
|
|
- QD-7 Tests go through the real router (newAdminEnv: surf.Assemble with the jwt guard and the user-api limiter, Postgres from testcontainers), so cookies, the guard and the route answers are seen as a browser sees them. The classes helper gets a pure table test that runs under -short. The harness app.url is https://app.example.test, so LoadCookieConfig gives Secure=true; cookie attributes are compared against e.cookieConfig(t).TokenCookie, as assertForgets does, never against hard-coded flags.
|
|
|
|
|
- QD-8 Docs commit. The orchestrator asked for the framework-repo planning docs as a separate docs task and commit, staged by path. Task 3 commits exactly the note and the todo with a pathspec commit. The orchestrator's final quick commit (PLAN, SUMMARY, STATE) stays separate.
|
|
|
|
|
|
|
|
|
|
Source coverage audit:
|
|
|
|
|
| Source | Item | Covered by |
|
|
|
|
|
|--------|------|------------|
|
|
|
|
|
| GOAL / todo Solution 1-2, L-1, L-2, L-5 | token_cookie key (default false) on /login, /register, /activate-by-code; cookie from LoadCookieConfig + TokenCookie(RefreshTTL); body token omitted; off unchanged; api_tokens untouched | Task 2 (code, config.yaml, TestTokenCookie off/on/settings, POST /tokens check, file-list gate) |
|
|
|
|
|
| L-3 | LoadCookieConfig error logged, opaque 500 | Task 1 (refresh), Task 2 (three routes), both tested with a broken auth.cookie.same_site |
|
|
|
|
|
| todo Solution 3, L-6, L-9 | cookie-carried /refresh rewrites the auth.cookie cookie, regardless of token_cookie; rotated token not in the body | Task 1 (Refresh + writeSession, TestRefreshRewritesAuthCookie, updated TestSessionRoutesAcceptCookies) |
|
|
|
|
|
| L-7, L-8 | bearer-carried (with or without the cookie) and other-cookie refreshes stay body-only, no second cookie | Task 1 (SessionTokenCookie, bearer-wins, auth_token and custom-name subtests) |
|
|
|
|
|
| L-10 | 401 refresh writes and forgets no cookie | Task 1 (banned and blacklisted subtests assert no Set-Cookie) |
|
|
|
|
|
| todo Solution 4, L-4 | SameSite=Lax, no CSRF, no SameSite=None | Tasks 1-2: no SameSite code; attributes only from LoadCookieConfig; README sentence; threat T-wah-02 |
|
|
|
|
|
| CONTEXT specifics | README config row, JWT sessions paragraph (the cookie-refresh sentence changes), config.yaml commented key | Task 1 (refresh sentence, API reference, Testing) and Task 2 (config row, token_cookie sentences, config.yaml, Testing) |
|
|
|
|
|
| CONTEXT specifics | note item 7 (tracker, verdict, reply), G3 reply sentence reworded, todo Outcome, todo stays pending | Task 3 |
|
|
|
|
|
| orchestrator | base a484d0e; local commits only; no push/tag; no sm-demo-app pointer bump; no co-author tags; docs as a separate commit staged by path | Task 1-3 commit gates |
|
|
|
|
|
| REQ / RESEARCH | none (quick task, no research) | n/a |
|
|
|
|
|
| CONTEXT Deferred Ideas | none listed | n/a |
|
|
|
|
|
</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/quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/261022-wah-CONTEXT.md
|
|
|
|
|
@.planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md
|
|
|
|
|
@.planning/notes/2026-10-22-external-user-plugin-escalations.md
|
|
|
|
|
@../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
|
|
|
|
|
@../sm-demo-app/plugins/golem15/user/classes/session_user.go
|
|
|
|
|
@../sm-demo-app/plugins/golem15/sociallogin/controllers/helpers.go
|
|
|
|
|
@modules/bouncer/cookie.go
|
|
|
|
|
|
|
|
|
|
Working copies (observed at planning time, 2026-10-22):
|
|
|
|
|
- sm-user-plugin is at /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user. Branch master at a484d0e, 5 commits ahead of origin/master (930b158), working tree clean, no tag on HEAD; the last plugin tag is v0.1.8, which contains none of the five. Do not fetch, merge, rebase or reset. If master has moved past a484d0e, build on top and record git log --oneline a484d0e..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 dir runs with GOWORK=off. go.mod:176 replaces summercms with ../../../../summercms.go. sm-demo-app's go.work replaces sm-user-plugin with this checkout for the sociallogin and oauthserver consumers.
|
|
|
|
|
- Docker: docker info answered (server 29.9.1). If the socket is denied, run go test through sudo -u golem (CLAUDE.md).
|
|
|
|
|
- sm-demo-app (HEAD 18597e5) has another session's uncommitted edits (main.go, and the plugins/golem15/user pointer already shows modified because the checkout is ahead of the recorded commit). It is read-only here: run tests, never stage or commit there.
|
|
|
|
|
- summercms.go (HEAD ec69f54) has other sessions' uncommitted edits (.planning/state.json, untracked .msd/ and two phase .gitkeep files) and nothing staged. Leave them alone. STATE.md, g15office and the final quick commit belong to the orchestrator; Task 3 commits only the note and the todo.
|
|
|
|
|
|
|
|
|
|
Source facts (plugin checkout at a484d0e):
|
|
|
|
|
- controllers/api_controller.go:
|
|
|
|
|
- Login: doc comment :46-59, handler :60-141. sessionDeps :62, readFields :66, CheckAndRecordLogin success :108, TouchLastSeen :126, mintFor :129, the final 200 write with the token and user keys :139.
|
|
|
|
|
- Refresh: doc comment :191-198 (it says the rotated token is answered in the body only and no cookie is written). Handler :199-247: sessionDeps :201, raw := classes.SessionToken(r) :205, the "Token not found" 401 :206-209, bouncer.Refresh :214 (blacklists the old jti on success), the two 401 bodies :216-219 and :232-236, TouchLastSeen, then the final 200 write with the token key :245.
|
|
|
|
|
- ActivateByCode: doc comment :472, handler :473-531. sessionDeps :475, readFields :479, VerifyActivationCode :509 (activates the user and clears the code), mintFor :514, final write :524-528 with message "Account activated", the token and user.
|
|
|
|
|
- Register: doc comment :743, handler :744-818. sessionDeps :746, RegisterUser :768, user mode answers {"message":"Activation email sent"} :799, the token branch (mode auto or require_activation off) IssueToken :803 and its write :813, admin mode answers {} :816.
|
|
|
|
|
- appConfig :888 (nil-safe), sessionDeps :930 (guarantees a non-nil app), refreshTTL :996 (RefreshTTL exported at :992), writeJSON :1186 (Cache-Control no-cache, private, then wire.WriteJSON), writeOpaque500 :1253. pluginLog is diary.For("user") in controllers/logger.go. bouncer, net/http and strings are already imported.
|
|
|
|
|
- The only session-token mint sites are :129, :514 and :803 (IssueToken in registration.go:185 wraps mintFor). api_tokens.go:61 answers 201 {"token": secret, "meta": …} for personal API tokens; not a session, untouched.
|
|
|
|
|
- classes/session_user.go: sessionCookieNames = {"token", "auth_token"} :21, SessionCookieNames :27. forgetSessionCookies :159 already loads LoadCookieConfig with a fallback, for logout. SessionToken :225 returns sessionTokens(r)[0]. sessionTokens :235 parses the Authorization header as strings.TrimSpace(header) then strings.CutPrefix(h, "Bearer "), then each cookie of sessionCookieNames in order, trimmed, empty values skipped, duplicates dropped. A header of "Bearer " with nothing after it trims to "Bearer", so it supplies no token and the cookies are read.
|
|
|
|
|
- summercms.go modules/bouncer/cookie.go (read-only): LoadCookieConfig(src) returns defaults (token, "/", Lax) for a nil interface, but a nil *compass.Config wrapped in the interface is not nil, so never pass one. It errors on an invalid name, path, domain or same_site (lax, strict and none only), on Secure false in production, and on none without Secure. Secure defaults to app.url being https. CookieConfig.TokenCookie(token, ttl) is HttpOnly with MaxAge int(ttl/time.Second). No boot step calls LoadCookieConfig, so a broken block surfaces only at request time.
|
|
|
|
|
- golem15.sociallogin writeSession (../sm-demo-app/plugins/golem15/sociallogin/controllers/helpers.go:148): loads cfg.TokenCookie, then bouncer.LoadCookieConfig(app.Config); on error it logs and answers 500; then it either calls http.SetCookie(w, cc.TokenCookie(token, usercontrollers.RefreshTTL(app))) or sets body["token"]. Its TestTokenCookieMode (complete_test.go:116) is the assertion style to mirror.
|
|
|
|
|
- Tests (package user, plugin root; Docker ones skip under -short through adminHarnessDB):
|
|
|
|
|
- admin_harness_test.go: adminEnvConfig :131 (app.url https://app.example.test, SUMMER_ENV development), newAdminEnv(t, configure...) :165. Configure functions run after party.Activate and before Assemble; each calls cfg.Set and t.Fatal on error, as logout_test.go:167-178 does. Handlers read config per request, so e.app.Config.Set mid-test also applies. call :287, send :301 (Host example.com, a fresh RemoteAddr 198.51.100.N per request, bearer only when token is non-empty, extra headers), seedUser :513 (password correct-horse, IsActivated true, unique email; mutate adjusts the row), reload :554, seedThrottle :612, userLogin :682 (reads the token from the body).
|
|
|
|
|
- logout_test.go: userRefreshPath :22, userTokensPath :20, blacklistedBody :19, sessionLogin :208 (fails when the body has no token, so it only works with token_cookie off), guarded(t, token, cookie) :220, cookieConfig :255, assertForgets :273 (the attribute comparison to copy), userAudienceToken :291.
|
|
|
|
|
- session_cookie_test.go: TestSessionRoutesAcceptCookies :36, whose doc comment :27-35 ends with the cookie-refresh clause, and the "refresh via the <name> cookie" loop :92-119 (asserts body token, no cookies, then the old token refused through the same cookie). cookieSend :207, login :220, userSub :237, fetchedID :242.
|
|
|
|
|
- offboarding_test.go: fetchCode :45, refresh :50 (bearer), assertRefreshRefused :55 with refusedRefreshBody.
|
|
|
|
|
- session_test.go: TestLogin :54, TestRefresh :420, invalidLoginBody, parseToken :644. account_test.go TestActivateByCode :98 (classes.IssueActivationCode, packed id!code). register_test.go TestRegister :23 (email, password and name are enough in auto mode).
|
|
|
|
|
- The names TestRefreshRewritesAuthCookie, TestSessionTokenCookie, TestTokenCookie, SessionTokenCookie, tokenCookieConfig, writeSession, sessionCookie, assertSessionCookie and assertNoCookie, and the files refresh_cookie_test.go and token_cookie_test.go, are all unused in the plugin today.
|
|
|
|
|
- README.md: JWT sessions bullet :20 (its cookie-refresh sentence begins "A cookie-carried `POST /refresh`" and ends "stores the new token in it."; the SameSite sentence after it stays). The route note :111. The classes API bullet with SessionToken(r) and SessionCookieNames() :192. Config table :205-226, where the jwt rows end with blacklist_sweep_interval :212. Testing :246-274: TestSessionRoutesAcceptCookies paragraph :266 and TestLoginThrottleClientIP paragraph :270.
|
|
|
|
|
- config/config.yaml: the jwt block :1-7 ends with blacklist_sweep_interval: 10m; two-space indent; comments use #.
|
|
|
|
|
- Escalation note (summercms.go): tracker row 7 :28 (status open, Shipped empty); rows 1-3 :22-24 are the format. Verdict **G3.** :56 (its third sentence says /refresh leaves the cookie alone); verdict **7.** :61; **9.** and **10.** :63-64 are the "Done locally (`sha`). ..." format. Reply: the G3 "Ships in" line :79 ends with a sentence that begins "A cookie-carried /refresh returns"; "Cookie on /login" :94-96 with its TODO line :96; item 8's TODO line :100 stays. The note has exactly two lines starting with TODO today.
|
|
|
|
|
- Todo: sections ## Problem :17, ## Solution :25, ## Note from G3 (2026-10-22) :38, ## Invoke :42. The Outcome goes between Note from G3 and Invoke, in the format of .planning/todos/pending/2026-10-22-user-logout-blacklist-missing-under-serve.md:33-35.
|
|
|
|
|
</context>
|
|
|
|
|
|
|
|
|
|
<tasks>
|
|
|
|
|
|
|
|
|
|
<task type="tracer" tdd="true">
|
|
|
|
|
<name>Task 1 (tracer): a POST /refresh carried by the auth.cookie cookie rewrites that cookie, end to end through the router on Postgres, committed in sm-user-plugin</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/refresh_cookie_test.go, ../sm-demo-app/plugins/golem15/user/session_cookie_test.go, ../sm-demo-app/plugins/golem15/user/README.md</files>
|
|
|
|
|
<precondition>git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user status --short is empty, its branch is master, and a484d0e is an ancestor of HEAD. Docker is reachable for testcontainers, directly or through sudo -u golem.</precondition>
|
|
|
|
|
<behavior>
|
|
|
|
|
- TestRefreshRewritesAuthCookie (refresh_cookie_test.go, package user, Docker through newAdminEnv; skips under -short). golem15.user.jwt.token_cookie is never set in this test (L-6: the rewrite does not depend on it).
|
|
|
|
|
- "token cookie rotates" (default env): T from e.sessionLogin. cookieSend POST /refresh with only the token cookie = T answers 200 and the body is exactly {}. Exactly one cookie is set: name token, attributes equal to e.cookieConfig(t).TokenCookie (path, domain, SameSite, Secure, HttpOnly), MaxAge 20160*60, value N non-empty and different from T, and parseToken(N) sub equals T's sub. cookieSend GET /fetch with token = N answers 200 and fetchedID is that sub; e.guarded(t, N, "token") is 200; cookieSend GET /fetch with token = T answers 401.
|
|
|
|
|
- "auth_token cookie stays body-only" (L-8): cookieSend POST /refresh with only auth_token = T' answers 200 with exactly the key token (a value other than T'), and no cookie is set.
|
|
|
|
|
- "bearer wins over the cookie" (L-7): A and B from sessionLogin. e.send POST /refresh with bearer A and the header Cookie token=B answers 200 with exactly the key token, whose sub is A's; no cookie is set; cookieSend GET /fetch with token = B still answers 200 (B was not rotated).
|
|
|
|
|
- "bearer only": e.refresh(t, A2) answers 200 with exactly the key token and sets no cookie.
|
|
|
|
|
- "configured auth.cookie name": its own env with auth.cookie.name auth_token and golem15.user.jwt.refresh_ttl 60. A refresh carried by auth_token answers {} and sets exactly one cookie, auth_token, with MaxAge 3600 (no token cookie); that cookie value authenticates GET /fetch through the auth_token cookie. A refresh carried only by the token cookie answers exactly the key token and sets no cookie.
|
|
|
|
|
- "refusals set no cookie" (L-10): a banned user's token (seedUser, seedThrottle banned, userAudienceToken) sent through the token cookie gets assertRefreshRefused and no cookie. A token already rotated away through the token cookie, refreshed again through the token cookie, answers 401 with blacklistedBody and no cookie.
|
|
|
|
|
- "broken auth.cookie" (L-3): its own env with auth.cookie.same_site "sideways". T5 from sessionLogin (login does not read auth.cookie). A refresh carried by the token cookie answers 500 with exactly {"error":true,"message":"Internal server error"} and no cookie; then e.refresh(t, T5) by bearer answers 200 with a token (T5 was not rotated, and the bearer path never reads auth.cookie).
|
|
|
|
|
- TestSessionTokenCookie (same file, no database, runs under -short), on httptest.NewRequest requests:
|
|
|
|
|
- bearer A plus a token cookie: "".
|
|
|
|
|
- Authorization "Bearer " (nothing after it) plus a token cookie: "token".
|
|
|
|
|
- Authorization "Basic xyz" plus a token cookie: "token".
|
|
|
|
|
- only auth_token: "auth_token".
|
|
|
|
|
- token and auth_token: "token".
|
|
|
|
|
- an empty token value (Cookie: token=; auth_token=X): "auth_token".
|
|
|
|
|
- no credentials: "".
|
|
|
|
|
- For every case, a non-empty name means the trimmed value of r.Cookie(name) equals classes.SessionToken(r), and "" with a non-empty SessionToken means the bearer header supplied it.
|
|
|
|
|
- session_cookie_test.go: the "refresh via the <name> cookie" loop branches on the name. For token it asserts the rewrite: a {} body, a token cookie with a new value that serves e.fetchCode. For auth_token it keeps today's assertions. For both, the old token is refused afterwards through the same cookie. The TestSessionRoutesAcceptCookies doc comment's last clause says that a refresh through the token cookie (the auth.cookie default) gets the cookie back with the rotated token and an empty body, and one through auth_token gets it in the body and no cookie.
|
|
|
|
|
- RED at a484d0e: "token cookie rotates" fails at the cookie assertion (no Set-Cookie, and the body carries the token).
|
|
|
|
|
</behavior>
|
|
|
|
|
<action>
|
|
|
|
|
RED. Create refresh_cookie_test.go (package user) with TestRefreshRewritesAuthCookie as the behavior block describes. Its doc comment says it pins the cookie-carried refresh: the auth.cookie cookie is rewritten with the rotated token and the body is empty, whoever wrote the cookie; bearer and other-cookie refreshes stay body-only; refusals touch no cookie; a broken auth.cookie block fails a cookie refresh before it rotates.
|
|
|
|
|
|
|
|
|
|
Add these helpers to the same file, each with a one-line doc comment:
|
|
|
|
|
- assertSessionCookie(t, rec, cfg bouncer.CookieConfig, maxAge int) string. It requires exactly one cookie in rec.Result().Cookies(), named cfg.Name, with a non-empty value. Its Path, Domain, SameSite, Secure and HttpOnly must equal those of cfg.TokenCookie("x", time.Hour), and its MaxAge must equal maxAge. It returns the value.
|
|
|
|
|
- assertNoCookie(t, rec). It requires rec.Result().Cookies() to be empty and no Set-Cookie header.
|
|
|
|
|
- A small decoder that returns the body as map[string]any, so subtests compare the exact key set.
|
|
|
|
|
|
|
|
|
|
Configure functions call c.Set and t.Fatal on error, as logout_test.go:167-178 does.
|
|
|
|
|
|
|
|
|
|
Edit the "refresh via the <name> cookie" loop of session_cookie_test.go, and the TestSessionRoutesAcceptCookies doc comment, as the behavior block says. Do not add any reference to the not-yet-written classes function in this step, so the package still compiles.
|
|
|
|
|
|
|
|
|
|
Run GOWORK=off go test -count=1 -run '^TestRefreshRewritesAuthCookie$' . It must fail against the unchanged handler. Record the failure line for the SUMMARY. Do not commit a red test.
|
|
|
|
|
|
|
|
|
|
GREEN, classes/session_user.go (per QD-2):
|
|
|
|
|
- Extract the Authorization parsing of sessionTokens into an unexported bearerToken(r *http.Request) string. It trims the header, cuts the "Bearer " prefix, trims the rest, and returns "" when there is none. sessionTokens then calls it, so its behaviour is unchanged.
|
|
|
|
|
- Add exported SessionTokenCookie(r *http.Request) string right after SessionToken. It returns "" for a nil r, "" when bearerToken(r) is non-empty, and otherwise the name of the first cookie of sessionCookieNames whose trimmed value is non-empty ("" when there is none).
|
|
|
|
|
- Its doc comment says it names the session cookie that supplied the token SessionToken returns, for a handler that writes that cookie back (POST /refresh), and that it is "" when the bearer header supplied the token or the request carries none.
|
|
|
|
|
- Add TestSessionTokenCookie to refresh_cookie_test.go now.
|
|
|
|
|
|
|
|
|
|
GREEN, controllers/api_controller.go:
|
|
|
|
|
- Add tokenCookieConfig(app *backpack.App) (bouncer.CookieConfig, error). It returns bouncer.LoadCookieConfig(app.Config) when app and app.Config are non-nil, and bouncer.LoadCookieConfig(nil) (the defaults) otherwise, so a nil *compass.Config never reaches the interface. Doc comment: the auth.cookie block the plugin writes the session cookie with.
|
|
|
|
|
- Add writeSession(w http.ResponseWriter, app *backpack.App, cc *bouncer.CookieConfig, token string, body map[string]any). With cc nil it sets body["token"] = token. With cc set it calls http.SetCookie(w, cc.TokenCookie(token, refreshTTL(app))) and leaves the body without a token. Either way it then calls writeJSON(w, http.StatusOK, body).
|
|
|
|
|
- writeSession's doc comment calls it the user plugin's twin of golem15.sociallogin's writeSession, and says the HttpOnly token never reaches JavaScript when the cookie is written. With cc nil the body gets the same key set the routes answer today, so the bytes are unchanged.
|
|
|
|
|
- Refresh, after the "Token not found" check and before bouncer.Refresh: declare rotate as a *bouncer.CookieConfig, initially nil. When name := classes.SessionTokenCookie(r) is non-empty:
|
|
|
|
|
- Load cc, err := tokenCookieConfig(app).
|
|
|
|
|
- On error, log pluginLog.Error("golem15.user: refresh: auth.cookie config", "err", err), call writeOpaque500(w) and return. Nothing has been rotated at that point.
|
|
|
|
|
- When cc.Name equals name, set rotate = &cc.
|
|
|
|
|
- Replace the final 200 write of the token body with writeSession(w, app, rotate, next, map[string]any{}). The 401 paths stay exactly as they are and write no cookie.
|
|
|
|
|
- Rewrite the Refresh doc comment (it keeps beginning with "// Refresh "). It should say:
|
|
|
|
|
- the token is read as the jwt guard reads it;
|
|
|
|
|
- when it came from the cookie named auth.cookie.name with no bearer header, that cookie is rewritten with the rotated token (bouncer.LoadCookieConfig attributes, HttpOnly, Max-Age refresh_ttl) and the body is {}, whoever wrote the cookie;
|
|
|
|
|
- a bearer-carried or other-cookie refresh answers the token in the body and writes no cookie;
|
|
|
|
|
- refusals write and forget no cookie;
|
|
|
|
|
- a broken auth.cookie block answers a cookie-carried refresh with the opaque 500 before rotating;
|
|
|
|
|
- the existing subject-check sentences stay.
|
|
|
|
|
- gofmt both files.
|
|
|
|
|
|
|
|
|
|
README.md, in the same commit:
|
|
|
|
|
- In the JWT sessions bullet (:20), replace the cookie-refresh sentence (it begins "A cookie-carried `POST /refresh`" and ends "stores the new token in it.") with sentences that say the following:
|
|
|
|
|
- A `POST /refresh` whose token came from the `auth.cookie.*` cookie (`auth.cookie.name`, default `token`) with no bearer header gets that cookie back with the rotated token. The cookie has the `bouncer.LoadCookieConfig` attributes, HttpOnly and Max-Age `refresh_ttl`, and the answer is `{}`, so the token never reaches JavaScript.
|
|
|
|
|
- This holds whoever wrote the cookie (`golem15.sociallogin`'s `token_cookie` or the application). A frontend that writes the `token` cookie itself and refreshes through it gets it back HttpOnly, without a body token; such a frontend sends the bearer header to keep rotating in the body.
|
|
|
|
|
- A refresh carried by the bearer header (a cookie sent with it included) or by the other session cookie answers `{"token": …}` and writes no cookie. The old token is blacklisted after `golem15.user.jwt.blacklist_grace`, so that caller stores the new token itself.
|
|
|
|
|
- A refused refresh writes and forgets no cookie.
|
|
|
|
|
- When `bouncer.LoadCookieConfig` refuses the `auth.cookie` block, a cookie-carried refresh answers the opaque 500 before anything is rotated; a bearer refresh never reads that block.
|
|
|
|
|
- Keep `auth.cookie.name` at `token` or `auth_token`, the cookies the guard reads.
|
|
|
|
|
- Keep the SameSite sentence that follows.
|
|
|
|
|
- In the classes API bullet (:192), extend the SessionToken item with `SessionTokenCookie(r)`: the name of the session cookie that supplied that token (`""` when the bearer header did or there is none), which `POST /refresh` uses to rewrite the `auth.cookie` cookie.
|
|
|
|
|
- In the TestSessionRoutesAcceptCookies paragraph (:266), rewrite the parenthesis after `POST /refresh` to say that through `token` (the `auth.cookie` default) the rotated token comes back in a rewritten cookie with a `{}` body, through `auth_token` in the body with no cookie, and that the old token is refused afterwards.
|
|
|
|
|
- After the TestLoginThrottleClientIP paragraph, add one paragraph naming `TestRefreshRewritesAuthCookie` and its cases: the token cookie rewritten with the `LoadCookieConfig` attributes, `auth_token` and bearer refreshes left body-only (bearer wins over a cookie), a configured cookie name and refresh window, refusals that set no cookie, and a broken `auth.cookie` block. Name `TestSessionTokenCookie` (in `-short`) as the carrier table.
|
|
|
|
|
|
|
|
|
|
Run go vet, the targeted tests, go test -short and the full Docker-backed suite of the plugin, so the commit is green (CLAUDE.md). If an existing test other than the loop you edited relied on a body token from a token-cookie refresh, stop and report it; planning found none (logout_test and serve_order_test only expect 401 from cookie refreshes). A failure unrelated to this change is not patched here: stop and report it.
|
|
|
|
|
|
|
|
|
|
Commit in the plugin repo only, staging by path. Run git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user add classes/session_user.go controllers/api_controller.go refresh_cookie_test.go session_cookie_test.go README.md, then commit with git -C on the same path. The subject:
|
|
|
|
|
fix: a /refresh carried by the auth.cookie session cookie rewrites that cookie, so an HttpOnly session survives the rotation
|
|
|
|
|
The body is one short paragraph:
|
|
|
|
|
- 8e4b863 answered a cookie-carried refresh in the body only and blacklisted the old jti, so an HttpOnly cookie (golem15.sociallogin token_cookie) kept a dead token;
|
|
|
|
|
- now the cookie named auth.cookie.name gets the rotated token and the body is {};
|
|
|
|
|
- bearer and auth_token refreshes are unchanged, and refusals write no cookie.
|
|
|
|
|
|
|
|
|
|
Add no trailer of any kind; the user's global CLAUDE.md forbids co-author tags and overrides any harness attribution reminder. Do not push or tag (v0.5.0 release hold). Do not stage anything in sm-demo-app. Record the short SHA (git log -1 --format=%h) and the RED line in the SUMMARY.
|
|
|
|
|
</action>
|
|
|
|
|
<verify>
|
|
|
|
|
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && test -z "$(gofmt -l classes/session_user.go controllers/api_controller.go refresh_cookie_test.go session_cookie_test.go)" && GOWORK=off go vet ./... && GOWORK=off go test -short -count=1 -run '^TestSessionTokenCookie$' . && GOWORK=off go test -count=1 -run '^(TestRefreshRewritesAuthCookie|TestSessionTokenCookie|TestSessionRoutesAcceptCookies|TestLogoutEndsTheSession|TestBootBeforeDatabase|TestRefresh|TestRefreshSubjectChecks|TestOffboarding)$' . && grep -qF 'func SessionTokenCookie(r *http.Request) string' classes/session_user.go && grep -qF 'func bearerToken(r *http.Request) string' classes/session_user.go && grep -qF 'func tokenCookieConfig(app *backpack.App) (bouncer.CookieConfig, error)' controllers/api_controller.go && grep -qF 'func writeSession(w http.ResponseWriter, app *backpack.App, cc *bouncer.CookieConfig, token string, body map[string]any)' controllers/api_controller.go && grep -qF 'TokenCookie(token, refreshTTL(app))' controllers/api_controller.go && R=$(awk '/^func Refresh\(/,/^}/' controllers/api_controller.go) && grep -qF 'classes.SessionTokenCookie(r)' <<<"$R" && grep -qF 'writeSession(w, app, rotate, next, map[string]any{})' <<<"$R" && ! grep -qE '"token": +next' <<<"$R" && awk '/^\/\/ Refresh /,/^func Refresh\(/' controllers/api_controller.go | grep -qF 'auth.cookie' && grep -qF 'SessionTokenCookie(r)' README.md && grep -qF 'TestRefreshRewritesAuthCookie' README.md && grep -qF 'TestSessionTokenCookie' README.md && ! grep -qF 'answers the rotated token in the body and writes no cookie' README.md && GOWORK=off go test -short -count=1 ./... && GOWORK=off go test -count=1 ./... && test -z "$(git status --short)" && C=$(git log -1 --format=%H --fixed-strings --grep='rewrites that cookie, so an HttpOnly session survives' master) && test -n "$C" && test "$C" = "$(git rev-parse master)" && git merge-base --is-ancestor a484d0e "$C" && B=$(git log -1 --format=%B "$C") && ! grep -qi 'co-authored-by' <<<"$B" && git diff --quiet "$C^" "$C" -- go.mod go.sum && F=$(git diff --name-only "$C^" "$C") && test "$(LC_ALL=C sort <<<"$F" | tr '\n' ' ')" = "README.md classes/session_user.go controllers/api_controller.go refresh_cookie_test.go session_cookie_test.go " && test -z "$(git tag --points-at "$C")" && test -z "$(git branch -r --contains "$C")" && ! git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app diff --cached --name-only | grep -qxF plugins/golem15/user && test -z "$(git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app log --format=%h 18597e5..HEAD -- plugins/golem15/user)"</automated>
|
|
|
|
|
</verify>
|
|
|
|
|
<done>TestRefreshRewritesAuthCookie failed at a484d0e (failure line recorded) and now passes. A refresh carried by the auth.cookie cookie gets that cookie back with the rotated token and a {} body; bearer, bearer-plus-cookie and auth_token refreshes answer the token in the body with no cookie; refusals set no cookie; a broken auth.cookie answers the opaque 500 on a cookie refresh and leaves the token refreshable by bearer. classes.SessionTokenCookie exists with its -short table test. README describes the behaviour, the new export and the tests. go vet, go test -short and the full suite are green. One fix commit (five files) is on sm-user-plugin master on top of a484d0e: no trailer, go.mod and go.sum unchanged, untagged, not on any remote branch. The tree is clean and sm-demo-app is untouched.</done>
|
|
|
|
|
</task>
|
|
|
|
|
|
|
|
|
|
<task type="auto" tdd="true">
|
|
|
|
|
<name>Task 2: golem15.user.jwt.token_cookie (default off) puts the session token of /login, /register and /activate-by-code into the HttpOnly auth.cookie cookie, committed in sm-user-plugin</name>
|
|
|
|
|
<files>../sm-demo-app/plugins/golem15/user/controllers/api_controller.go, ../sm-demo-app/plugins/golem15/user/config/config.yaml, ../sm-demo-app/plugins/golem15/user/token_cookie_test.go, ../sm-demo-app/plugins/golem15/user/README.md</files>
|
|
|
|
|
<precondition>Task 1's verify passed: sm-user-plugin master's HEAD is the refresh-rewrite commit and the tree is clean.</precondition>
|
|
|
|
|
<behavior>
|
|
|
|
|
- TestTokenCookie (token_cookie_test.go, package user, Docker through newAdminEnv; skips under -short). Every env sets golem15.user.registration.use_register_throttle false, so the shared harness database's registration count cannot interfere. Register bodies use a unique email (label plus adminStamp), password correct-horse and a name. Activation codes come from seedUser with IsActivated false, then classes.IssueActivationCode(t.Context(), e.gdb, user), packed as userSub(user)+"!"+code.
|
|
|
|
|
- "off by default" (L-2): with token_cookie unset, POST /login answers 200 with exactly the keys token and user; POST /register (activate_mode auto, the default) answers exactly token and user; POST /activate-by-code answers exactly message, token and user. None sets a cookie (assertNoCookie).
|
|
|
|
|
- "on" (L-1, L-2, L-5): golem15.user.jwt.token_cookie true.
|
|
|
|
|
- POST /login answers 200 with exactly the key user (its email is the seeded one) and sets the cookie checked by assertSessionCookie(e.cookieConfig(t), 20160*60). That value C alone serves GET /fetch (cookieSend token) as the user.
|
|
|
|
|
- A cookie-carried POST /refresh with C answers {} and a new cookie C2, and C2 serves GET /fetch (Task 1 composed with the flag).
|
|
|
|
|
- cookieSend POST /tokens with C2 and {"name":"cli"} answers 201 with a non-empty token in the body and sets no cookie: personal API tokens are untouched.
|
|
|
|
|
- A wrong password answers 401 with invalidLoginBody and no cookie.
|
|
|
|
|
- POST /register answers exactly the key user and a session cookie that serves GET /fetch as the new account.
|
|
|
|
|
- POST /activate-by-code answers exactly message ("Account activated") and user (is_activated true) and a session cookie that serves GET /fetch.
|
|
|
|
|
- "on with configured auth.cookie": token_cookie true, auth.cookie.name auth_token, golem15.user.jwt.refresh_ttl 60. POST /login sets exactly one cookie, auth_token, with MaxAge 3600 and no token cookie, and that value serves GET /fetch through the auth_token cookie.
|
|
|
|
|
- "on with a broken auth.cookie" (L-3, QD-4): token_cookie true, auth.cookie.same_site "sideways".
|
|
|
|
|
- POST /login with the correct password answers 500 with exactly {"error":true,"message":"Internal server error"} and no cookie; the user's last_seen stays nil and the user has no user_throttle row.
|
|
|
|
|
- POST /register answers the same 500 and no users row (Unscoped) exists for that email.
|
|
|
|
|
- POST /activate-by-code answers the same 500 and the user is still not activated. Then e.app.Config.Set("auth.cookie.same_site", "lax"), and the same packed code answers 200 with exactly message and user and a session cookie (the code was not consumed by the 500).
|
|
|
|
|
- RED at the Task 1 commit: the "on" subtest fails (the body carries token and no cookie is set).
|
|
|
|
|
</behavior>
|
|
|
|
|
<action>
|
|
|
|
|
RED. Create token_cookie_test.go (package user) with TestTokenCookie as the behavior block describes. Reuse assertSessionCookie, assertNoCookie and the body decoder from refresh_cookie_test.go, and cookieSend, fetchedID, userSub, seedUser, reload and cookieConfig from the existing files. Do not use sessionLogin, userLogin or login in token_cookie-on envs, because they read the token from the body; call e.call on /_user/api/v1/login with {"email","password"}.
|
|
|
|
|
|
|
|
|
|
The doc comment says TestTokenCookie pins golem15.user.jwt.token_cookie: off keeps the body token and sets no cookie; on puts the session token of /login, /register and /activate-by-code into the HttpOnly auth.cookie cookie and leaves it out of the body; a broken auth.cookie block answers the opaque 500 before anything is signed in, created or activated.
|
|
|
|
|
|
|
|
|
|
Run GOWORK=off go test -count=1 -run '^TestTokenCookie$' . It must fail against the Task 1 handlers. Record the failure line. Do not commit a red test.
|
|
|
|
|
|
|
|
|
|
GREEN, controllers/api_controller.go (per L-1 to L-5, QD-2, QD-4):
|
|
|
|
|
- Add sessionCookie(app *backpack.App) (*bouncer.CookieConfig, error).
|
|
|
|
|
- It returns nil, nil when app or app.Config is nil, or when app.Config.Bool("golem15.user.jwt.token_cookie") is false, without reading auth.cookie.
|
|
|
|
|
- Otherwise it calls tokenCookieConfig(app) and returns the address of the config, or the error.
|
|
|
|
|
- Its doc comment calls it the golem15.sociallogin token_cookie twin, and says a nil config means the token goes into the body.
|
|
|
|
|
- In Login, Register and ActivateByCode, right after the sessionDeps check and before anything else, call cc, err := sessionCookie(app). On error, log pluginLog.Error("golem15.user: token cookie config", "err", err), call writeOpaque500(w) and return.
|
|
|
|
|
- Then replace each route's final token write with a writeSession call. Pass the same non-token keys the route answers today, and let writeSession add the token key when cc is nil:
|
|
|
|
|
- Login: writeSession(w, app, cc, token, map[string]any{"user": payload}).
|
|
|
|
|
- Register: in the token branch only, writeSession(w, app, cc, token, map[string]any{"user": payload}). The user-mode and admin-mode answers stay as they are.
|
|
|
|
|
- ActivateByCode: writeSession(w, app, cc, token, map[string]any{"message": "Account activated", "user": payload}).
|
|
|
|
|
- Extend each of the three doc comments by one sentence: with golem15.user.jwt.token_cookie on, the token goes into the auth.cookie session cookie instead of the body.
|
|
|
|
|
- Do not touch api_tokens.go, sessionDeps, mintFor or IssueToken. gofmt the file.
|
|
|
|
|
|
|
|
|
|
config/config.yaml: in the jwt block, after blacklist_sweep_interval, add token_cookie: false (two-space indent). Put a comment above it saying three things: on, POST /login, /register (when it signs the account in) and /activate-by-code set the HttpOnly session cookie from auth.cookie.* and leave token out of the body; a POST /refresh carried by that cookie rewrites it either way; keep auth.cookie.name token or auth_token.
|
|
|
|
|
|
|
|
|
|
README.md, in the same commit:
|
|
|
|
|
- Configuration table: a row after golem15.user.jwt.blacklist_sweep_interval, reading | `golem15.user.jwt.token_cookie` | `false` | followed by a meaning cell. The cell says that when on, `POST /login`, `POST /register` (when it signs the new account in) and `POST /activate-by-code` set the HttpOnly session cookie with the `auth.cookie.*` attributes (`bouncer.LoadCookieConfig`; Max-Age `refresh_ttl`) and leave `token` out of the body (`user`, and `message` on activate-by-code, stay). When off, the token is in the body and no cookie is set. It is the twin of `golem15.sociallogin.token_cookie`.
|
|
|
|
|
- JWT sessions bullet, after the Task 1 refresh sentences, add sentences that say:
|
|
|
|
|
- what golem15.user.jwt.token_cookie does on the three routes (see the config row);
|
|
|
|
|
- SameSite stays the `auth.cookie` setting (`lax` by default) and there is no CSRF token, so do not set `same_site: none` for this cookie;
|
|
|
|
|
- with the key on, a broken `auth.cookie` block answers those routes with the opaque 500 before they sign in, create or activate anything;
|
|
|
|
|
- personal API tokens (`POST /tokens`) are not sessions and are unaffected.
|
|
|
|
|
- In the Task 1 parenthesis naming who may have written the cookie, add this plugin's `golem15.user.jwt.token_cookie`.
|
|
|
|
|
- Testing: after the TestRefreshRewritesAuthCookie paragraph, add one paragraph naming `TestTokenCookie` and its cases: off by default, on for the three routes with the cookie serving `/fetch` and a cookie refresh, `POST /tokens` unaffected, a configured cookie name and refresh window, and a broken `auth.cookie` block that signs in, creates and activates nothing.
|
|
|
|
|
|
|
|
|
|
Run go vet, the targeted tests, go test -short and the full Docker-backed suite of the plugin. TestLogin, TestRegister and TestActivateByCode must pass unchanged; they are the default-off byte-shape pins.
|
|
|
|
|
|
|
|
|
|
Consumer cross-check, read-only: from /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app run go test -count=1 ./plugins/golem15/sociallogin/... ./plugins/golem15/oauthserver/... (go.work points them at this checkout, which carries both commits). Record the result in the SUMMARY.
|
|
|
|
|
- If a failure names /login, /refresh, a session cookie or a body token of the user plugin, stop and fix it in this task.
|
|
|
|
|
- Record any other failure as unrelated, and do not patch those repos.
|
|
|
|
|
- Do not stage anything in sm-demo-app.
|
|
|
|
|
|
|
|
|
|
Commit in the plugin repo only, staging by path. Run git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user add controllers/api_controller.go config/config.yaml token_cookie_test.go README.md, then commit with git -C on the same path. The subject:
|
|
|
|
|
feat: opt-in golem15.user.jwt.token_cookie sets the HttpOnly session cookie on /login, /register and /activate-by-code instead of returning the token
|
|
|
|
|
The body is one short paragraph: default off, so body-token clients are unchanged; when on, the cookie carries the auth.cookie attributes via bouncer.LoadCookieConfig and the body keeps user (and message); SameSite stays Lax, with no CSRF token; personal API tokens are untouched.
|
|
|
|
|
|
|
|
|
|
Add no trailer of any kind. Do not push or tag. Record the short SHA in the SUMMARY.
|
|
|
|
|
</action>
|
|
|
|
|
<verify>
|
|
|
|
|
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && test -z "$(gofmt -l controllers/api_controller.go token_cookie_test.go)" && GOWORK=off go vet ./... && GOWORK=off go test -count=1 -run '^(TestTokenCookie|TestRefreshRewritesAuthCookie|TestLogin|TestLoginSixthAttempt|TestLoginRequiresActivation|TestRegister|TestActivateByCode|TestSessionRoutesAcceptCookies)$' . && grep -qF 'func sessionCookie(app *backpack.App) (*bouncer.CookieConfig, error)' controllers/api_controller.go && grep -qF 'Bool("golem15.user.jwt.token_cookie")' controllers/api_controller.go && L=$(awk '/^func Login\(/,/^}/' controllers/api_controller.go) && grep -qF 'sessionCookie(app)' <<<"$L" && grep -qF 'writeSession(w, app, cc, token, ' <<<"$L" && ! grep -qE '"token": +token' <<<"$L" && G=$(awk '/^func Register\(/,/^}/' controllers/api_controller.go) && grep -qF 'sessionCookie(app)' <<<"$G" && grep -qF 'writeSession(w, app, cc, token, ' <<<"$G" && ! grep -qE '"token": +token' <<<"$G" && grep -qF '"Activation email sent"' <<<"$G" && A=$(awk '/^func ActivateByCode\(/,/^}/' controllers/api_controller.go) && grep -qF 'sessionCookie(app)' <<<"$A" && grep -qF 'writeSession(w, app, cc, token, ' <<<"$A" && ! grep -qE '"token": +token' <<<"$A" && awk '/^jwt:/{f=1;next} /^[^ #]/{f=0} f' config/config.yaml | grep -qxF ' token_cookie: false' && grep -qF '| `golem15.user.jwt.token_cookie` | `false` |' README.md && grep -qF 'TestTokenCookie' README.md && GOWORK=off go test -short -count=1 ./... && GOWORK=off go test -count=1 ./... && test -z "$(git status --short)" && C=$(git log -1 --format=%H --fixed-strings --grep='opt-in golem15.user.jwt.token_cookie' master) && test -n "$C" && test "$C" = "$(git rev-parse master)" && test "$(git rev-parse "$C^")" = "$(git log -1 --format=%H --fixed-strings --grep='rewrites that cookie, so an HttpOnly session survives' master)" && B=$(git log -1 --format=%B "$C") && ! grep -qi 'co-authored-by' <<<"$B" && git diff --quiet "$C^" "$C" -- go.mod go.sum controllers/api_tokens.go && F=$(git diff --name-only "$C^" "$C") && test "$(LC_ALL=C sort <<<"$F" | tr '\n' ' ')" = "README.md config/config.yaml controllers/api_controller.go token_cookie_test.go " && test -z "$(git tag --points-at "$C")" && test -z "$(git branch -r --contains "$C")" && ! git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app diff --cached --name-only | grep -qxF plugins/golem15/user && test -z "$(git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app log --format=%h 18597e5..HEAD -- plugins/golem15/user)" && git -C /media/nvme/dev/golem15/summercms.io/summercms/summercms.go diff --quiet HEAD -- modules/bouncer</automated>
|
|
|
|
|
</verify>
|
|
|
|
|
<done>TestTokenCookie failed at the Task 1 commit (failure line recorded) and now passes. With the key off, the three routes answer today's bodies and set no cookie, and TestLogin, TestRegister and TestActivateByCode pass unchanged. With it on, they set the HttpOnly auth.cookie cookie (LoadCookieConfig attributes, Max-Age refresh_ttl) and leave token out of the body. The cookie serves /fetch and rotates through /refresh, and POST /tokens is untouched. A broken auth.cookie answers the opaque 500 before any sign-in, account or activation. config.yaml and README document the key. go vet, go test -short and the full suite are green, and the sociallogin/oauthserver cross-check result is recorded. A second commit (four files) sits on top of Task 1's: no trailer, go.mod, go.sum and api_tokens.go unchanged, untagged, unpushed. The tree is clean; sm-demo-app and summercms.go modules/bouncer are untouched.</done>
|
|
|
|
|
</task>
|
|
|
|
|
|
|
|
|
|
<task type="auto">
|
|
|
|
|
<name>Task 3: fill item 7 and reword the G3 refresh sentence in the escalation note, add the todo's Outcome, and commit both in summercms.go</name>
|
|
|
|
|
<files>.planning/notes/2026-10-22-external-user-plugin-escalations.md, .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md</files>
|
|
|
|
|
<precondition>Task 2's verify passed: sm-user-plugin master carries both commits. git -C /media/nvme/dev/golem15/summercms.io/summercms/summercms.go diff --quiet HEAD -- on the note and the todo succeeds (no other session is editing them).</precondition>
|
|
|
|
|
<action>
|
|
|
|
|
Get the two short SHAs from /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user:
|
|
|
|
|
- S1 = git log -1 --format=%h --fixed-strings --grep='rewrites that cookie, so an HttpOnly session survives' master
|
|
|
|
|
- S2 = git log -1 --format=%h --fixed-strings --grep='opt-in golem15.user.jwt.token_cookie' master
|
|
|
|
|
|
|
|
|
|
Edit .planning/notes/2026-10-22-external-user-plugin-escalations.md with Edit, using scoped replacements only, never a whole-file Write.
|
|
|
|
|
|
|
|
|
|
Tracker row 7, using rows 1-3 as the format:
|
|
|
|
|
- The Quick / todo cell becomes [261022-wah](../quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/) · [todo](../todos/pending/2026-10-22-user-login-opt-in-session-cookie.md).
|
|
|
|
|
- Status becomes done (unreleased).
|
|
|
|
|
- Shipped becomes `S1`, `S2` → v0.5.0.
|
|
|
|
|
- Label, Verdict and g15office stay.
|
|
|
|
|
|
|
|
|
|
Verdicts:
|
|
|
|
|
- **G3.**: replace only its third sentence, the one about /refresh and cookies, with: Item 7 (`S1`) made a `/refresh` carried by the `auth.cookie` cookie rewrite it; bearer- and `auth_token`-carried refreshes stay body-only. The other sentences stay.
|
|
|
|
|
- **7.**: in the "Done locally" format, it becomes: Done locally (`S1`, `S2`). `S1`: a `/refresh` carried by the `auth.cookie` cookie (`token` by default, no bearer) rewrites it and answers `{}`, whatever `token_cookie` says; bearer and other-cookie refreshes stay body-only; refusals write no cookie. `S2`: `golem15.user.jwt.token_cookie` (default off) makes /login, /register (auto sign-in) and /activate-by-code set the HttpOnly `auth.cookie` cookie via `bouncer.LoadCookieConfig` and omit body `token`; off keeps today's bodies. SameSite=Lax, no CSRF; API tokens untouched.
|
|
|
|
|
|
|
|
|
|
Reply to send, G3 paragraph: in its "Ships in SummerCMS v0.5.0 (sm-user-plugin 8e4b863, …)" line, keep everything up to the last sentence. Replace that sentence (it begins "A cookie-carried /refresh returns") with: A /refresh carried by the session cookie named in auth.cookie (token by default) rewrites that cookie with the rotated token and answers {} (see "Cookie on /login" below). A /refresh carried by the Authorization header, or by the auth_token cookie, returns the rotated token in the body and writes no cookie; the previous token is revoked, so store the new one yourself.
|
|
|
|
|
|
|
|
|
|
Reply to send, "Cookie on /login": keep the "Agreed as an opt-in. …" line. Replace the TODO line under it with one line in the G2-G4 format:
|
|
|
|
|
Ships in SummerCMS v0.5.0 (sm-user-plugin S1 and S2, tagged with that wave): set golem15.user.jwt.token_cookie: true (default false) and POST /login, POST /register (when it signs the new account in) and POST /activate-by-code set the HttpOnly session cookie with the auth.cookie attributes (bouncer.LoadCookieConfig: name token, path /, SameSite=Lax, Secure from app.url, Max-Age the refresh window) and leave token out of the body; user (and message) stay. A POST /refresh carried by that cookie rewrites it with the rotated token and answers {}; a bearer refresh is unchanged. With the key off nothing changes. SameSite stays Lax and there is no CSRF token. Keep auth.cookie.name at token or auth_token, the cookies the session routes read.
|
|
|
|
|
|
|
|
|
|
In both reply lines, substitute the real S1 and S2, unquoted, as G2-G4 do. Leave item 8's TODO line and everything else in the note unchanged.
|
|
|
|
|
|
|
|
|
|
Edit the todo .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md. Insert an "## Outcome (2026-10-22)" section after the "## Note from G3 (2026-10-22)" paragraph and before "## Invoke", in the format of 2026-10-22-user-logout-blacklist-missing-under-serve.md. It is one paragraph that:
|
|
|
|
|
- names quick `261022-wah`, the two commits `S1` and `S2` on sm-user-plugin master and their base `a484d0e`;
|
|
|
|
|
- describes S1: /refresh rewrites the auth.cookie cookie when it carried the token without a bearer header, answers {} and keeps bearer and auth_token refreshes body-only; classes.SessionTokenCookie is the new export; TestRefreshRewritesAuthCookie and TestSessionTokenCookie cover it;
|
|
|
|
|
- describes S2: golem15.user.jwt.token_cookie on /login, /register and /activate-by-code through writeSession; the auth.cookie config is loaded before any side effect, so a broken block answers the opaque 500; README and config.yaml updated; TestTokenCookie covers it;
|
|
|
|
|
- ends with: Reply note item 7 and the G3 refresh sentence filled. Not tagged, not pushed: ships with the v0.5.0 wave.
|
|
|
|
|
|
|
|
|
|
Leave the todo in pending/.
|
|
|
|
|
|
|
|
|
|
Commit exactly these two files with a pathspec commit, so nothing else in the index is included:
|
|
|
|
|
git -C /media/nvme/dev/golem15/summercms.io/summercms/summercms.go commit -m "docs: fill item 7 in the external-escalation reply" -- .planning/notes/2026-10-22-external-user-plugin-escalations.md .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md
|
|
|
|
|
|
|
|
|
|
No trailer. Do not touch STATE.md, state.json, the PLAN, the SUMMARY or any other file; the orchestrator commits those and handles STATE and g15office. Do not push.
|
|
|
|
|
</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-login-opt-in-session-cookie.md && P=/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && S1=$(git -C "$P" log -1 --format=%h --fixed-strings --grep='rewrites that cookie, so an HttpOnly session survives' master) && S2=$(git -C "$P" log -1 --format=%h --fixed-strings --grep='opt-in golem15.user.jwt.token_cookie' master) && test -n "$S1" && test -n "$S2" && grep -qE '^\| 7 \| Cookie option on `/login` \| Feature \| \[261022-wah\]\(\.\./quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/\) · \[todo\].*\| strnsccy \| done \(unreleased\) \| `[0-9a-f]{7,}`, `[0-9a-f]{7,}` → v0\.5\.0 \|$' "$N" && grep -qF "\`$S1\`, \`$S2\` → v0.5.0" "$N" && grep -qF "**7.** Done locally (\`$S1\`, \`$S2\`)" "$N" && grep -qF "Item 7 (\`$S1\`)" "$N" && grep -qF "Ships in SummerCMS v0.5.0 (sm-user-plugin $S1 and $S2, tagged with that wave)" "$N" && grep -qF 'golem15.user.jwt.token_cookie' "$N" && grep -qF 'rewrites that cookie with the rotated token' "$N" && ! grep -qF 'does not rewrite a cookie' "$N" && ! grep -qF 'does not rewrite the cookie' "$N" && test "$(grep -c '^TODO' "$N")" = 1 && grep -qF 'TODO: ships in SummerCMS v0.5.0 — config key <name>.' "$N" && grep -qF 'Ships in SummerCMS v0.5.0 (sm-user-plugin 8e4b863, tagged with that wave)' "$N" && grep -qF 'Ships in SummerCMS v0.5.0 (sm-user-plugin 4bf44fc, tagged with that wave)' "$N" && grep -q '^## Outcome' "$T" && awk '/^## Note from G3/{g=NR} /^## Outcome/{o=NR} /^## Invoke/{i=NR} END{exit !(g && o && i && g<o && o<i)}' "$T" && grep -qF "$S1" "$T" && grep -qF "$S2" "$T" && grep -qF 'a484d0e' "$T" && test ! -e .planning/todos/done/2026-10-22-user-login-opt-in-session-cookie.md && C=$(git log -1 --format=%H --fixed-strings --grep='docs: fill item 7 in the external-escalation reply') && test -n "$C" && git merge-base --is-ancestor ec69f54 "$C" && F=$(git show --name-only --format= "$C") && test "$(LC_ALL=C sort <<<"$F" | tr '\n' ' ')" = "$N $T " && B=$(git log -1 --format=%B "$C") && ! grep -qi 'co-authored-by' <<<"$B" && git diff --quiet HEAD -- "$N" "$T" && test -z "$(git branch -r --contains "$C")"</automated>
|
|
|
|
|
</verify>
|
|
|
|
|
<done>Escalation note row 7 reads done (unreleased) with `S1`, `S2` → v0.5.0 and links the quick and the todo. Verdict 7 says "Done locally" with both SHAs, and the G3 verdict's refresh sentence names item 7. The G3 reply now says a refresh carried by the auth.cookie cookie rewrites it while bearer and auth_token refreshes stay body-only. The Cookie-on-/login reply carries the "Ships in SummerCMS v0.5.0 (sm-user-plugin S1 and S2, tagged with that wave)" line naming golem15.user.jwt.token_cookie. Only item 8's TODO line remains. The todo has an Outcome section between Note from G3 and Invoke, naming both commits, the base and the tests, and it is still in pending/. One docs commit in summercms.go touches exactly those two files: no trailer, not pushed. Nothing else in summercms.go was touched.</done>
|
|
|
|
|
</task>
|
|
|
|
|
|
|
|
|
|
</tasks>
|
|
|
|
|
|
|
|
|
|
<threat_model>
|
|
|
|
|
## Trust Boundaries
|
|
|
|
|
|
|
|
|
|
| Boundary | Description |
|
|
|
|
|
|----------|-------------|
|
|
|
|
|
| browser JavaScript to the session token | a token in a JSON body or a JS-readable cookie is exposed to any script on the page (XSS) |
|
|
|
|
|
| browser to /refresh and the cookie-carried session routes | the cookie travels on requests the browser makes on its own, including cross-site ones subject to SameSite |
|
|
|
|
|
| request carriers (Authorization header, token and auth_token cookies) | an untrusted request may carry several tokens; which one is rotated and which cookie is written must follow one fixed order |
|
|
|
|
|
| operator configuration (auth.cookie.*, golem15.user.jwt.token_cookie) | an invalid block must fail loudly without half-done account changes |
|
|
|
|
|
|
|
|
|
|
## STRIDE Threat Register
|
|
|
|
|
|
|
|
|
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
|
|
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
|
|
|
| T-wah-01 | Information disclosure | session token in the /login, /register, /activate-by-code and cookie-refresh bodies | medium | mitigate | With token_cookie on, the token goes only into the HttpOnly cookie (writeSession omits the body key; Task 2). A cookie-carried refresh answers {} (Task 1). TestTokenCookie and TestRefreshRewritesAuthCookie assert the exact key sets. |
|
|
|
|
|
| T-wah-02 | Spoofing | cross-site requests riding the session cookie (CSRF) on /refresh and the signed-in routes | medium | accept | Locked L-4: SameSite stays Lax (the auth.cookie default, from LoadCookieConfig only), which keeps the cookie off cross-site POSTs; no CSRF token; change-password still needs the current password. The README says not to set same_site none for this cookie. |
|
|
|
|
|
| T-wah-03 | Tampering | carrier confusion: rotating one token and writing it into another carrier's cookie, or writing over an app-owned cookie | medium | mitigate | SessionTokenCookie mirrors SessionToken's order through one shared bearerToken parser, and returns "" whenever a bearer supplied the token. Refresh rewrites only when that carrier equals auth.cookie.name. Tests: bearer wins over a cookie, auth_token stays body-only, a configured name, and the TestSessionTokenCookie table. |
|
|
|
|
|
| T-wah-04 | Elevation of privilege | an HttpOnly cookie left holding a rotated (blacklisted) jti after /refresh, so the session dies or the client resends a dead token | high | mitigate | Task 1 rewrites the auth.cookie cookie on every successful cookie-carried refresh. The rotated cookie is proven to authenticate and the old token to be refused. Refusals write nothing, so no cookie gets a token that was not issued. |
|
|
|
|
|
| T-wah-05 | Denial of service | a broken auth.cookie block half-completing a request (account created, activation code consumed, login recorded, token rotated) before answering 500 | medium | mitigate | QD-4: the three routes load the config right after sessionDeps, and /refresh loads it before bouncer.Refresh. TestTokenCookie checks that no users row exists, the user is not activated with the code still usable, and last_seen and the throttle row are unwritten. TestRefreshRewritesAuthCookie checks that the token still refreshes by bearer. |
|
|
|
|
|
| T-wah-06 | Denial of service | a core plugin's existing body-token clients broken by the change | high | mitigate | token_cookie defaults to false, and sessionCookie then never reads auth.cookie. writeSession with a nil config writes the same key set as today, so the bytes are unchanged. Bearer and auth_token refreshes are unchanged and the bearer path never reads auth.cookie. TestLogin, TestRegister, TestActivateByCode and the "off by default" subtest pin it. The only changed answer (the token-cookie refresh) was never released (QD-6). |
|
|
|
|
|
| T-wah-07 | Information disclosure | a session cookie without Secure on an https site | low | transfer | bouncer.LoadCookieConfig derives Secure from app.url and refuses a Secure that resolves to false in production; the plugin passes those attributes through unchanged. Operators keep app.url correct. |
|
|
|
|
|
| T-wah-SC | Tampering | npm/pip/cargo/Go module installs | low | accept | No dependency is added; go.mod and go.sum are unchanged (both plugin commit gates check it). No package-legitimacy checkpoint is needed. |
|
|
|
|
|
</threat_model>
|
|
|
|
|
|
|
|
|
|
<verification>
|
|
|
|
|
- sm-user-plugin: GOWORK=off go vet ./..., GOWORK=off go test -short -count=1 ./... and GOWORK=off go test -count=1 ./... are green at both commits.
|
|
|
|
|
- TestRefreshRewritesAuthCookie was red at a484d0e and is green at the first commit. TestTokenCookie was red at the first commit and is green at the second.
|
|
|
|
|
- The sociallogin and oauthserver suites, run through sm-demo-app's go.work against this checkout, have their result recorded in the SUMMARY.
|
|
|
|
|
- Two local commits on sm-user-plugin master: no trailer, not pushed, not tagged, go.mod/go.sum unchanged. The sm-demo-app pointer is neither staged nor committed; summercms.go modules/bouncer is unmodified.
|
|
|
|
|
- One docs commit in summercms.go touches exactly the escalation note and the todo.
|
|
|
|
|
</verification>
|
|
|
|
|
|
|
|
|
|
<success_criteria>
|
|
|
|
|
- An application can opt in with golem15.user.jwt.token_cookie: true and keep the session in an HttpOnly cookie across sign-in, registration, activation by code, refresh and fetch, with no token in any body.
|
|
|
|
|
- With the key off, every existing response of the plugin is byte-identical, apart from the never-released token-cookie refresh answer.
|
|
|
|
|
- A refresh rotates exactly the cookie that carried the token when that cookie is the auth.cookie cookie, and nothing else; bearer refreshes never read auth.cookie.
|
|
|
|
|
- Item 7 is recorded as done (unreleased) with both SHAs, the G3 refresh wording is corrected, and the reply is ready apart from item 8.
|
|
|
|
|
</success_criteria>
|
|
|
|
|
|
|
|
|
|
<output>
|
|
|
|
|
Create `.planning/quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/261022-wah-SUMMARY.md` in summercms.go when done. Include:
|
|
|
|
|
- both plugin commits' short SHAs and the base;
|
|
|
|
|
- both RED failure lines;
|
|
|
|
|
- the suite results and the consumer cross-check result;
|
|
|
|
|
- the docs commit SHA in summercms.go;
|
|
|
|
|
- a confirmation that nothing was pushed, tagged or pointer-bumped.
|
|
|
|
|
|
|
|
|
|
Do not commit the SUMMARY; the orchestrator commits it with the PLAN and STATE.
|
|
|
|
|
</output>
|