Skip to content

fix: actually invalidate the session on OAuth logout - #211

Open
heskew wants to merge 5 commits into
mainfrom
security/f2-f4-identity-session
Open

fix: actually invalidate the session on OAuth logout#211
heskew wants to merge 5 commits into
mainfrom
security/f2-f4-identity-session

Conversation

@heskew

@heskew heskew commented Aug 24, 2026

Copy link
Copy Markdown
Member

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 (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 and clearOAuthSession runs — the stored hdb_session record kept authenticating until cookie TTL.

Fix: invalidate by persisting session.update({ user: null, oauth: null, oauthUser: null }), mirroring Harper's own logout() (security/auth.ts:381). The next request resolves session.user to no user. Falls back to an in-memory clear only when no .update exists (non-session transports).

Tests updated across handlers / sessionValidator / withOAuthValidation — several had encoded the inverted delete-based model (asserting session.delete(id) and in-memory clears that never actually persisted). Full suite: 1157 unit + 15 integration, 0 fail.

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

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

1 blocker found.

1. In-memory session state inconsistency in production path

File: src/lib/handlers.ts:546
What: The updated clearOAuthSession persists an invalidation via session.update({ user: null }) in the production path but does NOT clear the in-memory session fields (user, oauth, oauthUser) for the remainder of the current request.
Why it matters: As noted in the new documentation in src/lib/withOAuthValidation.ts, this causes the onValidationError callback and any requireAuth: false resources to observe a stale "authenticated" session state for the remainder of the current request, even though the session has been invalidated in the database. This violates the goal of "actually invalidate the session" and creates a regression in consistency compared to the fallback path (and the previous version of the code which always cleared in-memory state). The inconsistency is even explicitly pinned by a new test case in test/lib/withOAuthValidation.test.js:1186.
Suggested fix: Move the in-memory field clearing (setting session.user = null and deleting session.oauth/oauthUser) outside the else block so it runs in both the production and fallback paths.

Suggestions (non-blocking)

  • src/lib/withOAuthValidation.ts:117Use clearOAuthSession for persistence and consistency. clearStaleOAuth currently only clears fields in memory. Consider importing clearOAuthSession from ./handlers.ts and updating this helper to call it. This ensures that "stale provider" paths (e.g. when a provider is removed from config) also benefit from the new database-level invalidation, preventing the malformed session from being reloaded on the next request.
  • src/lib/withOAuthValidation.ts:117Nullify session.user in clearStaleOAuth. To ensure requireAuth: false resources observe a truly anonymous state when validation fails, request.session.user (the Harper identity) should also be nullified in the current request. (Note: using clearOAuthSession as suggested above would resolve this automatically).

Comment thread src/lib/handlers.ts Outdated

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

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.

heskew and others added 3 commits August 24, 2026 11:46
… 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
@heskew heskew closed this Aug 25, 2026
@heskew heskew reopened this Aug 25, 2026
Comment thread src/lib/handlers.ts
* 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.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 BlockerIn-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.

Comment thread src/lib/handlers.ts
Comment on lines +564 to +569
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 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread src/lib/handlers.ts
// 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 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

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.

1 participant