Skip to content

Commit 91a640c

Browse files
committed
improvement(auth): encrypt stored integration tokens
1 parent e5ad3ae commit 91a640c

14 files changed

Lines changed: 552 additions & 11 deletions

‎apps/sim/lib/core/config/env.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export const env = createEnv({
142142
TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification
143143
ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data
144144
API_ENCRYPTION_KEY: z.string().min(32).optional(), // Dedicated key for encrypting API keys (optional for OSS)
145+
ENCRYPT_ACCOUNT_TOKENS: z.boolean().optional(), // Seal OAuth access/refresh tokens with ENCRYPTION_KEY on write (read by @sim/db). Enable only once every app, worker, and background-task deployment runs a release that reads sealed tokens
145146
INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication
146147
INTERNAL_JWT_SECRET: z.string().min(32).optional(), // Dedicated signing key for internal JWTs (falls back to INTERNAL_API_SECRET); separating limits blast radius if one leaks
147148

‎apps/sim/lib/oauth/credential-revocation.integration.ts‎

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { account, credential, user, workspace } from '@sim/db/schema'
55
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
66
import { createDeferred, type Deferred } from '@sim/testing/helpers/deferred'
77
import { generateId } from '@sim/utils/id'
8-
import { eq, inArray } from 'drizzle-orm'
8+
import { eq, inArray, sql } from 'drizzle-orm'
99
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
1010

1111
const { redisUrl } = await vi.hoisted(async () => {
@@ -81,6 +81,17 @@ async function storedRow(rowId: string) {
8181
return row
8282
}
8383

84+
async function rawTokens() {
85+
const [row] = await db.execute<{ access_token: string | null; refresh_token: string | null }>(
86+
sql`SELECT access_token, refresh_token FROM account WHERE id = ${accountId}`
87+
)
88+
return row
89+
}
90+
91+
function sealWrites(enabled: boolean) {
92+
process.env.ENCRYPT_ACCOUNT_TOKENS = enabled ? 'true' : 'false'
93+
}
94+
8495
async function resolveToken(rowId = accountId) {
8596
return refreshTokenIfNeeded('revocation-integration', await storedRow(rowId), rowId)
8697
}
@@ -177,6 +188,7 @@ beforeEach(async () => {
177188
})
178189

179190
afterEach((context) => {
191+
sealWrites(false)
180192
pausedProvider?.release.resolve()
181193
checks.push({
182194
name: context.task.name,
@@ -453,3 +465,70 @@ describe('OAuth refresh-token revocation against PostgreSQL and Redis', () => {
453465
})
454466
})
455467
})
468+
469+
describe('OAuth refresh chains with sealed account tokens', () => {
470+
async function sealStoredChain() {
471+
sealWrites(true)
472+
await db
473+
.update(account)
474+
.set({ accessToken: 'expired-access', refreshToken: 'issued-refresh' })
475+
.where(eq(account.id, accountId))
476+
}
477+
478+
it('stores tokens sealed and reads them back as plaintext', async () => {
479+
await sealStoredChain()
480+
481+
const raw = await rawTokens()
482+
expect(raw.access_token).toMatch(/^enc:v1:/)
483+
expect(raw.refresh_token).toMatch(/^enc:v1:/)
484+
expect(JSON.stringify(raw)).not.toContain('issued-refresh')
485+
expect(await storedAccount()).toMatchObject({
486+
accessToken: 'expired-access',
487+
refreshToken: 'issued-refresh',
488+
})
489+
})
490+
491+
it('rotates a chain stored before sealing was enabled', async () => {
492+
sealWrites(true)
493+
providerAnswer = REFRESHED
494+
495+
await expect(resolveToken()).resolves.toEqual({ accessToken: 'fresh-access', refreshed: true })
496+
expect(await rawTokens()).toMatchObject({
497+
access_token: expect.stringMatching(/^enc:v1:/),
498+
refresh_token: 'issued-refresh',
499+
})
500+
expect((await storedAccount()).accessToken).toBe('fresh-access')
501+
})
502+
503+
it('rotates a sealed chain on a process that does not seal writes', async () => {
504+
await sealStoredChain()
505+
sealWrites(false)
506+
providerAnswer = REFRESHED
507+
508+
await expect(resolveToken()).resolves.toEqual({ accessToken: 'fresh-access', refreshed: true })
509+
expect(await rawTokens()).toMatchObject({
510+
access_token: 'fresh-access',
511+
refresh_token: expect.stringMatching(/^enc:v1:/),
512+
})
513+
})
514+
515+
it('records a revocation against a sealed chain on a process that does not seal writes', async () => {
516+
await sealStoredChain()
517+
sealWrites(false)
518+
519+
await expect(resolveToken()).rejects.toBeInstanceOf(CredentialRevokedError)
520+
await expect(getCredentialTerminalRefreshError(credentialId)).resolves.toEqual({
521+
errorCode: 'invalid_grant',
522+
providerId: PROVIDER_ID,
523+
})
524+
})
525+
526+
it('keeps a revocation recorded before the chain was sealed', async () => {
527+
await expect(resolveToken()).rejects.toBeInstanceOf(CredentialRevokedError)
528+
await expireRedisFlags()
529+
await sealStoredChain()
530+
531+
await expect(resolveToken()).rejects.toBeInstanceOf(CredentialRevokedError)
532+
expect(providerRequests).toBe(1)
533+
})
534+
})

‎apps/sim/lib/oauth/credential-service.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createHmac, createSign } from 'crypto'
22
import { db } from '@sim/db'
33
import { account, credential } from '@sim/db/schema'
4+
import { sealedTextEquals } from '@sim/db/sealed-text'
45
import { createLogger } from '@sim/logger'
56
import { getPostgresErrorCode, toError } from '@sim/utils/errors'
67
import { and, desc, eq, isNotNull, sql } from 'drizzle-orm'
@@ -973,7 +974,7 @@ async function recordRefreshRevocation(
973974
.where(
974975
and(
975976
chainRowsFilter(scope.accountId, scope.slackTeamId),
976-
eq(account.refreshToken, rejectedRefreshToken),
977+
sealedTextEquals(account.refreshToken, rejectedRefreshToken),
977978
// Raw sql params skip drizzle's column mapping, so the Date is serialized explicitly;
978979
// truncation keeps a version read back at millisecond precision comparable. A row that
979980
// vanished before it could be read has no version and matches nothing anyway.
@@ -1366,7 +1367,9 @@ async function performCoalescedRefresh({
13661367
const rotated = await db
13671368
.update(account)
13681369
.set(updateData)
1369-
.where(and(eq(account.id, accountId), eq(account.refreshToken, refreshToken)))
1370+
.where(
1371+
and(eq(account.id, accountId), sealedTextEquals(account.refreshToken, refreshToken))
1372+
)
13701373
.returning({ id: account.id })
13711374
if (rotated.length === 0) {
13721375
const stored = await readStoredChain(accountId)

‎bun.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docker/db.Dockerfile‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ WORKDIR /app
1212
# Copy only package files needed for migrations (these change less frequently)
1313
COPY package.json bun.lock turbo.json ./
1414
COPY patches ./patches
15-
RUN mkdir -p packages/db packages/logger packages/tsconfig packages/utils
15+
RUN mkdir -p packages/db packages/logger packages/security packages/tsconfig packages/utils
1616
COPY packages/db/package.json ./packages/db/package.json
1717
COPY packages/logger/package.json ./packages/logger/package.json
18+
COPY packages/security/package.json ./packages/security/package.json
1819
COPY packages/tsconfig/package.json ./packages/tsconfig/package.json
1920
COPY packages/utils/package.json ./packages/utils/package.json
2021

@@ -52,6 +53,9 @@ COPY --chown=nextjs:nodejs packages/utils ./packages/utils
5253
# Copy logger package (needed by @sim/db's tx-tripwire at import time)
5354
COPY --chown=nextjs:nodejs packages/logger ./packages/logger
5455

56+
# Copy security package (the schema's sealed columns import its encryption)
57+
COPY --chown=nextjs:nodejs packages/security ./packages/security
58+
5559
# Copy database package source code (changes most frequently - placed last)
5660
COPY --chown=nextjs:nodejs packages/db ./packages/db
5761

‎packages/db/package.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
"types": "./pool-profiles.ts",
2626
"default": "./pool-profiles.ts"
2727
},
28+
"./sealed-text": {
29+
"types": "./sealed-text.ts",
30+
"default": "./sealed-text.ts"
31+
},
2832
"./sso-primary-provider": {
2933
"types": "./sso-primary-provider.ts",
3034
"default": "./sso-primary-provider.ts"
@@ -44,6 +48,7 @@
4448
"db:migrate": "bun --env-file=.env run ./scripts/migrate.ts",
4549
"db:reconcile-fork-kb-file-ownership": "bun --env-file=.env run ./scripts/reconcile-fork-kb-file-ownership.ts",
4650
"db:reconcile-workspace-storage": "bun --env-file=.env run ./scripts/reconcile-workspace-storage.ts",
51+
"db:seal-account-tokens": "bun --env-file=.env run ./scripts/seal-account-tokens.ts",
4752
"db:retire-indexed-search": "bun --no-env-file ./scripts/retire-indexed-search.ts",
4853
"db:studio": "bunx drizzle-kit studio --config=./drizzle.config.ts",
4954
"test": "vitest run",
@@ -55,6 +60,7 @@
5560
},
5661
"dependencies": {
5762
"@sim/logger": "workspace:*",
63+
"@sim/security": "workspace:*",
5864
"@sim/utils": "workspace:*",
5965
"drizzle-orm": "^0.45.4",
6066
"postgres": "^3.4.5"

‎packages/db/schema.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
vector,
2929
} from 'drizzle-orm/pg-core'
3030
import { DEFAULT_FREE_CREDITS, TAG_SLOTS } from './constants'
31+
import { sealedText } from './sealed-text'
3132

3233
/**
3334
* Drizzle push compares index options as JSON, while Postgres introspection
@@ -176,8 +177,8 @@ export const account = pgTable(
176177
userId: text('user_id')
177178
.notNull()
178179
.references(() => user.id, { onDelete: 'cascade' }),
179-
accessToken: text('access_token'),
180-
refreshToken: text('refresh_token'),
180+
accessToken: sealedText('access_token'),
181+
refreshToken: sealedText('refresh_token'),
181182
idToken: text('id_token'),
182183
accessTokenExpiresAt: timestamp('access_token_expires_at'),
183184
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { sealAccountTokens } from '@sim/db/script-migrations/0037_seal_account_tokens'
2+
import { openText } from '@sim/db/sealed-text'
3+
import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
4+
import { generateId } from '@sim/utils/id'
5+
import postgres, { type Sql } from 'postgres'
6+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
7+
8+
const databaseUrl = readTestDatabaseUrl()
9+
10+
interface StoredRow {
11+
id: string
12+
access_token: string | null
13+
refresh_token: string | null
14+
updated_at: string
15+
}
16+
17+
/** Copies the account table into an isolated disposable schema; no public rows are touched. */
18+
describe('sealing stored account tokens in PostgreSQL', () => {
19+
let sql: Sql
20+
let admin: Sql
21+
const schemaName = `account_sealing_${generateId().replaceAll('-', '')}`
22+
const previousKey = process.env.ENCRYPTION_KEY
23+
24+
beforeAll(async () => {
25+
process.env.ENCRYPTION_KEY = 'a'.repeat(64)
26+
admin = postgres(databaseUrl, { max: 1, onnotice: () => undefined })
27+
await admin.unsafe(`CREATE SCHEMA "${schemaName}"`)
28+
await admin.unsafe(
29+
`CREATE TABLE "${schemaName}"."account" (LIKE public."account" INCLUDING DEFAULTS)`
30+
)
31+
sql = postgres(databaseUrl, {
32+
max: 2,
33+
connection: { search_path: schemaName },
34+
onnotice: () => undefined,
35+
})
36+
})
37+
38+
afterAll(async () => {
39+
process.env.ENCRYPTION_KEY = previousKey
40+
await sql?.end()
41+
if (admin) {
42+
await admin.unsafe(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`)
43+
await admin.end()
44+
}
45+
})
46+
47+
beforeEach(async () => {
48+
await sql`TRUNCATE account`
49+
})
50+
51+
async function insertAccount(accessToken: string | null, refreshToken: string | null) {
52+
const id = generateId()
53+
await sql`INSERT INTO account (id, account_id, provider_id, user_id, access_token, refresh_token, created_at, updated_at)
54+
VALUES (${id}, ${`subject-${id}`}, 'google-drive', ${`user-${id}`}, ${accessToken}, ${refreshToken},
55+
'2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`
56+
return id
57+
}
58+
59+
async function storedRows() {
60+
return sql<StoredRow[]>`
61+
SELECT id, access_token, refresh_token, updated_at::text FROM account ORDER BY id`
62+
}
63+
64+
it('seals every stored token across batches without moving the chain version', async () => {
65+
await Promise.all(
66+
Array.from({ length: 5 }, (_, index) => insertAccount(`access-${index}`, `refresh-${index}`))
67+
)
68+
const accessOnly = await insertAccount('access-only', null)
69+
const before = await storedRows()
70+
71+
expect(await sealAccountTokens(sql, { mode: 'seal', batchSize: 2, pauseMs: 0 })).toEqual({
72+
scanned: 6,
73+
pending: 6,
74+
updated: 6,
75+
})
76+
77+
const after = await storedRows()
78+
for (const row of after) {
79+
const original = before.find((candidate) => candidate.id === row.id)
80+
expect(row.updated_at).toBe(original?.updated_at)
81+
expect(row.access_token).toMatch(/^enc:v1:/)
82+
expect(openText(row.access_token ?? '')).toBe(original?.access_token)
83+
if (row.id === accessOnly) {
84+
expect(row.refresh_token).toBeNull()
85+
} else {
86+
expect(row.refresh_token).toMatch(/^enc:v1:/)
87+
expect(openText(row.refresh_token ?? '')).toBe(original?.refresh_token)
88+
}
89+
}
90+
})
91+
92+
it('finds nothing left to do on a rerun or a dry run after a completed pass', async () => {
93+
await insertAccount('access', 'refresh')
94+
await sealAccountTokens(sql, { mode: 'seal', pauseMs: 0 })
95+
const sealed = await storedRows()
96+
97+
expect(await sealAccountTokens(sql, { mode: 'seal', pauseMs: 0 })).toMatchObject({
98+
pending: 0,
99+
updated: 0,
100+
})
101+
expect(await sealAccountTokens(sql, { mode: 'seal', dryRun: true, pauseMs: 0 })).toMatchObject({
102+
pending: 0,
103+
})
104+
expect(await storedRows()).toEqual(sealed)
105+
})
106+
107+
it('reports pending rows on a dry run without writing them', async () => {
108+
await insertAccount('access', 'refresh')
109+
const before = await storedRows()
110+
111+
expect(await sealAccountTokens(sql, { mode: 'seal', dryRun: true, pauseMs: 0 })).toEqual({
112+
scanned: 1,
113+
pending: 1,
114+
updated: 0,
115+
})
116+
expect(await storedRows()).toEqual(before)
117+
})
118+
119+
it('restores the exact plaintext when unsealing for a rollback', async () => {
120+
await insertAccount('ya29.access:with:colons', 'refresh')
121+
await insertAccount(null, 'refresh-only')
122+
const plaintext = await storedRows()
123+
await sealAccountTokens(sql, { mode: 'seal', pauseMs: 0 })
124+
125+
expect(await sealAccountTokens(sql, { mode: 'unseal', pauseMs: 0 })).toMatchObject({
126+
updated: 2,
127+
})
128+
expect(await storedRows()).toEqual(plaintext)
129+
})
130+
})

0 commit comments

Comments
 (0)