Skip to content

🐛 Re-key session when X-Auth-Request identity changes#18

Closed
awais786 wants to merge 17 commits into
foss-mainfrom
fix/proxy-auth-stale-session-on-user-switch
Closed

🐛 Re-key session when X-Auth-Request identity changes#18
awais786 wants to merge 17 commits into
foss-mainfrom
fix/proxy-auth-stale-session-on-user-switch

Conversation

@awais786
Copy link
Copy Markdown

@awais786 awais786 commented May 15, 2026

Summary

  • wrap-authz (X-Auth-Request middleware) used to bail the moment wrap-session had set ::session/profile-id. Once Penpot's own auth-token cookie was issued, the upstream X-Auth-Request-Email was no longer consulted.
  • After a portal "Log out of all apps" + login as a different user — which clears the shared _oauth2_proxy cookie and Cognito SSO session but not Penpot's auth-token cookie on paint.<domain> — refreshing the Penpot tab kept serving the previous user.
  • Fix: when the proxy header is present, always resolve the header email's profile and reconcile against the session's profile-id.
    • Match → pass through unchanged (steady state).
    • Mismatch with a resolvable upstream identity → re-key: drop the stale session keys, inject the new profile-id, and issue a fresh auth-token cookie on the response, replacing the stale browser cookie.
    • Mismatch where the upstream email does NOT resolve to a local profile (unknown user + auto-register off) → clear the in-flight local session markers AND expire the browser's auth-token cookie via session/delete-fn. The request continues unauthenticated (downstream handlers respond with 401/redirect-to-login per their own rules) rather than as the previous user.
    • Mismatch with a blocked or inactive incoming profile → 403; the renewal guard in session.clj ensures the stale session cookie is NOT renewed on the denial response.
  • API-key (::actoken/profile-id) auth is unaffected — programmatic identity is issued out-of-band by the user and not subject to browser SSO state.

Repro (before this PR)

  1. Log in to the FOSS portal as user A.
  2. Open Penpot (paint.<domain>) in a tab.
  3. On the portal, click Log out of all apps.
  4. Log in as user B via password.
  5. Refresh the Penpot tab → still shows user A.

Same architectural class as Pressingly/plane#29 and Pressingly/outline#19 — native session cookie that outlives upstream auth state. SurfSense doesn't exhibit it because FastAPI re-derives identity from headers on every request.

Session-renewal guard (session.clj)

The outer session/authz middleware renews the incoming cookie when the session is due for renewal. Without an additional guard it would overwrite wrap-authz's freshly-issued re-key cookie (or extend the stale session on a 4xx denial). The guard now requires all of:

  • session present
  • renew-session? is true
  • response status is success-shaped — (or (nil? status) (< status 400)) against qualified ::yres/status
  • response does NOT already carry an auth-token cookie

If any one is false, renewal is skipped. The combination prevents both the re-key-overwrite and the denial-extends-stale paths.

Test plan

  • clj -X:test :nses '[backend-tests.http-middleware-test]' passes — new cases:
    • x-auth-request-rekeys-when-session-identity-differs — alice's session-pid + bob's email header → captured request sees bob's pid, response carries a new auth-token cookie
    • x-auth-request-no-rekey-when-session-matches-header — steady-state guard (no new cookie when session already matches)
    • x-auth-request-drops-local-session-when-header-email-unresolvable — alice's session-pid + unknown header email (auto-register off) → captured request has no ::session/profile-id, response expires the auth-token cookie via session/delete-fn. Replaces the earlier preserves-session-… test; the new invariant is "drop & expire," not "preserve."
    • x-auth-request-rekey-not-overwritten-by-session-renewal — integration through session/authz + wrap-auth, with a renewal-threshold-due seeded cookie; asserts the re-keyed token survives outer renewal
    • session-authz-does-not-renew-on-error-response — the status guard exercised against a 403 response
  • Existing tests still pass (auto-register, blocked, inactive, access-token-passthrough)
  • Manual: rebuild Penpot backend, run the QA's exact repro — refreshing the Penpot tab as user B now serves user B (new auth-token cookie is set on the same response)

