From 8dd280574b8f1a6a39a9b959f5e2d812e6137981 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 17:55:02 -0800 Subject: [PATCH 1/3] refactor(credentials): resolve Vanta tokens through the coordinated client-token resolver vanta-token.ts carried a second copy of the lock-and-memo protocol in coordinated-client-token.ts. Vanta now calls resolveCoordinatedClientToken and invalidateCoordinatedClientToken through four optional inputs whose defaults leave Shopify, Atlassian, and Linear rows unchanged: - minRemainingMs: Vanta keeps its 30-second reuse margin - failureKeyQualifier: Vanta's failure memo stays keyed by secret and scope - accessTokenDigestTag: Vanta's token digest stays keyed by API domain - pinnedState: Vanta's scope is stored with the token and checked against every stored record, expired ones included, under the lock Row ids, the advisory lock name and key, token digests, and encrypted payloads are unchanged, so old and new workers read each other's rows and contend on the same lock during a rollout. Unit tests pin those formats for Vanta and for the shared defaults; they pass against the previous code too. --- .../vanta-token.test.ts | 172 +++++++++++++++++ .../client-credential-accounts/vanta-token.ts | 181 +++--------------- .../coordinated-client-token.test.ts | 124 ++++++++++++ .../credentials/coordinated-client-token.ts | 81 ++++++-- 4 files changed, 388 insertions(+), 170 deletions(-) create mode 100644 apps/sim/lib/credentials/client-credential-accounts/vanta-token.test.ts create mode 100644 apps/sim/lib/credentials/coordinated-client-token.test.ts diff --git a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.test.ts b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.test.ts new file mode 100644 index 00000000000..df4c068bbd2 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.test.ts @@ -0,0 +1,172 @@ +/** + * Old and new workers share `client_credential_token` during a rollout, and Vanta revokes a + * token when another is minted for the application, so every row id, digest, lock key, and + * payload here is pinned to the exact format earlier builds wrote and read. + */ +import { createHash } from 'node:crypto' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock' +import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' +import { schemaMock } from '@sim/testing/mocks/schema.mock' +import { eq, inArray, sql } from 'drizzle-orm' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/security/encryption', () => encryptionMock) + +import { + invalidateVantaClientToken, + resolveVantaClientToken, +} from '@/lib/credentials/client-credential-accounts/vanta-token' + +const API_DOMAIN = 'https://api.vanta.com' +const CLIENT_ID = 'vanta-client' +const CLIENT_SECRET = 'vanta-secret' +const READ = 'vanta-api.all:read' +const WRITE = 'vanta-api.all:read vanta-api.all:write' +const table = schemaMock.clientCredentialToken + +function sha256Json(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} + +const APPLICATION_KEY = `vanta:${sha256Json([API_DOMAIN, CLIENT_ID])}` +const SECRET_FINGERPRINT = sha256Json([API_DOMAIN, CLIENT_ID, CLIENT_SECRET]) +const failureKey = (scope: string) => + `${APPLICATION_KEY}:${sha256Json([SECRET_FINGERPRINT, scope])}` +const accessTokenDigest = (accessToken: string) => sha256Json([API_DOMAIN, accessToken]) + +function storedRow(id: string, payload: object, expiresInMs: number, tokenDigest?: string) { + return { + id, + encryptedValue: JSON.stringify(payload), + accessTokenDigest: tokenDigest ?? null, + expiresAt: new Date(Date.now() + expiresInMs), + createdAt: new Date(), + updatedAt: new Date(), + } +} + +function applicationRow(scope: string, expiresInMs: number) { + return storedRow( + APPLICATION_KEY, + { accessToken: 'stored-token', secretFingerprint: SECRET_FINGERPRINT, scope }, + expiresInMs, + accessTokenDigest('stored-token') + ) +} + +/** Queues the rows for both lookups a resolution may make: unlocked, then under the lock. */ +function storeRows(rows: object[]) { + queueTableRows(table, rows) + queueTableRows(table, rows) +} + +function lookedUpIds() { + return [ + ...vi + .mocked(eq) + .mock.calls.filter(([column]) => Object.is(column, table.id)) + .map(([, id]) => id), + ...vi + .mocked(inArray) + .mock.calls.filter(([column]) => Object.is(column, table.id)) + .flatMap(([, ids]) => ids as unknown[]), + ] +} + +function writtenRows() { + return dbChainMockFns.values.mock.calls.map(([row]) => row as Record) +} + +function advisoryLock() { + const [, key, tag] = + vi + .mocked(sql) + .mock.calls.find(([strings]) => strings.join('').includes('pg_advisory_xact_lock')) ?? [] + return { key, tag: (tag as { rawSql?: string } | undefined)?.rawSql } +} + +const fields = { clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, scope: READ } +const mockFetch = vi.fn() + +describe('Vanta token rows stay readable across a rolling deploy', () => { + beforeEach(() => { + resetDbChainMock() + encryptionMockFns.mockEncryptSecret.mockImplementation(async (value) => ({ encrypted: value })) + encryptionMockFns.mockDecryptSecret.mockImplementation(async (value) => ({ decrypted: value })) + mockFetch.mockReset() + mockFetch.mockImplementation(async () => + Response.json({ access_token: 'minted-token', expires_in: 3600, token_type: 'Bearer' }) + ) + vi.stubGlobal('fetch', mockFetch) + }) + + it('serves a stored application token down to its 30-second reuse margin', async () => { + storeRows([applicationRow(READ, 60_000)]) + + const result = await resolveVantaClientToken(fields) + + expect(result).toMatchObject({ accessToken: 'stored-token', apiDomain: API_DOMAIN }) + expect(lookedUpIds()).toContain(APPLICATION_KEY) + expect([APPLICATION_KEY, failureKey(READ)]).toEqual(expect.arrayContaining(lookedUpIds())) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('refuses other permissions for a stored application even after its token expired', async () => { + storeRows([applicationRow(READ, -1000)]) + + await expect(resolveVantaClientToken({ ...fields, scope: WRITE })).rejects.toMatchObject({ + code: 'permission_conflict', + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('honors a stored failure for the same secret and scope without calling Vanta', async () => { + storeRows([storedRow(failureKey(READ), { code: 'invalid_credentials', status: 401 }, 20_000)]) + + await expect(resolveVantaClientToken(fields)).rejects.toMatchObject({ + code: 'invalid_credentials', + status: 401, + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('mints under the shared lock and stores the token in the established format', async () => { + await resolveVantaClientToken(fields) + + expect(advisoryLock()).toEqual({ key: APPLICATION_KEY, tag: "/*lock='vanta_client_token'*/" }) + const [row] = writtenRows() + expect(row).toMatchObject({ + id: APPLICATION_KEY, + accessTokenDigest: accessTokenDigest('minted-token'), + }) + expect(JSON.parse(String(row.encryptedValue))).toEqual({ + accessToken: 'minted-token', + secretFingerprint: SECRET_FINGERPRINT, + scope: READ, + }) + expect(eq).toHaveBeenCalledWith(table.id, failureKey(READ)) + }) + + it('stores a refusal in the established failure format', async () => { + mockFetch.mockImplementation(async () => new Response(null, { status: 401 })) + + await expect(resolveVantaClientToken(fields)).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + const [row] = writtenRows() + expect(row.id).toBe(failureKey(READ)) + expect(JSON.parse(String(row.encryptedValue))).toEqual({ + code: 'invalid_credentials', + status: 401, + }) + }) + + it('expires a rejected token by its established digest', async () => { + await invalidateVantaClientToken('stored-token', API_DOMAIN) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ expiresAt: new Date(0) }) + ) + expect(eq).toHaveBeenCalledWith(table.accessTokenDigest, accessTokenDigest('stored-token')) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts index 86bd767c970..2d1bc48ec70 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts @@ -1,23 +1,19 @@ -import { createHash } from 'node:crypto' -import { db } from '@sim/db' -import { clientCredentialToken } from '@sim/db/schema' import { isRecordLike } from '@sim/utils/object' -import { eq, inArray, sql } from 'drizzle-orm' -import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' import { VANTA_PERMISSION_OPTIONS } from '@/lib/credentials/client-credential-accounts/descriptors' -import { deleteExpiredClientTokenFailures } from '@/lib/credentials/coordinated-client-token' +import { + invalidateCoordinatedClientToken, + resolveCoordinatedClientToken, +} from '@/lib/credentials/coordinated-client-token' import { fetchProvider, isTransientProviderStatus, requireClientSecret, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' -import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' const SCOPES = VANTA_PERMISSION_OPTIONS.map((option) => option.value) const EXPIRY_BUFFER_MS = 30_000 -const FAILURE_TTL_MS = 30_000 const STEP = 'vanta_token_mint' export interface VantaClientTokenFields { @@ -31,27 +27,12 @@ export interface VantaClientTokenOptions { signal?: AbortSignal } -interface VantaApplicationState { - accessToken: string - secretFingerprint: string - scope: string -} - -interface VantaTokenFailure { - code: 'invalid_credentials' | 'provider_unavailable' - status: number -} - export interface VantaClientTokenResult { accessToken: string expiresInSeconds: number apiDomain: string } -function digest(value: unknown): string { - return createHash('sha256').update(JSON.stringify(value)).digest('hex') -} - /** Expires only the rejected token; a concurrent replacement remains usable. */ export async function invalidateVantaClientToken( accessToken: string, @@ -60,10 +41,7 @@ export async function invalidateVantaClientToken( if (apiDomain !== 'https://api.vanta.com' && apiDomain !== 'https://api.vanta-gov.com') { throw new Error('Invalid Vanta API domain') } - await db - .update(clientCredentialToken) - .set({ expiresAt: new Date(0), updatedAt: new Date() }) - .where(eq(clientCredentialToken.accessTokenDigest, digest([apiDomain, accessToken]))) + await invalidateCoordinatedClientToken(accessToken, { accessTokenDigestTag: apiDomain }) } function unavailable(reason: string): TokenServiceAccountValidationError { @@ -81,35 +59,6 @@ class VantaScopeConflictError extends TokenServiceAccountValidationError { } } -function decodeApplicationState(value: string): VantaApplicationState { - const parsed: unknown = JSON.parse(value) - if ( - !isRecordLike(parsed) || - typeof parsed.accessToken !== 'string' || - !parsed.accessToken || - typeof parsed.secretFingerprint !== 'string' || - typeof parsed.scope !== 'string' || - !SCOPES.some((scope) => scope === parsed.scope) - ) - throw unavailable('stored application state is malformed') - return { - accessToken: parsed.accessToken, - secretFingerprint: parsed.secretFingerprint, - scope: parsed.scope, - } -} - -function decodeFailure(value: string): VantaTokenFailure { - const parsed: unknown = JSON.parse(value) - if ( - !isRecordLike(parsed) || - (parsed.code !== 'invalid_credentials' && parsed.code !== 'provider_unavailable') || - typeof parsed.status !== 'number' - ) - throw unavailable('stored token failure is malformed') - return { code: parsed.code, status: parsed.status } -} - async function exchangeToken( fields: VantaClientTokenFields, apiDomain: string, @@ -194,104 +143,24 @@ export async function resolveVantaClientToken( } const apiDomain = fields.dataCenter === 'gov' ? 'https://api.vanta-gov.com' : 'https://api.vanta.com' - const applicationKey = `vanta:${digest([apiDomain, fields.clientId])}` - const secretFingerprint = digest([apiDomain, fields.clientId, clientSecret]) - const failureKey = `${applicationKey}:${digest([secretFingerprint, scope])}` - try { - await deleteExpiredClientTokenFailures() - const outcome = await db.transaction( - async (tx) => { - await tx.execute(sql`SET LOCAL lock_timeout = '15s'`) - await acquireAdvisoryXactLock(tx, 'vanta_client_token', applicationKey) - options.signal?.throwIfAborted() - const entries = await tx - .select() - .from(clientCredentialToken) - .where(inArray(clientCredentialToken.id, [applicationKey, failureKey])) - const existing = entries.find((entry) => entry.id === applicationKey) - const existingState = existing - ? decodeApplicationState((await decryptSecret(existing.encryptedValue)).decrypted) - : null - if (existingState && existingState.scope !== scope) { - throw new VantaScopeConflictError() - } - const matchingSecret = existingState?.secretFingerprint === secretFingerprint - if ( - existingState && - matchingSecret && - existing && - existing.expiresAt.getTime() - Date.now() > EXPIRY_BUFFER_MS - ) { - return { - accessToken: existingState.accessToken, - expiresInSeconds: Math.floor((existing.expiresAt.getTime() - Date.now()) / 1000), - apiDomain, - } - } - const failure = entries.find( - (entry) => entry.id === failureKey && entry.expiresAt.getTime() > Date.now() - ) - if (failure) - return { failure: decodeFailure((await decryptSecret(failure.encryptedValue)).decrypted) } - options.signal?.throwIfAborted() - try { - const minted = await exchangeToken(fields, apiDomain, clientSecret, scope, options.signal) - const accessTokenDigest = digest([apiDomain, minted.accessToken]) - const { encrypted } = await encryptSecret( - JSON.stringify({ accessToken: minted.accessToken, secretFingerprint, scope }) - ) - await tx - .insert(clientCredentialToken) - .values({ - id: applicationKey, - encryptedValue: encrypted, - accessTokenDigest, - expiresAt: minted.expiresAt, - }) - .onConflictDoUpdate({ - target: clientCredentialToken.id, - set: { - encryptedValue: encrypted, - accessTokenDigest, - expiresAt: minted.expiresAt, - updatedAt: new Date(), - }, - }) - await tx.delete(clientCredentialToken).where(eq(clientCredentialToken.id, failureKey)) - return { - accessToken: minted.accessToken, - expiresInSeconds: Math.floor((minted.expiresAt.getTime() - Date.now()) / 1000), - apiDomain, - } - } catch (error) { - if (!(error instanceof TokenServiceAccountValidationError)) throw error - const failure: VantaTokenFailure = { - code: - error.code === 'invalid_credentials' ? 'invalid_credentials' : 'provider_unavailable', - status: error.status, - } - const { encrypted } = await encryptSecret(JSON.stringify(failure)) - const expiresAt = new Date(Date.now() + FAILURE_TTL_MS) - await tx - .insert(clientCredentialToken) - .values({ id: failureKey, encryptedValue: encrypted, expiresAt }) - .onConflictDoUpdate({ - target: clientCredentialToken.id, - set: { encryptedValue: encrypted, expiresAt, updatedAt: new Date() }, - }) - return { failure } - } - } - ) - options.signal?.throwIfAborted() - if ('failure' in outcome) - throw new TokenServiceAccountValidationError(outcome.failure.code, outcome.failure.status, { - step: STEP, - }) - return outcome - } catch (error) { - options.signal?.throwIfAborted() - if (error instanceof TokenServiceAccountValidationError) throw error - throw unavailable('token coordination or exchange failed') - } + const token = await resolveCoordinatedClientToken({ + namespace: 'vanta', + application: [apiDomain, fields.clientId], + clientSecret, + step: STEP, + signal: options.signal, + minRemainingMs: EXPIRY_BUFFER_MS, + failureKeyQualifier: scope, + accessTokenDigestTag: apiDomain, + pinnedState: { + values: { scope }, + verify: (stored) => { + if (typeof stored.scope !== 'string' || !SCOPES.some((known) => known === stored.scope)) + throw unavailable('stored application state is malformed') + if (stored.scope !== scope) throw new VantaScopeConflictError() + }, + }, + exchange: (signal) => exchangeToken(fields, apiDomain, clientSecret, scope, signal), + }) + return { ...token, apiDomain } } diff --git a/apps/sim/lib/credentials/coordinated-client-token.test.ts b/apps/sim/lib/credentials/coordinated-client-token.test.ts new file mode 100644 index 00000000000..b96135e6fc4 --- /dev/null +++ b/apps/sim/lib/credentials/coordinated-client-token.test.ts @@ -0,0 +1,124 @@ +/** + * Old and new workers share `client_credential_token` during a rollout, so the default row ids, + * digests, lock key, and payloads (used by Shopify, Atlassian, and Linear) stay pinned to the + * format earlier builds wrote and read. + */ +import { createHash } from 'node:crypto' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock' +import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' +import { schemaMock } from '@sim/testing/mocks/schema.mock' +import { eq, sql } from 'drizzle-orm' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/security/encryption', () => encryptionMock) + +import { + invalidateCoordinatedClientToken, + resolveCoordinatedClientToken, +} from '@/lib/credentials/coordinated-client-token' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' + +const APPLICATION = ['shop.myshopify.com', 'shopify-client'] as const +const CLIENT_SECRET = 'shopify-secret' +const table = schemaMock.clientCredentialToken + +function sha256Json(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} + +const APPLICATION_KEY = `shopify:${sha256Json(APPLICATION)}` +const SECRET_FINGERPRINT = sha256Json([...APPLICATION, CLIENT_SECRET]) +const FAILURE_KEY = `${APPLICATION_KEY}:${sha256Json(SECRET_FINGERPRINT)}` +const accessTokenDigest = (accessToken: string) => + sha256Json(['coordinated_client_token', accessToken]) + +function writtenRows() { + return dbChainMockFns.values.mock.calls.map(([row]) => row as Record) +} + +function advisoryLock() { + const [, key, tag] = + vi + .mocked(sql) + .mock.calls.find(([strings]) => strings.join('').includes('pg_advisory_xact_lock')) ?? [] + return { key, tag: (tag as { rawSql?: string } | undefined)?.rawSql } +} + +const exchange = + vi.fn<(signal?: AbortSignal) => Promise<{ accessToken: string; expiresAt: Date }>>() +const input = { + namespace: 'shopify', + application: APPLICATION, + clientSecret: CLIENT_SECRET, + step: 'shopify_token_mint', + exchange, +} + +describe('default coordinated token rows stay readable across a rolling deploy', () => { + beforeEach(() => { + resetDbChainMock() + encryptionMockFns.mockEncryptSecret.mockImplementation(async (value) => ({ encrypted: value })) + encryptionMockFns.mockDecryptSecret.mockImplementation(async (value) => ({ decrypted: value })) + exchange.mockReset() + exchange.mockResolvedValue({ + accessToken: 'minted-token', + expiresAt: new Date(Date.now() + 3_600_000), + }) + }) + + it('replaces a token inside the five-minute margin and stores it in the established format', async () => { + queueTableRows(table, [ + { + id: APPLICATION_KEY, + encryptedValue: JSON.stringify({ + accessToken: 'stored-token', + secretFingerprint: SECRET_FINGERPRINT, + }), + accessTokenDigest: accessTokenDigest('stored-token'), + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + await expect(resolveCoordinatedClientToken(input)).resolves.toMatchObject({ + accessToken: 'minted-token', + }) + + expect(advisoryLock()).toEqual({ + key: APPLICATION_KEY, + tag: "/*lock='shopify_client_token'*/", + }) + const [row] = writtenRows() + expect(row).toMatchObject({ + id: APPLICATION_KEY, + accessTokenDigest: accessTokenDigest('minted-token'), + }) + expect(JSON.parse(String(row.encryptedValue))).toEqual({ + accessToken: 'minted-token', + secretFingerprint: SECRET_FINGERPRINT, + }) + }) + + it('stores a refusal under the established failure key', async () => { + exchange.mockRejectedValue( + new TokenServiceAccountValidationError('grant_not_permitted', 403, { step: 'test' }) + ) + + await expect(resolveCoordinatedClientToken(input)).rejects.toMatchObject({ + code: 'grant_not_permitted', + }) + const [row] = writtenRows() + expect(row.id).toBe(FAILURE_KEY) + expect(JSON.parse(String(row.encryptedValue))).toEqual({ + code: 'grant_not_permitted', + status: 403, + }) + }) + + it('expires a rejected token by its established digest', async () => { + await invalidateCoordinatedClientToken('stored-token') + + expect(eq).toHaveBeenCalledWith(table.accessTokenDigest, accessTokenDigest('stored-token')) + }) +}) diff --git a/apps/sim/lib/credentials/coordinated-client-token.ts b/apps/sim/lib/credentials/coordinated-client-token.ts index f27567c6d40..23eb53f261d 100644 --- a/apps/sim/lib/credentials/coordinated-client-token.ts +++ b/apps/sim/lib/credentials/coordinated-client-token.ts @@ -10,6 +10,7 @@ import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' /** A stored token is only handed out while at least this much of its lifetime remains. */ const MIN_REMAINING_MS = 5 * 60 * 1000 const FAILURE_TTL_MS = 30_000 +const DEFAULT_ACCESS_TOKEN_DIGEST_TAG = 'coordinated_client_token' /** The provider token a coordinated exchange produced. */ interface CoordinatedClientTokenExchange { @@ -32,6 +33,27 @@ export interface CoordinatedClientTokenInput { /** Performs the provider exchange. Throws `TokenServiceAccountValidationError` on refusal. */ exchange: (signal?: AbortSignal) => Promise signal?: AbortSignal + /** Shortest remaining lifetime a stored token is still served with. Defaults to five minutes. */ + minRemainingMs?: number + /** + * A request setting beyond the secret that a remembered exchange failure is specific to, so a + * refusal for one setting does not block another. + */ + failureKeyQualifier?: string + /** + * Tag mixed into the access-token digest that invalidation matches on. Pass the same tag to + * {@link invalidateCoordinatedClientToken}. Defaults to a tag shared by every caller. + */ + accessTokenDigestTag?: string + /** + * Settings the application's first mint fixes for every later resolution, stored with its token. + * `verify` sees every stored token record (expired ones too, once the lock is held) and throws to + * refuse it: a validation error for a mismatch, `provider_unavailable` for a malformed record. + */ + pinnedState?: { + values: Readonly> + verify: (stored: Readonly>) => void + } } export interface CoordinatedClientToken { @@ -42,6 +64,7 @@ export interface CoordinatedClientToken { interface StoredApplicationToken { accessToken: string secretFingerprint: string + record: Readonly> } interface StoredFailure { @@ -61,8 +84,8 @@ function digest(value: unknown): string { } /** Identifies a stored token by its value alone, so a provider rejection can expire it. */ -function accessTokenDigestOf(accessToken: string): string { - return digest(['coordinated_client_token', accessToken]) +function accessTokenDigestOf(accessToken: string, tag = DEFAULT_ACCESS_TOKEN_DIGEST_TAG): string { + return digest([tag, accessToken]) } /** @@ -86,6 +109,8 @@ interface InvalidateCoordinatedClientTokenOptions { * the endpoint rather than the token, so a call that keeps failing cannot re-mint on every try. */ minimumAgeMs?: number + /** The `accessTokenDigestTag` the token was resolved with. */ + accessTokenDigestTag?: string } /** @@ -97,7 +122,10 @@ export async function invalidateCoordinatedClientToken( accessToken: string, options: InvalidateCoordinatedClientTokenOptions = {} ): Promise { - const matchesToken = eq(clientCredentialToken.accessTokenDigest, accessTokenDigestOf(accessToken)) + const matchesToken = eq( + clientCredentialToken.accessTokenDigest, + accessTokenDigestOf(accessToken, options.accessTokenDigestTag) + ) await db .update(clientCredentialToken) .set({ expiresAt: new Date(0), updatedAt: new Date() }) @@ -124,7 +152,11 @@ function decodeApplicationToken(value: string, step: string): StoredApplicationT typeof parsed.secretFingerprint !== 'string' ) throw unavailable(step, 'stored application token is malformed') - return { accessToken: parsed.accessToken, secretFingerprint: parsed.secretFingerprint } + return { + accessToken: parsed.accessToken, + secretFingerprint: parsed.secretFingerprint, + record: parsed, + } } function decodeFailure(value: string, step: string): StoredFailure { @@ -143,8 +175,8 @@ function remainingSeconds(expiresAt: Date): number { return Math.floor((expiresAt.getTime() - Date.now()) / 1000) } -function isServable(expiresAt: Date): boolean { - return expiresAt.getTime() - Date.now() > MIN_REMAINING_MS +function isServable(expiresAt: Date, minRemainingMs: number): boolean { + return expiresAt.getTime() - Date.now() > minRemainingMs } /** @@ -163,16 +195,27 @@ function isServable(expiresAt: Date): boolean { export async function resolveCoordinatedClientToken( input: CoordinatedClientTokenInput ): Promise { - const { namespace, step, signal } = input + const { namespace, step, signal, pinnedState } = input + const minRemainingMs = input.minRemainingMs ?? MIN_REMAINING_MS signal?.throwIfAborted() const applicationKey = `${namespace}:${digest(input.application)}` const secretFingerprint = digest([...input.application, input.clientSecret]) - const failureKey = `${applicationKey}:${digest(secretFingerprint)}` + const failureKey = `${applicationKey}:${digest( + input.failureKeyQualifier === undefined + ? secretFingerprint + : [secretFingerprint, input.failureKeyQualifier] + )}` - const serveIfFresh = async (row: typeof clientCredentialToken.$inferSelect | undefined) => { - if (!row || !isServable(row.expiresAt)) return undefined + const serveIfFresh = async ( + row: typeof clientCredentialToken.$inferSelect | undefined, + verifyExpired = false + ) => { + if (!row) return undefined + const servable = isServable(row.expiresAt, minRemainingMs) + if (!servable && !(verifyExpired && pinnedState)) return undefined const stored = decodeApplicationToken((await decryptSecret(row.encryptedValue)).decrypted, step) - if (stored.secretFingerprint !== secretFingerprint) return undefined + pinnedState?.verify(stored.record) + if (!servable || stored.secretFingerprint !== secretFingerprint) return undefined return { accessToken: stored.accessToken, expiresInSeconds: remainingSeconds(row.expiresAt) } } @@ -195,7 +238,10 @@ export async function resolveCoordinatedClientToken( .select() .from(clientCredentialToken) .where(inArray(clientCredentialToken.id, [applicationKey, failureKey])) - const minted = await serveIfFresh(entries.find((entry) => entry.id === applicationKey)) + const minted = await serveIfFresh( + entries.find((entry) => entry.id === applicationKey), + true + ) if (minted) return minted const failure = entries.find( (entry) => entry.id === failureKey && entry.expiresAt.getTime() > Date.now() @@ -209,11 +255,18 @@ export async function resolveCoordinatedClientToken( try { const exchanged = await input.exchange(signal) const { encrypted } = await encryptSecret( - JSON.stringify({ accessToken: exchanged.accessToken, secretFingerprint }) + JSON.stringify({ + accessToken: exchanged.accessToken, + secretFingerprint, + ...pinnedState?.values, + }) ) const values = { encryptedValue: encrypted, - accessTokenDigest: accessTokenDigestOf(exchanged.accessToken), + accessTokenDigest: accessTokenDigestOf( + exchanged.accessToken, + input.accessTokenDigestTag + ), expiresAt: exchanged.expiresAt, updatedAt: new Date(), } From e546a878d4a558387579db589da572c4f22940fc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 18:03:53 -0800 Subject: [PATCH 2/3] chore(credentials): keep the expired-failure sweep private to the coordinated resolver --- apps/sim/lib/credentials/coordinated-client-token.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/credentials/coordinated-client-token.ts b/apps/sim/lib/credentials/coordinated-client-token.ts index 23eb53f261d..0fea2e04b00 100644 --- a/apps/sim/lib/credentials/coordinated-client-token.ts +++ b/apps/sim/lib/credentials/coordinated-client-token.ts @@ -93,7 +93,7 @@ function accessTokenDigestOf(accessToken: string, tag = DEFAULT_ACCESS_TOKEN_DIG * the transaction that takes an application lock, so its row locks are released before any * provider call. */ -export async function deleteExpiredClientTokenFailures(): Promise { +async function deleteExpiredClientTokenFailures(): Promise { await db.execute(sql` DELETE FROM ${clientCredentialToken} WHERE ${clientCredentialToken.id} IN ( SELECT ${clientCredentialToken.id} FROM ${clientCredentialToken} From e85c16fc2dea13b97cea97ad8a3950e4528a41ac Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 18:13:12 -0800 Subject: [PATCH 3/3] test(credentials): prove rolling-deploy token compatibility against real Postgres Replace the mock-based unit tests for the coordinated resolver and the Vanta token chain with integration tests that seed rows exactly as the previous build wrote them (inline sha256 ids and digests, real encryptSecret) and read back what the new resolver writes. They cover a fresh stored token, a remembered refusal, the expired-row scope pin, mint and refusal payloads at the old ids, invalidation by the old digest, the old build's advisory lock blocking a new resolution, and the default Shopify-style formats. --- .../vanta-token.integration.ts | 183 +++++++++++++++++- .../vanta-token.test.ts | 172 ---------------- .../coordinated-client-token.integration.ts | 50 ++++- .../coordinated-client-token.test.ts | 124 ------------ 4 files changed, 231 insertions(+), 298 deletions(-) delete mode 100644 apps/sim/lib/credentials/client-credential-accounts/vanta-token.test.ts delete mode 100644 apps/sim/lib/credentials/coordinated-client-token.test.ts diff --git a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts index 7e2b8f9eb62..2ba5cd6f308 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts @@ -2,10 +2,12 @@ import { createHash } from 'node:crypto' import { createServer, type Server } from 'node:http' import { db } from '@sim/db' import { clientCredentialToken } from '@sim/db/schema' +import { createDeferred } from '@sim/testing/helpers/deferred' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' -import { eq, like, or } from 'drizzle-orm' +import { eq, like, or, sql } from 'drizzle-orm' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { invalidateVantaClientToken, resolveVantaClientToken, @@ -34,6 +36,67 @@ function fields(suffix: string) { return { clientId, clientSecret: `secret-${clientId}`, scope: READ } } +function sha256Json(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} + +/** Row ids and digests exactly as the build before the shared resolver derived them. */ +function previousBuildKeys(input: { clientId: string; clientSecret: string }, scope: string) { + const apiDomain = 'https://api.vanta.com' + const applicationKey = `vanta:${sha256Json([apiDomain, input.clientId])}` + const secretFingerprint = sha256Json([apiDomain, input.clientId, input.clientSecret]) + return { + applicationKey, + secretFingerprint, + failureKey: `${applicationKey}:${sha256Json([secretFingerprint, scope])}`, + accessTokenDigest: (accessToken: string) => sha256Json([apiDomain, accessToken]), + } +} + +/** Writes an application token row the way the previous build's mint did. */ +async function storePreviousBuildToken( + keys: ReturnType, + accessToken: string, + scope: string, + expiresInMs: number, + client: Pick = db +) { + const { encrypted } = await encryptSecret( + JSON.stringify({ accessToken, secretFingerprint: keys.secretFingerprint, scope }) + ) + await client.insert(clientCredentialToken).values({ + id: keys.applicationKey, + encryptedValue: encrypted, + accessTokenDigest: keys.accessTokenDigest(accessToken), + expiresAt: new Date(Date.now() + expiresInMs), + }) +} + +async function readStoredRow(id: string) { + const [row] = await db + .select() + .from(clientCredentialToken) + .where(eq(clientCredentialToken.id, id)) + .limit(1) + if (!row) throw new Error('Expected a stored row') + return { ...row, decrypted: (await decryptSecret(row.encryptedValue)).decrypted } +} + +/** Resolves once a session is blocked on the advisory lock for `key`; fails if `pending` settles first. */ +async function lockWaiterAppears(key: string, pending: { settled: boolean }): Promise { + for (;;) { + const waiting = await db.execute(sql` + WITH k AS (SELECT hashtextextended(${key}, 0) AS h) + SELECT 1 FROM pg_locks, k + WHERE locktype = 'advisory' AND NOT granted AND objsubid = 1 + AND classid::bigint = ((k.h >> 32) & 4294967295) + AND objid::bigint = (k.h & 4294967295)`) + if (waiting.length > 0) return + if (pending.settled) throw new Error('Resolution finished without waiting on the lock') + await sleep(10) + } +} + async function isActive(token: string): Promise { return ( await originalFetch(`${providerUrl}/verify`, { @@ -114,10 +177,12 @@ afterAll(async () => { describe('Vanta application token lifecycle against shared storage', () => { it('shares one active token across simultaneous resolutions for an application', async () => { + const count = tokenRequests const results = await Promise.all( Array.from({ length: 12 }, () => resolveVantaClientToken(fields('concurrent'))) ) expect(new Set(results.map((result) => result.accessToken)).size).toBe(1) + expect(tokenRequests).toBe(count + 1) expect(await isActive(results[0].accessToken)).toBe(true) expect((await resolveVantaClientToken(fields('concurrent'))).accessToken).toBe( results[0].accessToken @@ -265,3 +330,119 @@ describe('Vanta application token lifecycle against shared storage', () => { expect(tokenRequests).toBe(count) }) }) + +describe('Vanta token rows shared with the previous build during a rolling deploy', () => { + it('serves a fresh token the previous build stored down to the 30-second margin without calling Vanta', async () => { + const input = fields('legacy-fresh') + await storePreviousBuildToken( + previousBuildKeys(input, READ), + 'legacy-fresh-token', + READ, + 60_000 + ) + const count = tokenRequests + + await expect(resolveVantaClientToken(input)).resolves.toMatchObject({ + accessToken: 'legacy-fresh-token', + apiDomain: 'https://api.vanta.com', + }) + expect(tokenRequests).toBe(count) + }) + + it('honors a refusal the previous build remembered without calling Vanta', async () => { + const input = fields('legacy-failure') + const { encrypted } = await encryptSecret( + JSON.stringify({ code: 'invalid_credentials', status: 401 }) + ) + await db.insert(clientCredentialToken).values({ + id: previousBuildKeys(input, READ).failureKey, + encryptedValue: encrypted, + expiresAt: new Date(Date.now() + 20_000), + }) + const count = tokenRequests + + await expect(resolveVantaClientToken(input)).rejects.toMatchObject({ + code: 'invalid_credentials', + status: 401, + }) + expect(tokenRequests).toBe(count) + }) + + it('refuses other permissions for an expired application the previous build pinned', async () => { + const input = fields('legacy-expired-scope') + await storePreviousBuildToken(previousBuildKeys(input, READ), 'legacy-expired', READ, -1000) + const count = tokenRequests + + await expect(resolveVantaClientToken({ ...input, scope: WRITE })).rejects.toMatchObject({ + code: 'permission_conflict', + }) + expect(tokenRequests).toBe(count) + }) + + it('stores a minted token where and how the previous build reads it', async () => { + const input = fields('legacy-readable-mint') + const keys = previousBuildKeys(input, READ) + + const { accessToken } = await resolveVantaClientToken(input) + + const row = await readStoredRow(keys.applicationKey) + expect(row.decrypted).toBe( + JSON.stringify({ accessToken, secretFingerprint: keys.secretFingerprint, scope: READ }) + ) + expect(row.accessTokenDigest).toBe(keys.accessTokenDigest(accessToken)) + }) + + it('stores a refusal where and how the previous build reads it', async () => { + const input = fields('legacy-readable-failure') + const keys = previousBuildKeys({ ...input, clientSecret: 'revoked-secret' }, READ) + + await expect( + resolveVantaClientToken({ ...input, clientSecret: 'revoked-secret' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + + const row = await readStoredRow(keys.failureKey) + expect(row.decrypted).toBe(JSON.stringify({ code: 'invalid_credentials', status: 401 })) + expect(row.accessTokenDigest).toBeNull() + }) + + it('expires a token the previous build stored when Vanta rejects it', async () => { + const input = fields('legacy-invalidated') + const keys = previousBuildKeys(input, READ) + await storePreviousBuildToken(keys, 'legacy-rejected-token', READ, 3_600_000) + + await invalidateVantaClientToken('legacy-rejected-token', 'https://api.vanta.com') + + expect((await readStoredRow(keys.applicationKey)).expiresAt.getTime()).toBe(0) + }) + + it('waits on the application lock the previous build holds and serves the token it minted', async () => { + const input = fields('legacy-lock') + const keys = previousBuildKeys(input, READ) + const locked = createDeferred() + const commit = createDeferred() + const previousBuild = db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${keys.applicationKey}, 0))` + ) + locked.resolve() + await commit.promise + await storePreviousBuildToken(keys, 'legacy-locked-token', READ, 3_600_000, tx) + }) + await locked.promise + const count = tokenRequests + const pending = { settled: false } + const resolution = resolveVantaClientToken(input).finally(() => { + pending.settled = true + }) + + try { + await lockWaiterAppears(keys.applicationKey, pending) + } finally { + commit.resolve() + await previousBuild + } + + await expect(resolution).resolves.toMatchObject({ accessToken: 'legacy-locked-token' }) + expect(tokenRequests).toBe(count) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.test.ts b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.test.ts deleted file mode 100644 index df4c068bbd2..00000000000 --- a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Old and new workers share `client_credential_token` during a rollout, and Vanta revokes a - * token when another is minted for the application, so every row id, digest, lock key, and - * payload here is pinned to the exact format earlier builds wrote and read. - */ -import { createHash } from 'node:crypto' -import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock' -import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' -import { schemaMock } from '@sim/testing/mocks/schema.mock' -import { eq, inArray, sql } from 'drizzle-orm' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/core/security/encryption', () => encryptionMock) - -import { - invalidateVantaClientToken, - resolveVantaClientToken, -} from '@/lib/credentials/client-credential-accounts/vanta-token' - -const API_DOMAIN = 'https://api.vanta.com' -const CLIENT_ID = 'vanta-client' -const CLIENT_SECRET = 'vanta-secret' -const READ = 'vanta-api.all:read' -const WRITE = 'vanta-api.all:read vanta-api.all:write' -const table = schemaMock.clientCredentialToken - -function sha256Json(value: unknown): string { - return createHash('sha256').update(JSON.stringify(value)).digest('hex') -} - -const APPLICATION_KEY = `vanta:${sha256Json([API_DOMAIN, CLIENT_ID])}` -const SECRET_FINGERPRINT = sha256Json([API_DOMAIN, CLIENT_ID, CLIENT_SECRET]) -const failureKey = (scope: string) => - `${APPLICATION_KEY}:${sha256Json([SECRET_FINGERPRINT, scope])}` -const accessTokenDigest = (accessToken: string) => sha256Json([API_DOMAIN, accessToken]) - -function storedRow(id: string, payload: object, expiresInMs: number, tokenDigest?: string) { - return { - id, - encryptedValue: JSON.stringify(payload), - accessTokenDigest: tokenDigest ?? null, - expiresAt: new Date(Date.now() + expiresInMs), - createdAt: new Date(), - updatedAt: new Date(), - } -} - -function applicationRow(scope: string, expiresInMs: number) { - return storedRow( - APPLICATION_KEY, - { accessToken: 'stored-token', secretFingerprint: SECRET_FINGERPRINT, scope }, - expiresInMs, - accessTokenDigest('stored-token') - ) -} - -/** Queues the rows for both lookups a resolution may make: unlocked, then under the lock. */ -function storeRows(rows: object[]) { - queueTableRows(table, rows) - queueTableRows(table, rows) -} - -function lookedUpIds() { - return [ - ...vi - .mocked(eq) - .mock.calls.filter(([column]) => Object.is(column, table.id)) - .map(([, id]) => id), - ...vi - .mocked(inArray) - .mock.calls.filter(([column]) => Object.is(column, table.id)) - .flatMap(([, ids]) => ids as unknown[]), - ] -} - -function writtenRows() { - return dbChainMockFns.values.mock.calls.map(([row]) => row as Record) -} - -function advisoryLock() { - const [, key, tag] = - vi - .mocked(sql) - .mock.calls.find(([strings]) => strings.join('').includes('pg_advisory_xact_lock')) ?? [] - return { key, tag: (tag as { rawSql?: string } | undefined)?.rawSql } -} - -const fields = { clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, scope: READ } -const mockFetch = vi.fn() - -describe('Vanta token rows stay readable across a rolling deploy', () => { - beforeEach(() => { - resetDbChainMock() - encryptionMockFns.mockEncryptSecret.mockImplementation(async (value) => ({ encrypted: value })) - encryptionMockFns.mockDecryptSecret.mockImplementation(async (value) => ({ decrypted: value })) - mockFetch.mockReset() - mockFetch.mockImplementation(async () => - Response.json({ access_token: 'minted-token', expires_in: 3600, token_type: 'Bearer' }) - ) - vi.stubGlobal('fetch', mockFetch) - }) - - it('serves a stored application token down to its 30-second reuse margin', async () => { - storeRows([applicationRow(READ, 60_000)]) - - const result = await resolveVantaClientToken(fields) - - expect(result).toMatchObject({ accessToken: 'stored-token', apiDomain: API_DOMAIN }) - expect(lookedUpIds()).toContain(APPLICATION_KEY) - expect([APPLICATION_KEY, failureKey(READ)]).toEqual(expect.arrayContaining(lookedUpIds())) - expect(mockFetch).not.toHaveBeenCalled() - }) - - it('refuses other permissions for a stored application even after its token expired', async () => { - storeRows([applicationRow(READ, -1000)]) - - await expect(resolveVantaClientToken({ ...fields, scope: WRITE })).rejects.toMatchObject({ - code: 'permission_conflict', - }) - expect(mockFetch).not.toHaveBeenCalled() - }) - - it('honors a stored failure for the same secret and scope without calling Vanta', async () => { - storeRows([storedRow(failureKey(READ), { code: 'invalid_credentials', status: 401 }, 20_000)]) - - await expect(resolveVantaClientToken(fields)).rejects.toMatchObject({ - code: 'invalid_credentials', - status: 401, - }) - expect(mockFetch).not.toHaveBeenCalled() - }) - - it('mints under the shared lock and stores the token in the established format', async () => { - await resolveVantaClientToken(fields) - - expect(advisoryLock()).toEqual({ key: APPLICATION_KEY, tag: "/*lock='vanta_client_token'*/" }) - const [row] = writtenRows() - expect(row).toMatchObject({ - id: APPLICATION_KEY, - accessTokenDigest: accessTokenDigest('minted-token'), - }) - expect(JSON.parse(String(row.encryptedValue))).toEqual({ - accessToken: 'minted-token', - secretFingerprint: SECRET_FINGERPRINT, - scope: READ, - }) - expect(eq).toHaveBeenCalledWith(table.id, failureKey(READ)) - }) - - it('stores a refusal in the established failure format', async () => { - mockFetch.mockImplementation(async () => new Response(null, { status: 401 })) - - await expect(resolveVantaClientToken(fields)).rejects.toMatchObject({ - code: 'invalid_credentials', - }) - const [row] = writtenRows() - expect(row.id).toBe(failureKey(READ)) - expect(JSON.parse(String(row.encryptedValue))).toEqual({ - code: 'invalid_credentials', - status: 401, - }) - }) - - it('expires a rejected token by its established digest', async () => { - await invalidateVantaClientToken('stored-token', API_DOMAIN) - - expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ expiresAt: new Date(0) }) - ) - expect(eq).toHaveBeenCalledWith(table.accessTokenDigest, accessTokenDigest('stored-token')) - }) -}) diff --git a/apps/sim/lib/credentials/coordinated-client-token.integration.ts b/apps/sim/lib/credentials/coordinated-client-token.integration.ts index 845cb65a8c1..714466025d9 100644 --- a/apps/sim/lib/credentials/coordinated-client-token.integration.ts +++ b/apps/sim/lib/credentials/coordinated-client-token.integration.ts @@ -5,6 +5,7 @@ import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { eq, like, or } from 'drizzle-orm' import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' +import { decryptSecret } from '@/lib/core/security/encryption' import { type CoordinatedClientTokenInput, invalidateCoordinatedClientToken, @@ -16,8 +17,22 @@ import { TokenServiceAccountValidationError } from '@/lib/credentials/token-serv const NAMESPACE = `fixture${generateId().replaceAll('-', '')}` const ownedKeyPrefixes = new Set([`${NAMESPACE}:`]) +function sha256Json(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} + function applicationKey(namespace: string, application: readonly string[]): string { - return `${namespace}:${createHash('sha256').update(JSON.stringify(application)).digest('hex')}` + return `${namespace}:${sha256Json(application)}` +} + +async function readStoredRow(id: string) { + const [row] = await db + .select() + .from(clientCredentialToken) + .where(eq(clientCredentialToken.id, id)) + .limit(1) + if (!row) throw new Error('Expected a stored row') + return { ...row, decrypted: (await decryptSecret(row.encryptedValue)).decrypted } } /** A provider that issues numbered tokens per application and counts every exchange. */ @@ -209,6 +224,39 @@ describe('coordinated client-credentials token against shared storage', () => { }) }) +describe('default token rows shared with the previous build during a rolling deploy', () => { + it('stores tokens and refusals under the ids, digest, and payloads the previous build used', async () => { + const application = ['legacy.myshopify.com', 'legacy-client'] + const appKey = applicationKey(NAMESPACE, application) + const fingerprint = (secret: string) => sha256Json([...application, secret]) + const shopify = (clientSecret: string, exchange: CoordinatedClientTokenInput['exchange']) => ({ + namespace: NAMESPACE, + application, + clientSecret, + step: 'mint', + exchange, + }) + + const { accessToken } = await resolveCoordinatedClientToken( + shopify('secret-1', provider('legacy-default').exchange) + ) + const minted = await readStoredRow(appKey) + expect(minted.decrypted).toBe( + JSON.stringify({ accessToken, secretFingerprint: fingerprint('secret-1') }) + ) + expect(minted.accessTokenDigest).toBe(sha256Json(['coordinated_client_token', accessToken])) + + await invalidateCoordinatedClientToken(accessToken) + expect((await readStoredRow(appKey)).expiresAt.getTime()).toBe(0) + + await expect( + resolveCoordinatedClientToken(shopify('revoked', rejecting())) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + const refusal = await readStoredRow(`${appKey}:${sha256Json(fingerprint('revoked'))}`) + expect(refusal.decrypted).toBe(JSON.stringify({ code: 'invalid_credentials', status: 401 })) + }) +}) + describe('saving a Shopify client-credentials credential', () => { const shop = `fixture-${generateId().slice(0, 8)}.myshopify.com` const clientId = `client-${generateId()}` diff --git a/apps/sim/lib/credentials/coordinated-client-token.test.ts b/apps/sim/lib/credentials/coordinated-client-token.test.ts deleted file mode 100644 index b96135e6fc4..00000000000 --- a/apps/sim/lib/credentials/coordinated-client-token.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Old and new workers share `client_credential_token` during a rollout, so the default row ids, - * digests, lock key, and payloads (used by Shopify, Atlassian, and Linear) stay pinned to the - * format earlier builds wrote and read. - */ -import { createHash } from 'node:crypto' -import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock' -import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' -import { schemaMock } from '@sim/testing/mocks/schema.mock' -import { eq, sql } from 'drizzle-orm' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/core/security/encryption', () => encryptionMock) - -import { - invalidateCoordinatedClientToken, - resolveCoordinatedClientToken, -} from '@/lib/credentials/coordinated-client-token' -import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' - -const APPLICATION = ['shop.myshopify.com', 'shopify-client'] as const -const CLIENT_SECRET = 'shopify-secret' -const table = schemaMock.clientCredentialToken - -function sha256Json(value: unknown): string { - return createHash('sha256').update(JSON.stringify(value)).digest('hex') -} - -const APPLICATION_KEY = `shopify:${sha256Json(APPLICATION)}` -const SECRET_FINGERPRINT = sha256Json([...APPLICATION, CLIENT_SECRET]) -const FAILURE_KEY = `${APPLICATION_KEY}:${sha256Json(SECRET_FINGERPRINT)}` -const accessTokenDigest = (accessToken: string) => - sha256Json(['coordinated_client_token', accessToken]) - -function writtenRows() { - return dbChainMockFns.values.mock.calls.map(([row]) => row as Record) -} - -function advisoryLock() { - const [, key, tag] = - vi - .mocked(sql) - .mock.calls.find(([strings]) => strings.join('').includes('pg_advisory_xact_lock')) ?? [] - return { key, tag: (tag as { rawSql?: string } | undefined)?.rawSql } -} - -const exchange = - vi.fn<(signal?: AbortSignal) => Promise<{ accessToken: string; expiresAt: Date }>>() -const input = { - namespace: 'shopify', - application: APPLICATION, - clientSecret: CLIENT_SECRET, - step: 'shopify_token_mint', - exchange, -} - -describe('default coordinated token rows stay readable across a rolling deploy', () => { - beforeEach(() => { - resetDbChainMock() - encryptionMockFns.mockEncryptSecret.mockImplementation(async (value) => ({ encrypted: value })) - encryptionMockFns.mockDecryptSecret.mockImplementation(async (value) => ({ decrypted: value })) - exchange.mockReset() - exchange.mockResolvedValue({ - accessToken: 'minted-token', - expiresAt: new Date(Date.now() + 3_600_000), - }) - }) - - it('replaces a token inside the five-minute margin and stores it in the established format', async () => { - queueTableRows(table, [ - { - id: APPLICATION_KEY, - encryptedValue: JSON.stringify({ - accessToken: 'stored-token', - secretFingerprint: SECRET_FINGERPRINT, - }), - accessTokenDigest: accessTokenDigest('stored-token'), - expiresAt: new Date(Date.now() + 60_000), - createdAt: new Date(), - updatedAt: new Date(), - }, - ]) - - await expect(resolveCoordinatedClientToken(input)).resolves.toMatchObject({ - accessToken: 'minted-token', - }) - - expect(advisoryLock()).toEqual({ - key: APPLICATION_KEY, - tag: "/*lock='shopify_client_token'*/", - }) - const [row] = writtenRows() - expect(row).toMatchObject({ - id: APPLICATION_KEY, - accessTokenDigest: accessTokenDigest('minted-token'), - }) - expect(JSON.parse(String(row.encryptedValue))).toEqual({ - accessToken: 'minted-token', - secretFingerprint: SECRET_FINGERPRINT, - }) - }) - - it('stores a refusal under the established failure key', async () => { - exchange.mockRejectedValue( - new TokenServiceAccountValidationError('grant_not_permitted', 403, { step: 'test' }) - ) - - await expect(resolveCoordinatedClientToken(input)).rejects.toMatchObject({ - code: 'grant_not_permitted', - }) - const [row] = writtenRows() - expect(row.id).toBe(FAILURE_KEY) - expect(JSON.parse(String(row.encryptedValue))).toEqual({ - code: 'grant_not_permitted', - status: 403, - }) - }) - - it('expires a rejected token by its established digest', async () => { - await invalidateCoordinatedClientToken('stored-token') - - expect(eq).toHaveBeenCalledWith(table.accessTokenDigest, accessTokenDigest('stored-token')) - }) -})