feat(security): encrypt account OAuth tokens at rest - #7232
Conversation
️✅ There are no secrets present in this pull request anymore.If these secrets were true positive and are still valid, we highly recommend you to revoke them. 🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
c49745d to
0a09630
Compare
Greptile SummaryThe PR introduces versioned AES-256-GCM encryption for OAuth tokens stored in the account table, with mixed-format reads and feature-gated writes.
Confidence Score: 5/5The PR appears safe to merge because no blocking failure remains within the scope of this follow-up review. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| apps/sim/lib/oauth/account-token-crypto.ts | Defines the versioned OAuth-token envelope, exact format detection, authenticated decryption, and field-selection behavior. |
| apps/sim/lib/oauth/account-tokens.ts | Implements feature-gated token writes and mixed-format, failure-isolated token reads. |
| apps/sim/lib/oauth/credential-service.ts | Routes account token persistence and credential resolution through the new encryption boundary. |
| apps/sim/lib/auth/auth.ts | Encrypts Better Auth account creates and updates while decrypting token values needed by post-write provider operations. |
| scripts/backfill-account-token-encryption.ts | Adds a resumable batch process for encrypting existing plaintext account-token columns. |
| scripts/check-account-token-access.ts | Adds static checks intended to prevent unmediated account-token reads and writes. |
| apps/sim/app/api/auth/[...all]/route.ts | Blocks Better Auth token endpoints that would bypass the application token-decryption path. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[OAuth provider or refresh flow] --> B[Account token write accessor]
B --> C{Encryption flag enabled and key usable?}
C -->|Yes| D[Wrap tokens in simenc:v1 AES-GCM envelope]
C -->|No| E[Preserve legacy plaintext representation]
D --> F[(Account table)]
E --> F
F --> G[Account token read accessor]
G --> H{Envelope detected?}
H -->|Yes| I[Decrypt token columns]
H -->|No| J[Return legacy plaintext columns]
I --> K[Credential resolution and provider calls]
J --> K
Reviews (4): Last reviewed commit: "feat(security): add the account token en..." | Re-trigger Greptile
3964adb to
c9c37cb
Compare
The Better Auth `account` table stored `access_token`, `refresh_token` and `id_token` in plaintext. It is the credential store for every user-connected integration, so a dump of it was a dump of our customers' third-party data — the last plaintext credential store in the repo, and the largest. Tokens are now stored under a versioned AES-256-GCM envelope, `simenc:v1:<iv>:<ciphertext>:<authTag>`, built on the existing `encryptSecret`/`decryptSecret` primitives. Rollout is safe in both directions. Reads detect the format per value and never consult the flag, so a mixed-format table reads correctly throughout; only writes are gated, behind the AppConfig flag `oauth-token-encryption`, which is off by default. The deploy is therefore inert on arrival and the flag is flipped once every pod carries the tolerant reader. Rolling back is a config change. Self-hosted stays on plaintext until an operator opts in with a valid 64-hex `ENCRYPTION_KEY`; a misconfigured key degrades to plaintext rather than failing a user's OAuth connect. Better Auth's own `account.encryptOAuthTokens` is deliberately not used: it keys off `BETTER_AUTH_SECRET` rather than `ENCRYPTION_KEY`, leaves `idToken` in plaintext despite its docs, decrypts only inside its own endpoints rather than on the direct database reads this app performs, and detects ciphertext by treating any even-length hex string as encrypted — the shape of a real Trello or Airtable token. The rationale is recorded next to the envelope so the two schemes are never confused. Consolidation, because the duplication is what made encryption risky: - Three divergent copies of the token-staleness rule collapse into `refresh-policy.ts`. `getOAuthToken`'s copy omitted the Microsoft proactive-refresh arm, so credentials reached only that way could pass Microsoft's 90-day inactivity deadline and die; unifying fixes that. - `refreshTokenIfNeeded`'s `credential: any` becomes a branded `LoadedOAuthCredential`, which caught three callers passing raw rows at compile time. Two collapse onto the new `resolveAccessTokenForAccount`. - Eleven projection-less `account` reads become `id`/`userId` projections or calls to the existing `getCredentialOwner`. - The Shopify, Instagram and Trello connect flows — three copies of find/update/insert/re-find — share `upsertProviderAccountTokens`, so a new provider cannot store a plaintext token by copying an old flow. `check:account-token-access` enforces the boundary in CI, flagging direct token column reads, projection-less selects, and direct writes to the table. Also fixed along the way: `create.before` and `create.after` both called `fetchSalesforceInstanceUrl` and both prepended the instance-URL marker, so every Salesforce connect made the same live API call twice and stored a double-prefixed `scope`. And Better Auth's `/get-access-token` and `/refresh-token` endpoints, reachable through the catch-all and bypassing `databaseHooks` entirely, are now blocked — nothing in this app calls them. No migration: no query filters or joins on a token value, and the columns are `text`.
c9c37cb to
5ba3257
Compare
There was a problem hiding this comment.
All reported issues were addressed across 30 files
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Fix all with cubic | Re-trigger cubic
- The access audit matched line by line, so any Drizzle chain the formatter wrapped — which is how `db.select().from(account)` is normally written — was invisible to it. Matching over the whole source and mapping offsets back to lines closes that; a projection-less multiline select now fails CI as intended. - A legacy token beginning `simenc:` was classified as an envelope, which left it unencrypted on write and threw `unknown-version` on read, making the credential unavailable. Detection now requires a full versioned header, so such a value stays plaintext. - Encryption failures were caught and the token stored in plaintext. The only expected cause is an unusable key, which the gate already handles, so a throw past it is a real fault; swallowing it would silently break the guarantee the flag reports as on.
Once the flag is on, tokens envelope themselves as they are written or refreshed — but only for rows that get rewritten. A provider that issues no refresh token, and a user who never signs in again, both leave a row in plaintext indefinitely. This closes that tail. Deliberately a manual script rather than a `script-migration`, so it never runs as part of `db:migrate` or a deploy: a self-hosted upgrade is unaffected by its existence and nothing happens until an operator runs it. - Dry run unless `--apply`. Enveloping is not reversible without the key, so the destructive direction is opt-in twice. - Refuses to start unless `ENCRYPTION_KEY` is usable and an AES-GCM round trip agrees with itself, so a misconfigured deployment touches no rows. - Keyset pagination that must advance, so a persistently racing row cannot loop forever. - Compare-and-swap on the exact values read. A token rotated by a concurrent refresh is reported and skipped, never reverted to the stale one. - Never writes `updated_at` — Slack's fan-out version guard, Instagram's minimum token age, connection ordering and "last connected" all read it. `fieldsNeedingEncryption` moves into the crypto module so the bulk job and the live write path share one definition of "already encrypted" rather than drifting. The SQL pre-filter matches `simenc:v%`, not `simenc:%`, to agree with it: a legacy value that merely begins `simenc:` is not ours and must still be enveloped rather than skipped.
Why
The Better Auth
accounttable storedaccess_token,refresh_tokenandid_tokenin plaintext (packages/db/schema.ts:94-96). It is the credential store for every user-connected integration — Google Drive, Slack, GitHub, Salesforce, ~60 connectors — plus OIDC sign-in tokens. A dump of that table was a dump of our customers' third-party data.Every newer credential path already encrypts (
credential.encryptedOauthTokenSet,encryptedServiceAccountKey, MCP OAuth, BYOK, env vars). This was the last plaintext credential store, and the largest.What
Tokens are stored under a versioned AES-256-GCM envelope,
simenc:v1:<iv>:<ciphertext>:<authTag>, built on the existingencryptSecret/decryptSecretprimitives.Reads detect the format per value and never consult the flag, so a mixed-format table reads correctly throughout. Only writes are gated, behind the AppConfig flag
oauth-token-encryption, which is off by default. The deploy is therefore inert on arrival; the flag is flipped once every pod carries the tolerant reader, and rolling back is a config change rather than a data problem.Self-hosted stays on plaintext until an operator opts in. A misconfigured
ENCRYPTION_KEYdegrades to plaintext rather than failing a user's OAuth connect — losing a connection is worse than staying plaintext.No migration. No query filters or joins on a token value (the only predicate anywhere is
isNotNull, which is unaffected), and the columns aretext.Why not Better Auth's
account.encryptOAuthTokensDeliberately rejected, and the rationale is recorded next to the envelope so the two are never confused. It keys off
BETTER_AUTH_SECRETrather thanENCRYPTION_KEY; leavesidTokenin plaintext despite its docs; decrypts only inside its own endpoints, not on the ~20 direct database reads this app performs; and detects ciphertext by treating any even-length hex string as encrypted — which is the shape of a real Trello or Airtable token. The two schemes are mutually exclusive: its detector does not recognise our prefix.Consolidation
The duplication is what made encryption risky, so it went first:
refresh-policy.ts.getOAuthToken's copy omitted the Microsoft proactive-refresh arm, so credentials reached only that way could pass Microsoft's 90-day inactivity deadline and die. Unifying fixes that (called out below as an intentional behaviour change).refreshTokenIfNeeded'scredential: anybecomes a brandedLoadedOAuthCredential, which caught three callers passing raw rows at compile time. Two collapse onto the newresolveAccessTokenForAccount.accountreads becomeid/userIdprojections or calls to the already-existinggetCredentialOwner.upsertProviderAccountTokens, so a new provider cannot store a plaintext token by copying an old flow.Net −21 lines across modified files despite adding encryption.
Guardrail
check:account-token-access(new, incheck:audits) flags direct token-column reads, projection-less selects, and direct writes to the table. The write rule matters most:db.insert(account).values({ accessToken })is exactly how the next connect flow would be written, and no read-side rule catches it.Bugs fixed along the way
create.beforeandcreate.afterboth calledfetchSalesforceInstanceUrland both prepended the instance-URL marker (withSalesforceInstanceScopeprepends unconditionally) — so every Salesforce connect made the same live API call twice and stored a double-prefixedscope.POST /api/auth/get-access-tokenand/refresh-tokenare reachable through the catch-all and readaccountthrough the adapter with nodatabaseHookspass. Nothing in this app calls them; they are now blocked alongside the existing organization/SSO blocks.refreshTokenIfNeededcould return{ accessToken: null }— the parameter wasany, so nothing caught it — and callers forwarded the null to a provider. It now fails as the 401 it always was.Intentional behaviour changes
getOAuthToken. A bug fix, but it means those credentials issue refresh writes that bumpupdated_at; checked against all fourupdated_atconsumers and safe.<→<=on access-token expiry. A token expiring exactly atnowrefreshes.generateId()rather thantrello_${userId}_${Date.now()}. Verified nothing depends on the prefix.Each is pinned by a test.
Out of scope
managed_oauthand service-account credentials (already encrypted, different format, same key — do not unify). Moving Shopify's shop domain out of the overloadedid_tokeninto a scope marker. The backfill for dormant rows, which is a separate manually-run script. Tighteningenv.ts'sENCRYPTION_KEYvalidation frommin(32)to 64-hex, which is a breaking change for existing self-hosters and needs its own release note.Verification
bun run check:audits— 40/40bun run lint:check— 26/26 packagestsc --noEmit— clean@aws-sdk/client-lambdaones (reproduced with this branch stashed) and one unrelated timeout flake that passes in isolation