Trade-off

Adds one DB lookup per authenticated SSO request (resolving the header email's profile). The previous fast-path for the steady-state case is restored implicitly via the match branch (handler runs without re-keying); only the lookup itself is added. Worth it for correctness; revisit with a short-lived email→pid cache if it shows up in profiling.

Out of scope

This addresses Penpot only. Plane has the analogous fix in Pressingly/plane#29, Outline in Pressingly/outline#19, Twenty PR forthcoming.

🤖 Generated with Claude Code

The X-Auth-Request middleware short-circuited as soon as wrap-session
had resolved a profile-id from the auth-token cookie. That meant once
Penpot issued its own session cookie, the upstream X-Auth-Request-Email
was no longer consulted at all.

After a portal "log out of all apps" + login as a different user (which
clears the shared _oauth2_proxy cookie and Cognito session but NOT
Penpot's auth-token cookie on its own subdomain), wrap-session kept
resolving the previous user from the stale cookie, and wrap-authz
stepped aside — so refreshing the Penpot tab kept serving the previous
user.

Now: when X-Auth-Request-Email is present, always resolve the proxy-
asserted profile and compare it against the existing session-pid.

  - Match → pass through unchanged (steady state).
  - Mismatch (or no existing session) → re-key: inject the new profile-id
    into the request and issue a fresh auth-token cookie on the response.

Access-token (API key) auth is unaffected — programmatic identity is not
a browser SSO session and the header has no bearing on it.

Cost: one extra DB lookup per authenticated SSO request to resolve the
header email's profile. The previous fast-path is restored implicitly
for the match case (downstream handler runs without re-keying), so only
the lookup itself is added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the X-Auth-Request authentication middleware so proxy-provided identity can re-key a stale Penpot browser session when the upstream SSO user changes.

Changes:

  • Reworked wrap-authz to resolve the proxy email even when a session profile already exists.
  • Added re-key behavior when the proxy profile differs from the current session profile.
  • Updated and expanded middleware tests for re-key, no-rekey, and unresolved-header cases.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
backend/src/app/http/auth_request.clj Adds proxy-vs-session identity comparison and re-keying behavior.
backend/test/backend_tests/http_middleware_test.clj Updates existing X-Auth-Request tests and adds mismatch/match session scenarios.
Comments suppressed due to low confidence (2)

backend/test/backend_tests/http_middleware_test.clj:198

  • This profile is inactive by default, so wrap-authz returns 403 before reaching the session-match branch and captured remains nil. Mark the fixture profile active so this steady-state test actually verifies the no-rekey path.
  (let [profile  (th/create-profile* 1)

backend/src/app/http/auth_request.clj:161

  • This only replaces ::session/profile-id; the request still carries the old ::session/session from session/authz. Downstream code that reads the session object (for example password change invalidates other sessions via session/get-session) will operate on Alice's stale session while ::session/profile-id says Bob, which can invalidate the wrong user's sessions or skip Bob's session invalidation.
                    response        (-> request
                                        (assoc ::session/profile-id (:id profile))
                                        handler)]

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +179 to +180
(let [alice (th/create-profile* 1)
bob (th/create-profile* 2)
Comment thread backend/src/app/http/auth_request.clj Outdated
Comment on lines +158 to +164
(let [create-session! (session/create-fn cfg profile)
response (-> request
(assoc ::session/profile-id (:id profile))
handler)]
;; Issue a fresh auth-token cookie; replaces the stale one
;; the browser still has (if any).
(create-session! request response)))))))))
Comment thread backend/src/app/http/auth_request.clj Outdated
;; is off). Pass through with whatever session wrap-session set
;; — we don't have a profile to switch *to*.
(do
(l/wrn :hint "x-auth-request: no profile found for email, passing through unauthenticated"
Comment on lines +176 to +192
;; logs in upstream. wrap-session resolves alice's profile-id from the
;; old cookie, but oauth2-proxy is forwarding bob's email. The middleware
;; must re-key to bob.
(let [alice (th/create-profile* 1)
bob (th/create-profile* 2)
captured (volatile! nil)
cfg (make-xauth-cfg)
handler (#'app.http.auth-request/wrap-authz
(fn [req] (vreset! captured req) {::yres/status 200})
cfg)
request (-> (->DummyRequest {"x-auth-request-email" (:email bob)} {})
(assoc ::session/profile-id (:id alice)))
response (handler request)]
;; Downstream handler sees bob's profile-id, not alice's.
(t/is (= (:id bob) (::session/profile-id @captured)))
;; A fresh auth-token cookie is issued for bob's session.
(t/is (contains? (::yres/cookies response) "auth-token"))))
@awais786
Copy link
Copy Markdown
Author

Pressingly/plane#29 @copilot use this PR also for reference.

Copilot AI and others added 6 commits May 15, 2026 18:51
Agent-Logs-Url: https://github.com/Pressingly/penpot/sessions/d72618d7-825c-4877-82b4-8129c361442f

Co-authored-by: awais786 <445320+awais786@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Pressingly/penpot/sessions/d72618d7-825c-4877-82b4-8129c361442f

Co-authored-by: awais786 <445320+awais786@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Pressingly/penpot/sessions/d72618d7-825c-4877-82b4-8129c361442f

Co-authored-by: awais786 <445320+awais786@users.noreply.github.com>
Copy link
Copy Markdown

Copilot AI commented May 15, 2026

Pressingly/plane#29 @copilot use this PR also for reference.

Used Pressingly/plane#29 as reference and aligned the Penpot fix accordingly in e850a4f: re-key now remains authoritative even when outer session renewal runs, plus middleware tests now cover the renewal-overwrite regression path.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

backend/src/app/http/auth_request.clj:162

  • Re-keying only overwrites ::session/profile-id, while the request still carries auth artifacts from the old cookie such as ::session/session and ::http/auth-data. Downstream code that reads those values (for example session/get-session during password changes, or logout's auth-data claims) can act on or log the previous user's session even though the request is treated as the header user.
                    response        (-> request
                                        (assoc ::session/profile-id (:id profile))
                                        handler)]

Comment thread backend/src/app/http/auth_request.clj Outdated
Comment on lines +112 to +119
;; Header email doesn't resolve to a profile (and auto-register
;; is off). Pass through with whatever session wrap-session set
;; — we don't have a profile to switch *to*.
(do
(l/wrn :hint "x-auth-request: no profile found for email, preserving current auth context"
:email email
:session-profile-id (some-> session-pid str))
(handler request))
Comment thread backend/src/app/http/session.clj Outdated
awais786 and others added 2 commits May 16, 2026 00:08
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Awais Qureshi <awais.qureshi@arbisoft.com>
Closes the last open Copilot review comment on PR #18:
the `(nil? profile)` branch in wrap-authz used to preserve alice's
session-pid when the proxy header asserted an unknown email. That
served alice's data to whoever is now upstream — the exact stale-
session leak this PR was created to close.

