OAuth state service
Source: src/auth/oauth-state.service.ts
Table: oauth_states
Storage and cleanup primitives for the OAuth 2.0 PKCE + state flow. Phase 1 ships only the storage layer. Phase 2 adds the HTTP routes (/auth/:platform/start, /auth/:platform/callback) that drive these helpers.
What “state” and “PKCE” guard against
| Threat | Defence |
|---|---|
| CSRF on the OAuth callback (an attacker tricks a user into completing their flow with the user’s browser) | The state parameter — random 32 bytes, server-generated, single-use. Mismatch = reject. |
| Authorization-code interception on the wire (e.g. a malicious mobile app intercepts the redirect) | PKCE: the server commits to a code_verifier upfront and sends only its SHA-256 (code_challenge) to the auth provider. The token exchange must present the original verifier. |
API
createOAuthState(tenantId, platform): Promise<{ state, codeVerifier, codeChallenge }>
consumeOAuthState(state): Promise<row> // throws if unknown / expired / already-consumed
startOAuthStateCleanup(): NodeJS.Timeout // background interval, replaced by Inngest in Phase 4
_cleanupOAuthStatesNow(): Promise<void> // test helper, runs the cleanup synchronouslyLifecycle of one row
createOAuthState() INSERT (state, code_verifier, tenant_id, platform, expires_at = now + 10m)
│
▼
consumeOAuthState(state):
DELETE WHERE state = ? AND expires_at > now() RETURNING *
│
├─ row returned → delivered to caller (single-use enforced)
└─ no row → throw "Invalid or expired OAuth state"If consumeOAuthState is called twice for the same state, the second call gets zero rows back (DELETE is the consumption) and throws. Replay attacks are not possible.
Cleanup
A 5-minute interval runs DELETE ... WHERE expires_at < now(). This is best-effort — even if it never ran, expired rows would still be unconsumable thanks to the expires_at > now() clause in consumeOAuthState. The interval is purely about keeping the table small.
Phase 4 replaces the in-process interval with an Inngest cron so the cleanup runs once globally, not once per Node process.
Tests
tests/integration/oauth-state.test.ts:
createwrites a row;consumereturns and deletes it.- Second
consumefor the same state throws. - Expired rows are removed by
_cleanupOAuthStatesNow.