Skip to content

Commit b9e6ffb

Browse files
authored
improvement(auth): encrypt stored integration tokens (#9346)
* improvement(auth): encrypt stored integration tokens * fix(settings): read permission predicates from the db-free entry * improvement(security): detect sealed envelopes by their full shape * improvement(auth): bind sealed tokens to their column and guard the backfill key * improvement(auth): read the sealed context from the column definition * fix(audit): report the line of a raw sealed column name * improvement(audit): flag wildcard and identifier reads of sealed columns
1 parent 772fcf7 commit b9e6ffb

22 files changed

Lines changed: 1094 additions & 12 deletions

‎apps/sim/components/settings/navigation.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
Users,
2828
Wrench,
2929
} from '@sim/emcn/icons'
30-
import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace'
30+
import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/predicates'
3131
import { CodeIcon, McpIcon, SlackIcon } from '@/components/icons'
3232
import type { SettingsHeaderMeta } from '@/components/settings/settings-header'
3333
import type { DeploymentFeatures, DeploymentShape } from '@/lib/api/contracts/workspaces'

‎apps/sim/instrumentation-node.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,9 @@ export async function register() {
378378

379379
await initializeOpenTelemetry()
380380

381+
const { reportAccountTokenSealing } = await import('@sim/db/sealed-text')
382+
reportAccountTokenSealing()
383+
381384
const shutdownPostHog = async () => {
382385
try {
383386
const { getPostHogClient } = await import('@/lib/posthog/server')

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export const env = createEnv({
144144
TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification
145145
ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data
146146
API_ENCRYPTION_KEY: z.string().min(32).optional(), // Dedicated key for encrypting API keys (optional for OSS)
147+
ENCRYPT_ACCOUNT_TOKENS: z.boolean().optional(), // Seal OAuth access/refresh tokens on write (read by @sim/db); takes effect only with a 64-hex ENCRYPTION_KEY. Enable only once every app, worker, and background-task deployment runs a release that reads sealed tokens
147148
INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication
148149
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
149150

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

Lines changed: 91 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 () => {
@@ -36,6 +36,7 @@ import { fanOutSlackTokenChain } from '@/lib/oauth/slack'
3636
import { completeOAuthCredentialToken } from '@/lib/oauth/token-resolution'
3737

3838
const originalFetch = globalThis.fetch
39+
const fixtureEncryptionKey = process.env.ENCRYPTION_KEY
3940
const userId = generateId()
4041
const workspaceId = generateId()
4142
const accountId = generateId()
@@ -81,6 +82,17 @@ async function storedRow(rowId: string) {
8182
return row
8283
}
8384

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

179191
afterEach((context) => {
192+
sealWrites(false)
193+
process.env.ENCRYPTION_KEY = fixtureEncryptionKey
180194
pausedProvider?.release.resolve()
181195
checks.push({
182196
name: context.task.name,
@@ -453,3 +467,79 @@ describe('OAuth refresh-token revocation against PostgreSQL and Redis', () => {
453467
})
454468
})
455469
})
470+
471+
describe('OAuth refresh chains with sealed account tokens', () => {
472+
async function sealStoredChain() {
473+
sealWrites(true)
474+
await db
475+
.update(account)
476+
.set({ accessToken: 'expired-access', refreshToken: 'issued-refresh' })
477+
.where(eq(account.id, accountId))
478+
}
479+
480+
it('stores tokens sealed and reads them back as plaintext', async () => {
481+
await sealStoredChain()
482+
483+
const raw = await rawTokens()
484+
expect(raw.access_token).toMatch(/^enc:v1:/)
485+
expect(raw.refresh_token).toMatch(/^enc:v1:/)
486+
expect(JSON.stringify(raw)).not.toContain('issued-refresh')
487+
expect(await storedAccount()).toMatchObject({
488+
accessToken: 'expired-access',
489+
refreshToken: 'issued-refresh',
490+
})
491+
})
492+
493+
it('keeps writing readable tokens when sealing is requested without a usable key', async () => {
494+
sealWrites(true)
495+
process.env.ENCRYPTION_KEY = 'not-hex'.repeat(6)
496+
providerAnswer = REFRESHED
497+
498+
await expect(resolveToken()).resolves.toEqual({ accessToken: 'fresh-access', refreshed: true })
499+
expect(await rawTokens()).toMatchObject({ access_token: 'fresh-access' })
500+
})
501+
502+
it('rotates a chain stored before sealing was enabled', async () => {
503+
sealWrites(true)
504+
providerAnswer = REFRESHED
505+
506+
await expect(resolveToken()).resolves.toEqual({ accessToken: 'fresh-access', refreshed: true })
507+
expect(await rawTokens()).toMatchObject({
508+
access_token: expect.stringMatching(/^enc:v1:/),
509+
refresh_token: 'issued-refresh',
510+
})
511+
expect((await storedAccount()).accessToken).toBe('fresh-access')
512+
})
513+
514+
it('rotates a sealed chain on a process that does not seal writes', async () => {
515+
await sealStoredChain()
516+
sealWrites(false)
517+
providerAnswer = REFRESHED
518+
519+
await expect(resolveToken()).resolves.toEqual({ accessToken: 'fresh-access', refreshed: true })
520+
expect(await rawTokens()).toMatchObject({
521+
access_token: 'fresh-access',
522+
refresh_token: expect.stringMatching(/^enc:v1:/),
523+
})
524+
})
525+
526+
it('records a revocation against a sealed chain on a process that does not seal writes', async () => {
527+
await sealStoredChain()
528+
sealWrites(false)
529+
530+
await expect(resolveToken()).rejects.toBeInstanceOf(CredentialRevokedError)
531+
await expect(getCredentialTerminalRefreshError(credentialId)).resolves.toEqual({
532+
errorCode: 'invalid_grant',
533+
providerId: PROVIDER_ID,
534+
})
535+
})
536+
537+
it('keeps a revocation recorded before the chain was sealed', async () => {
538+
await expect(resolveToken()).rejects.toBeInstanceOf(CredentialRevokedError)
539+
await expireRedisFlags()
540+
await sealStoredChain()
541+
542+
await expect(resolveToken()).rejects.toBeInstanceOf(CredentialRevokedError)
543+
expect(providerRequests).toBe(1)
544+
})
545+
})

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ const serviceLogger = getMockLogger('OAuthCredentialService')
8686
const transportLogger = getMockLogger('GoogleServiceAccountTransport')
8787
const mocks = { ...hoisted, decryptSecret: encryptionMockFns.mockDecryptSecret }
8888

89+
beforeEach(() => {
90+
// Schema mocks carry no table to seal against; credential-revocation.integration.ts covers sealing.
91+
vi.stubEnv('ENCRYPTION_KEY', '')
92+
})
93+
8994
/** Every call the credential service and its token transport logged at the given levels. */
9095
function loggedCalls(...levels: Array<'info' | 'warn' | 'error'>) {
9196
return levels.flatMap((level) => [

‎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)

‎apps/sim/trigger.config.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ export default defineConfig({
133133
createLogger('TriggerInit').error('Could not distrust compromised CA roots', tlsTrust)
134134
}
135135
markInsideTriggerRun()
136+
const { reportAccountTokenSealing } = await import('@sim/db/sealed-text')
137+
reportAccountTokenSealing()
136138
const { warmRedisConnection } = await import('./lib/core/config/redis')
137139
await warmRedisConnection()
138140
},

‎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

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"check:spec-example-ids": "bun run scripts/check-spec-example-ids.ts",
7777
"check:script-tests": "bun run scripts/check-script-test-coverage.ts",
7878
"check:test-patterns": "bun run scripts/check-test-patterns.ts",
79+
"check:sealed-columns": "bun run scripts/check-sealed-columns.ts",
7980
"check:schema-mock": "bun run scripts/generate-schema-mock.ts --check",
8081
"check:audits": "bun run scripts/run-audits.ts",
8182
"check:skills": "bun run scripts/sync-skills.ts --check",

0 commit comments

Comments
 (0)