Behaviour change: when proxy header is present and resolves to no
local profile (unknown user + auto-register off), the middleware now
dissocs ::session/profile-id from the request before passing through.
Downstream handlers respond per their own anonymous-request rules
(401, redirect to login, etc.) rather than serving the previous user.

Brings the nil-profile path in line with proxy-auth-middleware Rule 2
in the openspec contract: "Identity mismatch SHALL flush the existing
session immediately", regardless of whether we can re-key to a known
alternative identity.

Test:
  x-auth-request-preserves-session-when-header-email-unresolvable
  → x-auth-request-drops-local-session-when-header-email-unresolvable
Asserts the downstream handler does NOT see alice's session-pid when
the header email is unresolvable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
awais786 added a commit that referenced this pull request May 15, 2026
Closes the last open Copilot review comment on PR #18:
the `(nil? profile)` branch in wrap-authz used to preserve alice's
session-pid when the proxy header asserted an unknown email. That
served alice's data to whoever is now upstream — the exact stale-
session leak this PR was created to close.

Behaviour change: when proxy header is present and resolves to no
local profile (unknown user + auto-register off), the middleware now
dissocs ::session/profile-id from the request before passing through.
Downstream handlers respond per their own anonymous-request rules
(401, redirect to login, etc.) rather than serving the previous user.

