Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions apps/sim/blocks/blocks/credential-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,13 @@ const CREDENTIAL_GROUP_CANONICAL_GROUP = {
advancedIds: ['manualCredentialGroup'],
} as const satisfies CanonicalGroup

async function fetchCachedCredentialGroups(signal?: AbortSignal) {
async function fetchCachedCredentialGroups() {
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
if (!workspaceId) return []

return getQueryClient().fetchQuery({
queryKey: credentialGroupKeys.list(workspaceId),
queryFn: ({ signal: querySignal }) =>
fetchCredentialGroupList(workspaceId, signal ?? querySignal),
queryFn: ({ signal }) => fetchCredentialGroupList(workspaceId, signal),
staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
})
}
Expand Down Expand Up @@ -170,8 +169,8 @@ export const CredentialGroupBlock: BlockConfig<CredentialGroupBlockOutput> = {
.map((group) => ({ label: group.name, id: group.id }))
.sort((a, b) => a.label.localeCompare(b.label))
},
fetchOptionById: async (_blockId: string, optionId: string, signal?: AbortSignal) => {
const groups = await fetchCachedCredentialGroups(signal)
fetchOptionById: async (_blockId: string, optionId: string) => {
const groups = await fetchCachedCredentialGroups()
const group = groups.find((candidate) => candidate.id === optionId)
return group ? { label: group.name, id: group.id } : null
},
Expand Down Expand Up @@ -220,10 +219,10 @@ export const CredentialGroupBlock: BlockConfig<CredentialGroupBlockOutput> = {
})
.sort((a, b) => a.label.localeCompare(b.label))
},
fetchOptionById: async (blockId: string, optionId: string, signal?: AbortSignal) => {
fetchOptionById: async (blockId: string, optionId: string) => {
const credentialGroupId = resolveCredentialGroupIdForBlock(blockId)
if (!credentialGroupId) return null
const groups = await fetchCachedCredentialGroups(signal)
const groups = await fetchCachedCredentialGroups()
const group = groups.find((candidate) => candidate.id === credentialGroupId)
const option = group?.options.find(
(candidate) =>
Expand Down
7 changes: 6 additions & 1 deletion apps/sim/lib/api/contracts/credential-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
CREDENTIAL_GROUP_PROVIDER_IDS,
CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS,
} from '@/lib/credential-groups/providers'
import { CREDENTIAL_GROUP_ENROLLMENT_CURSOR_MAX_LENGTH } from '@/lib/credential-groups/types'

export const credentialGroupProviderSchema = z.enum(CREDENTIAL_GROUP_PROVIDER_IDS)
export const credentialGroupStatusSchema = z.enum(['active', 'disabled'])
Expand Down Expand Up @@ -169,7 +170,11 @@ export const slackCredentialGroupConfigurationCallbackQuerySchema =
credentialGroupOAuthCallbackQuerySchema

export const credentialGroupEnrollmentListQuerySchema = z.object({
cursor: z.string().min(1, 'Enrollment cursor cannot be empty').max(128).optional(),
cursor: z
.string()
.min(1, 'Enrollment cursor cannot be empty')
.max(CREDENTIAL_GROUP_ENROLLMENT_CURSOR_MAX_LENGTH)
.optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
})

Expand Down
68 changes: 68 additions & 0 deletions apps/sim/lib/credential-groups/enrollments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
import { inArray } from 'drizzle-orm'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { credentialGroupEnrollmentListQuerySchema } from '@/lib/api/contracts/credential-groups'

const { adapter } = vi.hoisted(() => ({
adapter: {
Expand Down Expand Up @@ -151,6 +152,9 @@ describe('listCredentialGroupEnrollments', () => {
statuses: ['invited', 'in_progress', 'completed', 'delivery_failed'],
})
if (!firstPage.nextCursor) throw new Error('Expected a next enrollment cursor')
expect(
credentialGroupEnrollmentListQuerySchema.parse({ cursor: firstPage.nextCursor }).cursor
).toBe(firstPage.nextCursor)
const result = await listCredentialGroupEnrollments(
'workspace-1',
'group-1',
Expand All @@ -171,6 +175,70 @@ describe('listCredentialGroupEnrollments', () => {
])
})

it('rejects a cursor replayed against another credential group or filter', async () => {
const remainingEnrollment = {
...ENROLLMENT,
id: 'enrollment-2',
invitedAt: new Date('2026-08-10T12:00:00.000Z'),
}
dbChainMockFns.limit
.mockResolvedValueOnce([{ options: [] }])
.mockResolvedValueOnce([{ enrollment: ENROLLMENT }, { enrollment: remainingEnrollment }])
.mockResolvedValueOnce([{ options: [] }])
.mockResolvedValueOnce([{ options: [] }])

const filters = { statuses: ['invited' as const, 'completed' as const] }
const firstPage = await listCredentialGroupEnrollments(
'workspace-1',
'group-1',
1,
undefined,
filters
)
if (!firstPage.nextCursor) throw new Error('Expected a next enrollment cursor')

await expect(
listCredentialGroupEnrollments('workspace-1', 'group-2', 50, firstPage.nextCursor, filters)
).rejects.toMatchObject({ message: 'Enrollment cursor is invalid', status: 400 })
await expect(
listCredentialGroupEnrollments('workspace-1', 'group-1', 50, firstPage.nextCursor, {
statuses: ['completed'],
})
).rejects.toMatchObject({ message: 'Enrollment cursor is invalid', status: 400 })
})

it('rejects a cursor whose signed boundary was modified', async () => {
const remainingEnrollment = {
...ENROLLMENT,
id: 'enrollment-2',
invitedAt: new Date('2026-08-10T12:00:00.000Z'),
}
dbChainMockFns.limit
.mockResolvedValueOnce([{ options: [] }])
.mockResolvedValueOnce([{ enrollment: ENROLLMENT }, { enrollment: remainingEnrollment }])
.mockResolvedValueOnce([{ options: [] }])

const firstPage = await listCredentialGroupEnrollments('workspace-1', 'group-1', 1)
if (!firstPage.nextCursor) throw new Error('Expected a next enrollment cursor')
const [encoded, signature] = firstPage.nextCursor.split('.')
if (!encoded || !signature) throw new Error('Expected a signed enrollment cursor')
const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as Record<
string,
unknown
>
payload.id = 'fabricated-boundary'
const modifiedEncoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')

await expect(
listCredentialGroupEnrollments(
'workspace-1',
'group-1',
50,
`${modifiedEncoded}.${signature}`
)
).rejects.toMatchObject({ message: 'Enrollment cursor is invalid', status: 400 })
})

it('rejects a malformed enrollment cursor', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ options: [] }])

Expand Down
54 changes: 45 additions & 9 deletions apps/sim/lib/credential-groups/enrollments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@ import {
user,
workspace,
} from '@sim/db/schema'
import { safeCompare } from '@sim/security/compare'
import { sha256Hex } from '@sim/security/hash'
import { hmacSha256Hex } from '@sim/security/hmac'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { normalizeEmail, truncate } from '@sim/utils/string'
import { and, count, desc, eq, inArray, lt, or, sql } from 'drizzle-orm'
import { renderCredentialGroupInvitationEmail } from '@/components/emails/credential-groups/render'
import { getCredentialGroupInvitationSubject } from '@/components/emails/subjects'
import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access'
import { env } from '@/lib/core/config/env'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability'
import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry'
Expand Down Expand Up @@ -134,26 +137,41 @@ export class CredentialGroupEnrollmentError extends Error {
}

interface CredentialGroupEnrollmentCursor {
v: 1
id: string
invitedAt: Date
}

function encodeCredentialGroupEnrollmentCursor(
enrollment: Pick<EnrollmentRow, 'id' | 'invitedAt'>
enrollment: Pick<EnrollmentRow, 'id' | 'invitedAt'>,
scope: string
): string {
return Buffer.from(
JSON.stringify({ id: enrollment.id, invitedAt: enrollment.invitedAt.toISOString() })
const encoded = Buffer.from(
JSON.stringify({ v: 1, id: enrollment.id, invitedAt: enrollment.invitedAt.toISOString() })
).toString('base64url')
return `${encoded}.${hmacSha256Hex(`${encoded}.${scope}`, env.BETTER_AUTH_SECRET)}`
Comment thread
TheodoreSpeaks marked this conversation as resolved.
Comment thread
TheodoreSpeaks marked this conversation as resolved.
Comment thread
TheodoreSpeaks marked this conversation as resolved.
}

function decodeCredentialGroupEnrollmentCursor(cursor: string): CredentialGroupEnrollmentCursor {
function decodeCredentialGroupEnrollmentCursor(
cursor: string,
scope: string
): CredentialGroupEnrollmentCursor {
try {
const decoded: unknown = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
const [encoded, signature, extra] = cursor.split('.')
if (!encoded || !signature || extra !== undefined) {
throw new Error('Cursor token is malformed')
}
const expectedSignature = hmacSha256Hex(`${encoded}.${scope}`, env.BETTER_AUTH_SECRET)
if (!safeCompare(signature, expectedSignature)) {
throw new Error('Cursor signature is invalid')
}
const decoded: unknown = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'))
if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) {
throw new Error('Cursor payload must be an object')
}
const { id, invitedAt: invitedAtValue } = decoded as Record<string, unknown>
const { v, id, invitedAt: invitedAtValue } = decoded as Record<string, unknown>
if (
v !== 1 ||
typeof id !== 'string' ||
!id.trim() ||
id !== id.trim() ||
Expand All @@ -166,12 +184,27 @@ function decodeCredentialGroupEnrollmentCursor(cursor: string): CredentialGroupE
if (Number.isNaN(invitedAt.getTime()) || invitedAt.toISOString() !== invitedAtValue) {
throw new Error('Cursor timestamp is invalid')
}
return { id, invitedAt }
return { v, id, invitedAt }
} catch {
throw new CredentialGroupEnrollmentError('Enrollment cursor is invalid', 400)
}
}

function credentialGroupEnrollmentCursorScope(
workspaceId: string,
groupId: string,
filters: ListCredentialGroupEnrollmentFilters
): string {
return sha256Hex(
JSON.stringify({
workspaceId,
groupId,
email: filters.email || null,
statuses: [...new Set(filters.statuses ?? [])].sort(),
})
)
}

function hashInvitationToken(token: string): string {
return sha256Hex(token)
}
Expand Down Expand Up @@ -482,7 +515,10 @@ export async function listCredentialGroupEnrollments(
.filter((option) => option.status === 'active')
.map((option) => option.id)

const cursorPosition = cursor ? decodeCredentialGroupEnrollmentCursor(cursor) : undefined
const cursorScope = credentialGroupEnrollmentCursorScope(workspaceId, groupId, filters)
const cursorPosition = cursor
? decodeCredentialGroupEnrollmentCursor(cursor, cursorScope)
: undefined

const rows = await db
.select({ enrollment: credentialGroupEnrollment })
Expand Down Expand Up @@ -564,7 +600,7 @@ export async function listCredentialGroupEnrollments(
connections: connectionsByEnrollment.get(enrollment.id) ?? [],
})),
nextCursor: nextCursorEnrollment
? encodeCredentialGroupEnrollmentCursor(nextCursorEnrollment)
? encodeCredentialGroupEnrollmentCursor(nextCursorEnrollment, cursorScope)
: null,
}
}
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/credential-groups/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { CredentialGroupProvider } from '@/lib/credential-groups/providers'

export const CREDENTIAL_GROUP_ENROLLMENT_CURSOR_MAX_LENGTH = 256

interface CredentialGroupOptionInputBase {
label: string
required: boolean
Expand Down
Loading