Skip to content

fix: treat undecryptable user rows as not-found#208

Merged
cdtinney merged 2 commits into
mainfrom
cdtinney/skip-bad-tokens
Apr 29, 2026
Merged

fix: treat undecryptable user rows as not-found#208
cdtinney merged 2 commits into
mainfrom
cdtinney/skip-bad-tokens

Conversation

@cdtinney

Copy link
Copy Markdown
Owner

Summary

Follow-up to #207. When pool.query returns a users row whose tokens can't be decrypted, toUser() throws inside findUserBySpotifyId. That throw bubbles through passport's deserializeUser, which runs on every authenticated request — so the user 500s on every endpoint, including /api/auth/user/logout. The cookie holds them in a state they can't escape from in-app, and the only recovery is clearing cookies in DevTools or wiping the session table on the droplet (#207's script).

This catches the decrypt error in findUserBySpotifyId and returns null instead. Passport sees no user, treats the session as anonymous, and the next interaction redirects to OAuth, where findOrCreateUser's ON CONFLICT … DO UPDATE rewrites the row with freshly-encrypted tokens. A warning is logged so the recovery is visible in droplet logs.

How a row gets into this state:

  • Legacy plaintext row left over from before feat: encrypt Spotify OAuth tokens at rest, harden session #204 (the immediate trigger for this fix).
  • Row encrypted under a since-rotated TOKEN_ENCRYPTION_KEYSUPABASE.md already documents that rotating the key forces re-auth; this PR makes the re-auth happen automatically instead of via 500s.

The fix only touches the read path. findOrCreateUser and updateUserAccessTokenBySpotifyId write freshly-encrypted tokens before calling toUser on the RETURNING row, so they can't hit this path in normal flow and don't need the same treatment.

Testing

  • New userQueries.test.ts covers three cases: no row → null, valid row → user, decrypt-throws → null.
  • pnpm vitest run server suite: 53 tests pass (was 50 before this PR; the 3 pre-existing test-file load failures are unrelated to this change — they fail on main too, due to passport-spotify requiring credentials at strategy construction).
  • pnpm typecheck clean. pnpm lint clean for new files.

When pool.query returns a row whose tokens can't be decrypted (e.g. a
legacy plaintext row left over from before encryption-at-rest, or any
row encrypted under a since-rotated key), toUser() throws inside
findUserBySpotifyId. That throw bubbles through passport's
deserializeUser, which runs on every authenticated request — so the
user 500s on every endpoint they hit, including /api/auth/user/logout.
The cookie holds them in a state they can't escape from in-app.

Catch the decrypt error and return null instead. Passport sees no
user, treats the session as anonymous, and the next interaction
redirects to OAuth, where findOrCreateUser's ON CONFLICT … DO UPDATE
rewrites the row with freshly-encrypted tokens. We log a warning so
the recovery is visible in droplet logs.
Mirror the logger.warn spy pattern from tokenCrypto.test.ts so the
recovery is visibly logged. Without this assertion the test only
covered the return value, leaving the warn() call (which is how the
condition would surface in droplet logs) uncovered.
@cdtinney cdtinney force-pushed the cdtinney/skip-bad-tokens branch from 0f4c168 to 52f0476 Compare April 29, 2026 04:31
@cdtinney cdtinney enabled auto-merge (squash) April 29, 2026 04:32
@cdtinney cdtinney merged commit 9575865 into main Apr 29, 2026
6 checks passed
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