Brings the nil-profile path in line with proxy-auth-middleware Rule 2
in the openspec contract: "Identity mismatch SHALL flush the existing
session immediately", regardless of whether we can re-key to a known
alternative identity.

Test:
  x-auth-request-preserves-session-when-header-email-unresolvable
  → x-auth-request-drops-local-session-when-header-email-unresolvable
Asserts the downstream handler does NOT see alice's session-pid when
the header email is unresolvable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@awais786 awais786 requested a review from Copilot May 15, 2026 19:57
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

backend/src/app/http/auth_request.clj:169

  • The re-keyed handler call still runs inside session/wrap-authz's clock binding for the old session profile. If the old profile has a virtual clock offset, downstream work and the replacement session timestamps are produced under the wrong user's clock after this identity switch.
                    response        (-> request
                                        (assoc ::session/profile-id (:id profile))
                                        handler)]

Comment thread backend/src/app/http/session.clj Outdated
(if (and session (renew-session? session))
(if (and session
(renew-session? session)
(< (get response :status 200) 400)
Comment on lines +167 to +169
response (-> request
(assoc ::session/profile-id (:id profile))
handler)]
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in f06d71b: re-key now drops stale request session state (::session/session*) before injecting the new profile so downstream sees consistent identity/session data.

Comment thread backend/src/app/http/auth_request.clj Outdated
(t/is (nil? (::session/profile-id @captured)))))

