Skip to content
Merged
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
10 changes: 9 additions & 1 deletion apps/sim/app/api/auth/shopify/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { shopifyAuthorizeQuerySchema } from '@/lib/api/contracts/oauth-connections'
import {
shopifyAuthorizeQuerySchema,
shopifyShopDomainSchema,
} from '@/lib/api/contracts/oauth-connections'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { getBaseUrl } from '@/lib/core/utils/urls'
Expand Down Expand Up @@ -161,6 +164,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
cleanShop = `${cleanShop.replace('.myshopify.com', '')}.myshopify.com`
}

if (!shopifyShopDomainSchema.safeParse(cleanShop).success) {
logger.warn('Rejected invalid Shopify shop domain', { shop: shopDomain })
return NextResponse.json({ error: 'Invalid Shopify shop domain' }, { status: 400 })
}

const baseUrl = getBaseUrl()
const redirectUri = `${baseUrl}/api/auth/oauth2/callback/shopify`

Expand Down
130 changes: 130 additions & 0 deletions apps/sim/app/api/workflows/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* Tests for workflow access middleware — focused on the workspace-scoped
* API key boundary check in the `requireDeployment=false` branch.
*
* @vitest-environment node
*/

import {
hybridAuthMockFns,
workflowAuthzMock,
workflowAuthzMockFns,
workflowsUtilsMock,
workflowsUtilsMockFns,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
vi.mock('@sim/workflow-authz', () => workflowAuthzMock)
vi.mock('@/lib/api-key/service', () => ({
authenticateApiKeyFromHeader: vi.fn(),
updateApiKeyLastUsed: vi.fn(),
}))

import { validateWorkflowAccess } from '@/app/api/workflows/middleware'

function makeRequest() {
return new NextRequest(new URL('https://example.com/api/workflows/wf-1/log'))
}

describe('validateWorkflowAccess (requireDeployment=false)', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowsUtilsMockFns.mockGetWorkflowById.mockResolvedValue({
id: 'wf-1',
workspaceId: 'ws-A',
isDeployed: true,
})
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: { id: 'wf-1', workspaceId: 'ws-A' },
})
})

it('rejects a workspace-scoped API key issued for a different workspace', async () => {
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
authType: 'api_key',
apiKeyType: 'workspace',
workspaceId: 'ws-B',
})

const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)

expect(result.error).toEqual({
message: 'API key is not authorized for this workspace',
status: 403,
})
expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
})

it('allows a workspace-scoped API key issued for the matching workspace', async () => {
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
authType: 'api_key',
apiKeyType: 'workspace',
workspaceId: 'ws-A',
})

const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)

expect(result.error).toBeUndefined()
expect(result.workflow).toBeDefined()
expect(result.auth?.workspaceId).toBe('ws-A')
expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({
workflowId: 'wf-1',
userId: 'user-1',
action: 'read',
})
})

it('allows a personal API key regardless of workspaceId on the auth result', async () => {
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
authType: 'api_key',
apiKeyType: 'personal',
workspaceId: 'ws-B',
})

const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)

expect(result.error).toBeUndefined()
expect(result.workflow).toBeDefined()
})

it('allows session auth (no apiKeyType) when workspace permission grants access', async () => {
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
authType: 'session',
})

const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)

expect(result.error).toBeUndefined()
expect(result.workflow).toBeDefined()
})

it('still enforces workspace-permission rejection for personal keys', async () => {
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
authType: 'api_key',
apiKeyType: 'personal',
})
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
allowed: false,
status: 403,
message: 'Access denied',
})

const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)

expect(result.error).toEqual({ message: 'Access denied', status: 403 })
})
})
9 changes: 9 additions & 0 deletions apps/sim/app/api/workflows/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ export async function validateWorkflowAccess(
}
}

if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflow.workspaceId) {
return {
error: {
message: 'API key is not authorized for this workspace',
status: 403,
},
}
}

const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId: auth.userId,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/api/contracts/oauth-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export const oauthAuthorizeParamsResponseSchema = z.object({
response_type: z.literal('code'),
})

const SHOPIFY_SHOP_DOMAIN_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/
const SHOPIFY_SHOP_DOMAIN_REGEX = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]\.myshopify\.com$/
export const shopifyShopDomainSchema = z.string().regex(SHOPIFY_SHOP_DOMAIN_REGEX)

export const listOAuthConnectionsContract = defineRouteContract({
Expand Down
Loading