🐛 Re-key session when X-Auth-Request identity changes#18
Conversation
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>
There was a problem hiding this comment.
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-authzto 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-authzreturns 403 before reaching the session-match branch andcapturedremains 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/sessionfromsession/authz. Downstream code that reads the session object (for example password change invalidates other sessions viasession/get-session) will operate on Alice's stale session while::session/profile-idsays 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.
| (let [alice (th/create-profile* 1) | ||
| bob (th/create-profile* 2) |
| (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))))))))) |
| ;; 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" |
| ;; 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")))) |
|
Pressingly/plane#29 @copilot use this PR also for reference. |
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>
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>
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. |
There was a problem hiding this comment.
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)]
| ;; 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)) |
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>
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>
There was a problem hiding this comment.
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)]
| (if (and session (renew-session? session)) | ||
| (if (and session | ||
| (renew-session? session) | ||
| (< (get response :status 200) 400) |
| response (-> request | ||
| (assoc ::session/profile-id (:id profile)) | ||
| handler)] |
There was a problem hiding this comment.
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.
| (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 |
There was a problem hiding this comment.
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>
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>
Agent-Logs-Url: https://github.com/Pressingly/penpot/sessions/8bb70191-b81f-401c-9ff4-f33c74c775fc Co-authored-by: awais786 <445320+awais786@users.noreply.github.com>
Aligned with the Plane reference in e850a4f and kept the same approach through follow-up hardening in f06d71b. |
f06d71b to
a3d95c0
Compare
There was a problem hiding this comment.
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)))
| (let [create-session! (session/create-fn cfg profile) | ||
| response (-> request | ||
| (dissoc ::session/session-id | ||
| ::session/session) |
| request (dissoc request | ||
| ::session/profile-id | ||
| ::session/session-id | ||
| ::session/session) |
| {::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}) |
| ;; Issue a fresh auth-token cookie; replaces the stale one | ||
| ;; the browser still has (if any). | ||
| (create-session! request response))))))))) |
| profile (try | ||
| (get-or-register-profile cfg email fullname) | ||
| (catch Throwable cause | ||
| (l/err :hint "x-auth-request: error resolving profile" | ||
| :email email |
There was a problem hiding this comment.
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.
Agent-Logs-Url: https://github.com/Pressingly/penpot/sessions/b389792f-d18f-4b49-9241-455c18e728c0 Co-authored-by: awais786 <445320+awais786@users.noreply.github.com>
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>
d902bdf to
f1f1a6f
Compare
There was a problem hiding this comment.
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/sessionrather than the:app.http.session/idkey read bysession/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])))))
| (catch Throwable cause | ||
| (l/err :hint "x-auth-request: error resolving profile" | ||
| :email email | ||
| :cause cause) | ||
| {:status :error | ||
| :cause cause})) |
| (let [delete-session! (session/delete-fn cfg) | ||
| request (clear-stale-session request) | ||
| response (handler request)] | ||
| (delete-session! request response))) |
There was a problem hiding this comment.
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.
| [request] | ||
| (dissoc request | ||
| ::session/profile-id | ||
| ::session/session-id |
| (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))) |
There was a problem hiding this comment.
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.
| shorthand) and `::http/auth-data.claims.uid` (the raw decoded cookie | ||
| claims, used by errors.clj and access_token.clj). Leaving auth-data |
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>
|
Security follow-up: Copilot's review surfaced a real, pre-existing security issue while reviewing this PR's
Replay path: captured cookie → 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 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 |
Summary
wrap-authz(X-Auth-Request middleware) used to bail the momentwrap-sessionhad set::session/profile-id. Once Penpot's ownauth-tokencookie was issued, the upstreamX-Auth-Request-Emailwas no longer consulted._oauth2_proxycookie and Cognito SSO session but not Penpot'sauth-tokencookie onpaint.<domain>— refreshing the Penpot tab kept serving the previous user.auth-tokencookie on the response, replacing the stale browser cookie.auth-tokencookie viasession/delete-fn. The request continues unauthenticated (downstream handlers respond with 401/redirect-to-login per their own rules) rather than as the previous user.session.cljensures the stale session cookie is NOT renewed on the denial response.::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)
paint.<domain>) in a tab.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/authzmiddleware renews the incoming cookie when the session is due for renewal. Without an additional guard it would overwritewrap-authz's freshly-issued re-key cookie (or extend the stale session on a 4xx denial). The guard now requires all of:sessionpresentrenew-session?is true(or (nil? status) (< status 400))against qualified::yres/statusauth-tokencookieIf 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 newauth-tokencookiex-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 theauth-tokencookie viasession/delete-fn. Replaces the earlierpreserves-session-…test; the new invariant is "drop & expire," not "preserve."x-auth-request-rekey-not-overwritten-by-session-renewal— integration throughsession/authz+wrap-auth, with a renewal-threshold-due seeded cookie; asserts the re-keyed token survives outer renewalsession-authz-does-not-renew-on-error-response— the status guard exercised against a 403 responseauth-tokencookie 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