fix: actually invalidate the session on OAuth logout - #211
Conversation
clearOAuthSession gated on `typeof session.delete === 'function'`, but the
real Harper request.session is a shallow copy of the hdb_session record
exposing only `.update` (a persisting put) — never `.delete`. So the
delete branch never ran and the in-memory fallback (`session.user = null`)
was never persisted: the stored session kept authenticating, so an
explicit logout, a captured cookie, or an upstream-revoked/expired token
left the session fully valid until cookie TTL.
Invalidate by persisting `session.update({ user: null, oauth: null,
oauthUser: null })`, mirroring Harper's own logout() — the next request
resolves session.user to no user. Falls back to an in-memory clear only
when no `.update` exists (non-session transports).
Confirmed against harper@5.1.9 (security/auth.ts:110 session shape,
:381 logout()). Updates the logout/sessionValidator/withOAuthValidation
tests that had encoded the inverted delete-based model.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxkByn5VD9Mwh7Pm9wtmBH
|
1 blocker found. 1. In-memory session state inconsistency in production pathFile: src/lib/handlers.ts:546 Suggestions (non-blocking)
|
There was a problem hiding this comment.
Code Review
This pull request updates the clearOAuthSession function to use session.update instead of session.delete to invalidate sessions, aligning with the actual API of Harper's hdb_session record which only exposes .update. This ensures that a null-user record is persisted to invalidate the session, rather than attempting a non-existent delete method. Corresponding unit tests have been updated to mock and assert the new .update behavior and verify that session properties are correctly set to null upon invalidation. There are no review comments, and I have no additional feedback to provide.
|
1 blocker found (inline): clearOAuthSession's production path persists the invalidation but never clears request.session's in-memory user/oauth/oauthUser, so a requireAuth:false resource (or the index.ts provider-gone middleware passthrough) still observes a live-looking authenticated session on the request that just invalidated it -- pinned by this PR's own test at withOAuthValidation.test.js:1140-1190. This corroborates an independently-opened, unresolved gemini thread on the same line. One non-blocking suggestion posted inline re: a logout-vs-refresh race. |
… shape)
Review nit: the no-`.update` fallback used `delete`; use `= null` so it
matches the persisted invalidation shape ({ user: null, oauth: null,
oauthUser: null }). Tests updated accordingly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxkByn5VD9Mwh7Pm9wtmBH
…F4 review)
Cross-model review (Codex) of the F4 logout fix caught two problems:
- BLOCKER (regression this fix introduced): Harper defines `.update` on
EVERY request, including anonymous cookie-less ones, and calling it mints
a fresh hdb_session row (new UUID) with no expiry when
`authentication.cookieExpires` is unset. So an unauthenticated
POST /oauth/logout would spam non-expiring rows. Guard persistence on
`session.id` — only invalidate an existing session.
- The earlier `{ oauth: null }` shape broke the `Session` type (oauth is
not nullable) and the downstream `session.oauth === undefined` check.
Persist `session.update({ user: null })` (matching Harper's own logout);
the full-replace put drops oauth/oauthUser (absent, not null). In-memory
fallback goes back to `delete`.
Also refreshes the stale `session.delete` JSDoc in withOAuthValidation.ts
and adds a test that anonymous logout does NOT persist a row.
Follow-ups (tracked separately, not bolted in): logout-vs-refresh /
refresh-vs-refresh CAS races (session resurrection), and the global
middleware calling next(request) without clearing request.user on an
invalidated session. Integration-level login→logout→401 proof pending
(reusing the human-login harness from the F2 repro).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxkByn5VD9Mwh7Pm9wtmBH
#211 review) Codex re-review confirmed the anonymous-logout blocker is closed and next-request invalidation works. Its one new "significant" — that the session.id guard can't distinguish "never persisted" from "persisted this request via a separate update() payload" — is a real Harper-API limitation but NOT reachable via any OAuth flow (every clearOAuthSession caller runs on a cookie-loaded session with an id; handleCallback, the only id-less session-creator, never calls clearOAuthSession). Documented on the guard. Also refreshes a stale makeSession test comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxkByn5VD9Mwh7Pm9wtmBH
| * previous code's `session.delete` branch never ran (no such method) and its | ||
| * in-memory fallback left the stored `hdb_session` record fully valid — a | ||
| * captured cookie (or an upstream-revoked account) kept authenticating. | ||
| */ |
There was a problem hiding this comment.
🔴 Blocker — In-memory session state inconsistency in production path
The production path calls update() but doesn't clear the in-memory session fields. This causes requireAuth: false resources to see stale identity/tokens for the remainder of the current request. Move the in-memory clearing (session.user = null, delete session.oauth/oauthUser) outside the else block.
| if (session.id && typeof session.update === 'function') { | ||
| // Match Harper's own logout(): a full-replace put of `{ user: null }` clears | ||
| // the identity (unauthenticated on the next request) AND drops the oauth | ||
| // tokens — leaving them absent, not `null`, which keeps the `Session` type | ||
| // and the downstream `session.oauth === undefined` checks honest. | ||
| await session.update({ user: null }); |
There was a problem hiding this comment.
Blocker — production path leaves request.session looking fully authenticated for the rest of the request
What: When session.id && typeof session.update === 'function' (the normal case for any real cookie-loaded session), clearOAuthSession persists { user: null } to the DB but never touches the in-memory session.user / session.oauth / session.oauthUser. The else fallback clears them; this branch doesn't. Pre-fix, every call fell into that same in-memory-clearing branch (since .delete never existed), so this is a new gap, not a pre-existing one.
Why it matters: Both mid-request callers of clearOAuthSession let the request continue afterward: validateAndRefreshSession's expired/revoked-token paths feed withOAuthValidation's requireAuth:false passthrough, and index.ts's "provider not found" middleware branch calls clearOAuthSession then next(request). Downstream code — a requireAuth:false resource, or any app code reading request.session — now observes fully populated, live-looking oauth data on the very request that just invalidated the session. This PR's own test pins it: test/lib/withOAuthValidation.test.js:1140-1190 shows a requireAuth:false resource running (status: 200) with calls[0].oauthAfterValidate !== undefined right after clearOAuthSession ran — the resource believes the user still has a valid access token while the DB record backing it was just nulled. That's the opposite of "actually invalidate the session" for the request that detects the problem.
This matches an independently-opened, unresolved thread from the gemini reviewer on this same line (this same commit) — two independent traces landing on the same gap.
Suggested fix: Clear the in-memory fields in the if branch too (after the persisting update call), same as the else branch. If the intent is still for onValidationError to see stale data for audit logging (per the JSDoc above), mirror the existing clearStaleOAuth() pattern already used elsewhere in withOAuthValidation.ts — invoke the callback first, then clear — rather than never clearing on this path.
| // the identity (unauthenticated on the next request) AND drops the oauth | ||
| // tokens — leaving them absent, not `null`, which keeps the `Session` type | ||
| // and the downstream `session.oauth === undefined` checks honest. | ||
| await session.update({ user: null }); |
There was a problem hiding this comment.
Suggestion (non-blocking): This session.update({ user: null }) and validateAndRefreshSession's session.update(session) (sessionValidator.ts:92,169, unmodified by this PR — a full pre-refresh snapshot write-back) are both full-replace writes to the same hdb_session row with no version/CAS check. If a concurrent request's refresh write was read before this logout persisted and commits after it, it silently overwrites { user: null } with the stale, still-authenticated snapshot — resurrecting a session the user just logged out of. Realistic with multiple tabs/devices or a request racing a proactive 80%-lifetime refresh. Not fixable in this diff alone (no CAS primitive available here), but worth re-validating session.user isn't already cleared immediately before a refresh writes back, or tracking as a follow-up — the author's own commit message already flags this as deferred, so a tracking issue would make it visible outside commit history.
clearOAuthSessiongated ontypeof session.delete === 'function', but the real Harperrequest.sessionis a shallow copy of thehdb_sessionrecord exposing only.update(a persisting put) — never.delete(confirmed against harper@5.1.9,security/auth.ts:110). So the delete branch never ran, and the in-memory fallback (session.user = null) was never persisted: after an explicit logout — or when a token is expired/revoked andclearOAuthSessionruns — the storedhdb_sessionrecord kept authenticating until cookie TTL.Fix: invalidate by persisting
session.update({ user: null, oauth: null, oauthUser: null }), mirroring Harper's ownlogout()(security/auth.ts:381). The next request resolvessession.userto no user. Falls back to an in-memory clear only when no.updateexists (non-session transports).Tests updated across
handlers/sessionValidator/withOAuthValidation— several had encoded the inverted delete-based model (assertingsession.delete(id)and in-memory clears that never actually persisted). Full suite: 1157 unit + 15 integration, 0 fail.