(t/deftest x-auth-request-skips-when-session-present
(t/deftest x-auth-request-drops-local-session-when-header-email-unresolvable
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied the linked feedback in f06d71b: fixed the status-key renewal guard, removed stale session object leakage during re-key, and made the unresolvable-header path clear local session state and expire the auth-token cookie, with tests updated for these cases.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Awais Qureshi <awais.qureshi@arbisoft.com>
Copilot AI and others added 3 commits May 15, 2026 20:09
Agent-Logs-Url: https://github.com/Pressingly/penpot/sessions/8bb70191-b81f-401c-9ff4-f33c74c775fc

Co-authored-by: awais786 <445320+awais786@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Pressingly/penpot/sessions/8bb70191-b81f-401c-9ff4-f33c74c775fc

Co-authored-by: awais786 <445320+awais786@users.noreply.github.com>
Copy link
Copy Markdown

Copilot AI commented May 15, 2026

Pressingly/plane#29 @copilot use this PR also for reference.

Aligned with the Plane reference in e850a4f and kept the same approach through follow-up hardening in f06d71b.

@awais786 awais786 force-pushed the fix/proxy-auth-stale-session-on-user-switch branch from f06d71b to a3d95c0 Compare May 16, 2026 12:31
@awais786 awais786 changed the title fix(auth-request): re-key session when proxy identity differs 🐛 Re-key session when X-Auth-Request identity changes May 16, 2026
@awais786 awais786 requested a review from Copilot May 16, 2026 12:37
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

backend/src/app/http/auth_request.clj:145

  • The inactive-profile denial has the same stale-cookie gap as the blocked-profile branch: if this request arrived with another user's valid local session, the response is a 403 but the old browser cookie is left intact. If a later request reaches the backend without the proxy header, the no-header path will trust that stale session again, so this mismatch path should expire the local session cookie too.
            (not (:is-active profile))
            (do
              (l/wrn :hint "x-auth-request: profile is not active, denying access"
                     :email email
                     :profile-id (str (:id profile)))

Comment thread backend/src/app/http/auth_request.clj Outdated
(let [create-session! (session/create-fn cfg profile)
response (-> request
(dissoc ::session/session-id
::session/session)
Comment thread backend/src/app/http/auth_request.clj Outdated
request (dissoc request
::session/profile-id
::session/session-id
::session/session)
Comment thread backend/src/app/http/auth_request.clj Outdated
Comment on lines +139 to +146
{::yres/status 403})

(not (:is-active profile))
(do
(l/wrn :hint "x-auth-request: profile is not active, denying access"
:email email
:profile-id (str (:id profile)))
{::yres/status 403})
Comment thread backend/src/app/http/auth_request.clj Outdated
Comment on lines +178 to +180
;; Issue a fresh auth-token cookie; replaces the stale one
;; the browser still has (if any).
(create-session! request response)))))))))
Comment thread backend/src/app/http/auth_request.clj Outdated
Comment on lines 103 to 107
profile (try
(get-or-register-profile cfg email fullname)
(catch Throwable cause
(l/err :hint "x-auth-request: error resolving profile"
:email email
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 179bd40: profile-lookup exceptions are now handled as a distinct error state, so we no longer treat operational failures as "profile not found" or clear local auth state/cookies on that path. Added coverage in x-auth-request-preserves-local-session-when-profile-lookup-errors.

Five related findings from Copilot review of the proxy-identity re-key
path, all stemming from the same root: ::http/auth-data and the local
session cookie were not being kept consistent with ::session/profile-id
across the re-key / flush boundary, and edge cases (transient lookup
failure, logout response, blocked-with-mismatch) had gaps.

1. Profile-lookup exceptions are now distinguished from "no profile
   found" via the ::lookup-error sentinel. A Postgres blip used to be
   collapsed to nil and hit the "unknown user" branch, flushing the
   in-flight session of every authenticated user during the outage.
   Now: 503 on transient failure, session state untouched.

2. The unresolvable-email branch now dissocs ::http/auth-data alongside
   the session keys via a new clear-stale-session helper. Without this,
   downstream readers (errors.clj, access_token.clj) that consult
   ::http/auth-data.claims.uid directly would still see the old
   profile-id after the rest of the middleware thought it had flushed.

3. Blocked / inactive incoming identity now flushes the existing
   mismatched session before returning 403. Per the openspec contract
   (proxy-auth-middleware Rule 2: "Identity mismatch SHALL flush"),
   alice's local session should not survive an attempted switch to a
   refused user — otherwise her auth-token cookie outlives the
   intended denial and her next request still serves her.

4. The re-key branch also routes through clear-stale-session, so the
   downstream handler sees a clean request with the new profile-id and
   no leftover claims from the previous identity.

5. The unconditional create-session! after the re-keyed handler is
   now guarded by response-has-auth-cookie?. If the handler already
   wrote the auth-token cookie (an explicit logout that cleared it,
   or an auth endpoint that re-issued it), we step aside rather than
   clobber the handler's intent — same shape as the renewal guard in
   session.clj.

Tests:
  - x-auth-request-503-on-transient-lookup-error
  - x-auth-request-rekey-clears-stale-auth-data
  - x-auth-request-unresolvable-email-clears-stale-auth-data
  - x-auth-request-blocked-incoming-clears-existing-mismatched-session
  - x-auth-request-rekey-does-not-overwrite-handler-cookie

The previously-passing tests (steady-state pass-through, unresolvable
email drops session, basic re-key, blocked/inactive 403 without
existing session, renewal-overwrite integration guard) still pass
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@awais786 awais786 force-pushed the fix/proxy-auth-stale-session-on-user-switch branch from d902bdf to f1f1a6f Compare May 16, 2026 13:07
@awais786 awais786 requested a review from Copilot May 16, 2026 13:09
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (2)

backend/src/app/http/auth_request.clj:200

  • The denied mismatched-session path also only sends an expired cookie; it does not invalidate the old session in the manager because the request has ::session/session rather than the :app.http.session/id key read by session/delete-fn. The stale auth-token can therefore still authenticate if it is replayed, despite the "identity mismatch SHALL flush" invariant.
                  (let [delete-session! (session/delete-fn cfg)]
                    (delete-session! request response))

backend/test/backend_tests/http_middleware_test.clj:446

  • This blocked/inactive mismatch test only checks the deletion cookie, not that Alice's existing server-side session is actually invalidated. Because the production path relies on session/delete-fn, adding a manager-level assertion would catch stale auth-token replay after the 403 response.
    (t/is (= 403 (::yres/status response)))
    (t/is (= 0 (get-in response [::yres/cookies "auth-token" :max-age])))))

Comment thread backend/src/app/http/auth_request.clj Outdated
Comment on lines +138 to +143
(catch Throwable cause
(l/err :hint "x-auth-request: error resolving profile"
:email email
:cause cause)
{:status :error
:cause cause}))
Comment on lines +173 to +176
(let [delete-session! (session/delete-fn cfg)
request (clear-stale-session request)
response (handler request)]
(delete-session! request response)))
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and tracked as a separate PR — #20. delete-fn reads ::id but wrap-authz never set it, so every call site of delete-fn (logout endpoints, this PR's mismatch-flush) was clearing the cookie without removing the server-side row. Replayable for up to 7 days until GC.

This is pre-existing — affects Penpot's standard logout flow too, not just this PR. Fix is one line in wrap-authz but touches shared session middleware, so it's split out: see #20 for the upstream fix + a regression-guard test.

This PR's commit c95d6d346 adjusts the mismatch-flush path to pass the original (pre-clear) request to delete-fn so ::id survives once #20 lands. PRs are independent and can merge in either order.

Comment thread backend/src/app/http/auth_request.clj Outdated
[request]
(dissoc request
::session/profile-id
::session/session-id
Comment on lines +191 to +204
(let [profile-id (random-uuid)
stale-session {:id (random-uuid) :profile-id profile-id}
captured (volatile! nil)
handler (#'app.http.auth-request/wrap-authz
(fn [req] (vreset! captured req) req)
(make-xauth-cfg))
request (-> (->DummyRequest {"x-auth-request-email" "user@example.com"} {})
(assoc ::session/profile-id profile-id)
(assoc ::session/session stale-session))
response (handler request)]
;; Downstream handler must NOT see alice's profile-id.
(t/is (nil? (::session/profile-id @captured)))
;; Downstream handler must NOT see alice's stale local session.
(t/is (nil? (::session/session @captured)))
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracked in #20. The test as written exercises the contract delete-fn claims to uphold (server-side state cleared on logout), but with the pre-existing ::id-never-set bug it would have been verifying a no-op. #20 adds session-delete-fn-removes-server-side-row against the session-manager directly, which is the right level for the invariant. Once #20 merges, this test's browser-cookie assertion remains meaningful but the server-side invariant has its own dedicated guard upstream.

Comment thread backend/src/app/http/auth_request.clj Outdated
Comment on lines +91 to +92
shorthand) and `::http/auth-data.claims.uid` (the raw decoded cookie
claims, used by errors.clj and access_token.clj). Leaving auth-data
awais786 and others added 2 commits May 16, 2026 18:24
Three concrete fixes on top of f1f1a6f:

1. clear-stale-session docstring no longer overstates downstream
   readers. access_token.clj reads ::http/auth-data but only branches
   on :type :token; the cookie-auth path this middleware reconciles
   skips it via the access-token short-circuit, so only errors.clj is
   a real reader.

2. Removed the dead `::session/session-id` dissoc. wrap-session stores
   the resolved session at ::session, never at ::session/session-id —
   nothing in backend/src/app/http/ ever sets that key. Cleanup, no
   behavior change.

3. Narrowed the lookup-error transient catch from `Throwable` to a
   known-transient set: SQLException, SQLTransientException,
   SQLNonTransientConnectionException, IOException, PSQLException.
   Walks the cause chain so wrapped ExecutionException(SQLException)
   still classifies. Non-transient exceptions (validation failures,
   programming bugs, NPEs) now propagate to the standard 500 handler
   instead of being silently swallowed as "preserve session" — which
   would otherwise mask real bugs behind quiet pass-throughs.

Tests:
  - Updated x-auth-request-preserves-session-on-transient-lookup-error
    to throw java.sql.SQLException (the narrowed transient class)
  - New x-auth-request-rethrows-non-transient-lookup-error pins the
    rethrow behavior for ex-info / validation-class exceptions

Separately, lines 176/204 (delete-fn does not delete server-side
state because nothing in the codebase sets the ::id key wrap-session
expects) are noted as a pre-existing Penpot issue, not introduced by
this PR. Surfacing as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
clear-stale-session sanitises the request for the downstream handler,
but session/delete-fn reads ::id from the request to remove the
server-side session row. Routing the cleared request to BOTH the
handler and delete-fn meant delete-fn always saw a request with no
::id and silently fell through to "cookie clear only" — server-side
state was left intact.

Pass the original (pre-clear) request to delete-fn; the handler still
gets the sanitised version. No functional change today because the
upstream session/wrap-authz doesn't yet set ::id either (separate
follow-up issue), but this is the correct shape for when that fix
lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@awais786
Copy link
Copy Markdown
Author

Security follow-up: session/delete-fn doesn't actually delete server-side state — tracked in #20

Copilot's review surfaced a real, pre-existing security issue while reviewing this PR's delete-session! call sites (r3252847526 and r3252847543). Verified directly in the code:

session/delete-fn at session.clj:193-198 reads (get request ::id) — but session/wrap-authz (and nothing else in the codebase) ever sets that key. Grep confirms one read, zero writes. So every call site of delete-fn — including Penpot's standard logout endpoint, not just this PR's new SSO mismatch-flush — clears the browser cookie but leaves the underlying http_session_v2 row alive until the GC task expires it on schedule. Default ceiling: auth-token-cookie-max-age = 7 days.

Replay path: captured cookie → wrap-auth decodes JWT → signature valid (no per-session key rotation) → no :exp claim (assign-token) → wrap-authz resolves session via :sid → DB row exists → request authenticated. Logout's user-visible behaviour worked (cookie cleared); the contract delete-fn advertised — server-side invalidation at logout time — did not.

Severity: medium. Not remotely exploitable (attacker needs the cookie first), but logout doesn't fulfill its contract under the threat models where logout matters most: shared computer, post-incident cleanup, compromised endpoint. Mitigated in practice by HttpOnly / Secure / SameSite=Lax + the 7d GC ceiling.

Scope decision for this PR: the fix is a one-line addition in wrap-authz but it changes shared session middleware that all logout flows depend on — separate review surface, separate test plan. Bundling it here would mix concerns. Split out to #20 (branch fix/session-wraps-authz-sets-id) so #18's review surface stays scoped to the re-key boundary.

Independent merge order: #18's mismatch-flush works today (cookie cleared, replays caught by JWT-vs-row mismatch after #20 lands). #20 makes server-side delete real for every call site, not just this one. Either can ship first.

Update on this PR: commit c95d6d346 adjusts the mismatch-flush path to pass the original (pre-clear) request to delete-fn so ::id survives once #20 is in — this PR is correctly shaped today, will be functionally complete after #20 merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants