chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,139 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handlerMocks = vi.hoisted(() => ({
betterAuthGET: vi.fn(),
betterAuthPOST: vi.fn(),
ensureAnonymousUserExists: vi.fn(),
createAnonymousSession: vi.fn(() => ({
user: { id: 'anon' },
session: { id: 'anon-session' },
})),
isAuthDisabled: false,
}))
vi.mock('better-auth/next-js', () => ({
toNextJsHandler: () => ({
GET: handlerMocks.betterAuthGET,
POST: handlerMocks.betterAuthPOST,
}),
}))
vi.mock('@/lib/auth', () => ({
auth: { handler: {} },
}))
vi.mock('@/lib/auth/anonymous', () => ({
ensureAnonymousUserExists: handlerMocks.ensureAnonymousUserExists,
createAnonymousSession: handlerMocks.createAnonymousSession,
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isAuthDisabled() {
return handlerMocks.isAuthDisabled
},
}))
import { GET, POST } from '@/app/api/auth/[...all]/route'
describe('auth catch-all route (DISABLE_AUTH get-session)', () => {
beforeEach(() => {
vi.clearAllMocks()
handlerMocks.isAuthDisabled = false
})
it('returns anonymous session in better-auth response envelope when auth is disabled', async () => {
handlerMocks.isAuthDisabled = true
const req = createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/auth/get-session'
)
const res = await GET(req as any)
const json = await res.json()
expect(handlerMocks.ensureAnonymousUserExists).toHaveBeenCalledTimes(1)
expect(handlerMocks.betterAuthGET).not.toHaveBeenCalled()
expect(json).toEqual({
user: { id: 'anon' },
session: { id: 'anon-session' },
})
})
it('delegates to better-auth handler when auth is enabled', async () => {
handlerMocks.isAuthDisabled = false
const { NextResponse } = await import('next/server')
handlerMocks.betterAuthGET.mockResolvedValueOnce(
new NextResponse(JSON.stringify({ data: { ok: true } }), {
headers: { 'content-type': 'application/json' },
}) as any
)
const req = createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/auth/get-session'
)
const res = await GET(req as any)
const json = await res.json()
expect(handlerMocks.ensureAnonymousUserExists).not.toHaveBeenCalled()
expect(handlerMocks.betterAuthGET).toHaveBeenCalledTimes(1)
expect(json).toEqual({ data: { ok: true } })
})
})
describe('auth catch-all route organization mutations', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('blocks Better Auth organization mutation endpoints that bypass app lifecycle rules', async () => {
const req = createMockRequest(
'POST',
undefined,
{},
'http://localhost:3000/api/auth/organization/create'
)
const res = await POST(req as any)
const json = await res.json()
expect(res.status).toBe(404)
expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled()
expect(json).toEqual({
error: 'Organization mutations are handled by application API routes.',
})
})
it('allows safe Better Auth organization session endpoints', async () => {
const { NextResponse } = await import('next/server')
handlerMocks.betterAuthPOST.mockResolvedValueOnce(
new NextResponse(JSON.stringify({ data: { ok: true } }), {
headers: { 'content-type': 'application/json' },
}) as any
)
const req = createMockRequest(
'POST',
undefined,
{},
'http://localhost:3000/api/auth/organization/set-active'
)
const res = await POST(req as any)
const json = await res.json()
expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1)
expect(json).toEqual({ data: { ok: true } })
})
})
+44
View File
@@ -0,0 +1,44 @@
import { toNextJsHandler } from 'better-auth/next-js'
import { type NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
import { isAuthDisabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
export const dynamic = 'force-dynamic'
const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler)
const SAFE_ORGANIZATION_POST_PATHS = new Set(['organization/check-slug', 'organization/set-active'])
function getAuthPath(request: NextRequest): string {
const pathname = request.nextUrl?.pathname ?? new URL(request.url).pathname
return pathname.replace('/api/auth/', '')
}
function isBlockedOrganizationMutationPath(path: string): boolean {
return path.startsWith('organization/') && !SAFE_ORGANIZATION_POST_PATHS.has(path)
}
export const GET = withRouteHandler(async (request: NextRequest) => {
const path = getAuthPath(request)
if (path === 'get-session' && isAuthDisabled) {
await ensureAnonymousUserExists()
return NextResponse.json(createAnonymousSession())
}
return betterAuthGET(request)
})
export const POST = withRouteHandler(async (request: NextRequest) => {
const path = getAuthPath(request)
if (isBlockedOrganizationMutationPath(path)) {
return NextResponse.json(
{ error: 'Organization mutations are handled by application API routes.' },
{ status: 404 }
)
}
return betterAuthPOST(request)
})
+61
View File
@@ -0,0 +1,61 @@
import { db } from '@sim/db'
import { account, credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { connectedAccountsQuerySchema } from '@/lib/api/contracts/oauth-connections'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('AuthAccountsAPI')
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const { provider } = connectedAccountsQuerySchema.parse({
provider: searchParams.get('provider') || undefined,
})
const whereConditions = [eq(account.userId, session.user.id)]
if (provider) {
whereConditions.push(eq(account.providerId, provider))
}
const accounts = await db
.select({
id: account.id,
accountId: account.accountId,
providerId: account.providerId,
credentialDisplayName: credential.displayName,
})
.from(account)
.leftJoin(credential, eq(credential.accountId, account.id))
.where(and(...whereConditions))
.orderBy(desc(account.updatedAt))
const seen = new Map<string, (typeof accounts)[number]>()
for (const acc of accounts) {
if (!seen.has(acc.id)) {
seen.set(acc.id, acc)
}
}
const accountsWithDisplayName = Array.from(seen.values()).map((acc) => ({
id: acc.id,
accountId: acc.accountId,
providerId: acc.providerId,
displayName: acc.credentialDisplayName || acc.accountId || acc.providerId,
}))
return NextResponse.json({ accounts: accountsWithDisplayName })
} catch (error) {
logger.error('Failed to fetch accounts', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,170 @@
/**
* Tests for forget password API route
*
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockRequestPasswordReset, mockLogger } = vi.hoisted(() => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
child: vi.fn(),
}
return {
mockRequestPasswordReset: vi.fn(),
mockLogger: logger,
}
})
vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: vi.fn(() => 'https://app.example.com'),
}))
vi.mock('@/lib/auth', () => ({
auth: {
api: {
requestPasswordReset: mockRequestPasswordReset,
},
},
}))
vi.mock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
getRequestContext: () => undefined,
}))
import { POST } from '@/app/api/auth/forget-password/route'
describe('Forget Password API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRequestPasswordReset.mockResolvedValue(undefined)
})
afterEach(() => {
vi.clearAllMocks()
})
it('should send password reset email successfully with same-origin redirectTo', async () => {
const req = createMockRequest('POST', {
email: 'test@example.com',
redirectTo: 'https://app.example.com/reset',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(mockRequestPasswordReset).toHaveBeenCalledWith({
body: {
email: 'test@example.com',
redirectTo: 'https://app.example.com/reset',
},
method: 'POST',
})
})
it('should reject external redirectTo URL', async () => {
const req = createMockRequest('POST', {
email: 'test@example.com',
redirectTo: 'https://evil.com/phishing',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Redirect URL must be a valid same-origin URL')
expect(mockRequestPasswordReset).not.toHaveBeenCalled()
})
it('should send password reset email without redirectTo', async () => {
const req = createMockRequest('POST', {
email: 'test@example.com',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(mockRequestPasswordReset).toHaveBeenCalledWith({
body: {
email: 'test@example.com',
redirectTo: undefined,
},
method: 'POST',
})
})
it('should handle missing email', async () => {
const req = createMockRequest('POST', {})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Email is required')
expect(mockRequestPasswordReset).not.toHaveBeenCalled()
})
it('should handle empty email', async () => {
const req = createMockRequest('POST', {
email: '',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Please provide a valid email address')
expect(mockRequestPasswordReset).not.toHaveBeenCalled()
})
it('should handle auth service error with message', async () => {
const errorMessage = 'User not found'
mockRequestPasswordReset.mockRejectedValue(new Error(errorMessage))
const req = createMockRequest('POST', {
email: 'nonexistent@example.com',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.message).toBe(errorMessage)
expect(mockLogger.error).toHaveBeenCalledWith('Error requesting password reset:', {
error: expect.any(Error),
})
})
it('should handle unknown error', async () => {
mockRequestPasswordReset.mockRejectedValue('Unknown error')
const req = createMockRequest('POST', {
email: 'test@example.com',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.message).toBe('Failed to send password reset email. Please try again later.')
expect(mockLogger.error).toHaveBeenCalled()
})
})
@@ -0,0 +1,78 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { forgetPasswordContract } from '@/lib/api/contracts'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { auth } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
export const dynamic = 'force-dynamic'
const logger = createLogger('ForgetPasswordAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const parsed = await parseRequest(
forgetPasswordContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn('Invalid forget password request data', { errors: error.issues })
return NextResponse.json(
{ message: getValidationErrorMessage(error, 'Invalid request data') },
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const { email, redirectTo } = parsed.data.body
await auth.api.requestPasswordReset({
body: {
email,
redirectTo,
},
method: 'POST',
})
const [existingUser] = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.email, email))
.limit(1)
if (existingUser) {
recordAudit({
actorId: existingUser.id,
actorName: existingUser.name,
actorEmail: existingUser.email,
action: AuditAction.PASSWORD_RESET_REQUESTED,
resourceType: AuditResourceType.PASSWORD,
resourceId: existingUser.id,
resourceName: existingUser.email ?? undefined,
description: `Password reset requested for ${existingUser.email}`,
request,
})
}
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error requesting password reset:', { error })
return NextResponse.json(
{
message:
error instanceof Error
? error.message
: 'Failed to send password reset email. Please try again later.',
},
{ status: 500 }
)
}
})
@@ -0,0 +1,180 @@
/**
* Tests for OAuth connections API route
*
* @vitest-environment node
*/
import {
authMockFns,
createMockRequest,
dbChainMock,
dbChainMockFns,
resetDbChainMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockParseProvider, mockDecodeJwt, mockEq } = vi.hoisted(() => ({
mockParseProvider: vi.fn(),
mockDecodeJwt: vi.fn(),
mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
}))
vi.mock('@sim/db', () => ({
...dbChainMock,
account: { userId: 'userId', providerId: 'providerId' },
user: { email: 'email', id: 'id' },
eq: mockEq,
}))
vi.mock('drizzle-orm', () => ({
eq: mockEq,
}))
vi.mock('jose', () => ({
decodeJwt: mockDecodeJwt,
}))
vi.mock('@/lib/oauth/utils', () => ({
parseProvider: mockParseProvider,
}))
import { GET } from '@/app/api/auth/oauth/connections/route'
describe('OAuth Connections API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockParseProvider.mockImplementation((providerId: string) => ({
baseProvider: providerId.split('-')[0] || providerId,
featureType: providerId.split('-')[1] || 'default',
}))
})
it('should return connections successfully', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
const mockAccounts = [
{
id: 'account-1',
providerId: 'google-email',
accountId: 'test@example.com',
scope: 'email profile',
updatedAt: new Date('2024-01-01'),
idToken: null,
},
{
id: 'account-2',
providerId: 'github',
accountId: 'testuser',
scope: 'repo',
updatedAt: new Date('2024-01-02'),
idToken: null,
},
]
const mockUserRecord = [{ email: 'user@example.com' }]
dbChainMockFns.where.mockResolvedValueOnce(mockAccounts)
dbChainMockFns.limit.mockResolvedValueOnce(mockUserRecord)
const req = createMockRequest('GET')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.connections).toHaveLength(2)
expect(data.connections[0]).toMatchObject({
provider: 'google-email',
baseProvider: 'google',
featureType: 'email',
isConnected: true,
})
expect(data.connections[1]).toMatchObject({
provider: 'github',
baseProvider: 'github',
featureType: 'default',
isConnected: true,
})
})
it('should handle unauthenticated user', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce(null)
const req = createMockRequest('GET')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('User not authenticated')
})
it('should handle user with no connections', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
dbChainMockFns.where.mockResolvedValueOnce([])
dbChainMockFns.limit.mockResolvedValueOnce([])
const req = createMockRequest('GET')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.connections).toHaveLength(0)
})
it('should handle database error', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error'))
const req = createMockRequest('GET')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Internal server error')
})
it('should decode ID token for display name', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
const mockAccounts = [
{
id: 'account-1',
providerId: 'google',
accountId: 'google-user-id',
scope: 'email profile',
updatedAt: new Date('2024-01-01'),
idToken: 'mock-jwt-token',
},
]
mockDecodeJwt.mockReturnValueOnce({
email: 'decoded@example.com',
name: 'Decoded User',
})
dbChainMockFns.where.mockResolvedValueOnce(mockAccounts)
dbChainMockFns.limit.mockResolvedValueOnce([])
const req = createMockRequest('GET')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.connections[0].accounts[0].name).toBe('decoded@example.com')
})
})
@@ -0,0 +1,139 @@
import { account, db, user } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { decodeJwt } from 'jose'
import { type NextRequest, NextResponse } from 'next/server'
import type { OAuthConnection } from '@/lib/api/contracts/oauth-connections'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { OAuthProvider } from '@/lib/oauth'
import { parseProvider } from '@/lib/oauth'
const logger = createLogger('OAuthConnectionsAPI')
interface GoogleIdToken {
email?: string
sub?: string
name?: string
}
/**
* Get all OAuth connections for the current user
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
// Get the session
const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get all accounts for this user
const accounts = await db.select().from(account).where(eq(account.userId, session.user.id))
// Get the user's email for fallback
const userRecord = await db
.select({ email: user.email })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
const userEmail = userRecord.length > 0 ? userRecord[0]?.email : null
// Process accounts to determine connections
const connections: OAuthConnection[] = []
for (const acc of accounts) {
const { baseProvider, featureType } = parseProvider(acc.providerId as OAuthProvider)
const scopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
if (baseProvider) {
// Try multiple methods to get a user-friendly display name
let displayName = ''
// Method 1: Try to extract email from ID token (works for Google, etc.)
if (acc.idToken) {
try {
const decoded = decodeJwt<GoogleIdToken>(acc.idToken)
if (decoded.email) {
displayName = decoded.email
} else if (decoded.name) {
displayName = decoded.name
}
} catch (_error) {
logger.warn(`[${requestId}] Error decoding ID token`, {
accountId: acc.id,
})
}
}
// Method 2: For GitHub, the accountId might be the username
if (!displayName && baseProvider === 'github') {
displayName = `${acc.accountId} (GitHub)`
}
// Method 3: Use the user's email from our database
if (!displayName && userEmail) {
displayName = userEmail
}
// Fallback: Use accountId with provider type as context
if (!displayName) {
displayName = `${acc.accountId} (${baseProvider})`
}
// Create a unique connection key that includes the full provider ID
const connectionKey = acc.providerId
// Find existing connection for this specific provider ID
const existingConnection = connections.find((conn) => conn.provider === connectionKey)
const accountSummary = {
id: acc.id,
name: displayName,
}
if (existingConnection) {
// Add account to existing connection
existingConnection.accounts = existingConnection.accounts || []
existingConnection.accounts.push(accountSummary)
existingConnection.scopes = Array.from(
new Set([...(existingConnection.scopes || []), ...scopes])
)
const existingTimestamp = existingConnection.lastConnected
? new Date(existingConnection.lastConnected).getTime()
: 0
const candidateTimestamp = acc.updatedAt.getTime()
if (candidateTimestamp > existingTimestamp) {
existingConnection.lastConnected = acc.updatedAt.toISOString()
}
} else {
// Create new connection
connections.push({
provider: connectionKey,
baseProvider,
featureType,
isConnected: true,
scopes,
lastConnected: acc.updatedAt.toISOString(),
accounts: [accountSummary],
})
}
}
}
return NextResponse.json({ connections }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching OAuth connections`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,93 @@
/**
* Tests for OAuth credentials API route
*
* @vitest-environment node
*/
import { hybridAuthMockFns, permissionsMock, workflowsUtilsMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/credentials/oauth', () => ({
syncWorkspaceOAuthCredentialsForUser: vi.fn(),
}))
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
import { GET } from '@/app/api/auth/oauth/credentials/route'
describe('OAuth Credentials API Route', () => {
function createMockRequestWithQuery(method = 'GET', queryParams = ''): NextRequest {
const url = `http://localhost:3000/api/auth/oauth/credentials${queryParams}`
return new NextRequest(new URL(url), { method })
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should handle unauthenticated user', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: false,
error: 'Authentication required',
})
const req = createMockRequestWithQuery('GET', '?provider=google')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('User not authenticated')
})
it('should handle missing provider parameter', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
const req = createMockRequestWithQuery('GET')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Provider or credentialId is required')
})
it('should handle no credentials found', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
const req = createMockRequestWithQuery('GET', '?provider=github')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.credentials).toHaveLength(0)
})
it('should return empty credentials when no workspace context', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
const req = createMockRequestWithQuery('GET', '?provider=google-email')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.credentials).toHaveLength(0)
})
})
@@ -0,0 +1,304 @@
import { db } from '@sim/db'
import { account, credential, credentialMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { and, eq, isNotNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { oauthCredentialsQuerySchema } from '@/lib/api/contracts/credentials'
import { getValidationErrorMessage } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCredentialActorContext } from '@/lib/credentials/access'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import {
getCanonicalScopesForProvider,
getServiceAccountProviderForProviderId,
} from '@/lib/oauth/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('OAuthCredentialsAPI')
function toCredentialResponse(
id: string,
displayName: string,
providerId: string,
updatedAt: Date,
scope: string | null,
credentialType: 'oauth' | 'service_account' = 'oauth'
) {
const storedScope = scope?.trim()
// Some providers (e.g. Box) don't return scopes in their token response,
// so the DB column stays empty. Fall back to the configured scopes for
// the provider so the credential-selector doesn't show a false
// "Additional permissions required" banner.
const scopes = storedScope
? storedScope.split(/[\s,]+/).filter(Boolean)
: getCanonicalScopesForProvider(providerId)
const [_, featureType = 'default'] = providerId.split('-')
return {
id,
name: displayName,
provider: providerId,
type: credentialType,
lastUsed: updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes,
}
}
/**
* Get credentials for a specific provider
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const { searchParams } = new URL(request.url)
const rawQuery = {
provider: searchParams.get('provider'),
workflowId: searchParams.get('workflowId'),
workspaceId: searchParams.get('workspaceId'),
credentialId: searchParams.get('credentialId'),
}
const parseResult = oauthCredentialsQuerySchema.safeParse(rawQuery)
if (!parseResult.success) {
const refinementError = parseResult.error.issues.find((err) => err.code === 'custom')
if (refinementError) {
logger.warn(`[${requestId}] Invalid query parameters: ${refinementError.message}`)
return NextResponse.json({ error: refinementError.message }, { status: 400 })
}
logger.warn(`[${requestId}] Invalid query parameters`, {
errors: parseResult.error.issues,
})
return NextResponse.json(
{ error: getValidationErrorMessage(parseResult.error, 'Validation failed') },
{ status: 400 }
)
}
const { provider: providerParam, workflowId, workspaceId, credentialId } = parseResult.data
// Authenticate requester (supports session and internal JWT)
const authResult = await checkSessionOrInternalAuth(request)
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthenticated credentials request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const requesterUserId = authResult.userId
let effectiveWorkspaceId = workspaceId ?? undefined
if (workflowId) {
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId: requesterUserId,
action: 'read',
})
if (!workflowAuthorization.allowed) {
logger.warn(`[${requestId}] Forbidden credentials request for workflow`, {
requesterUserId,
workflowId,
status: workflowAuthorization.status,
})
return NextResponse.json(
{ error: workflowAuthorization.message || 'Forbidden' },
{ status: workflowAuthorization.status }
)
}
effectiveWorkspaceId = workflowAuthorization.workflow?.workspaceId || undefined
}
let requesterCanAdmin = false
if (effectiveWorkspaceId) {
const workspaceAccess = await checkWorkspaceAccess(effectiveWorkspaceId, requesterUserId)
if (!workspaceAccess.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
requesterCanAdmin = workspaceAccess.canAdmin
}
if (credentialId) {
const [platformCredential] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
providerId: credential.providerId,
accountId: credential.accountId,
updatedAt: credential.updatedAt,
accountProviderId: account.providerId,
accountScope: account.scope,
accountUpdatedAt: account.updatedAt,
})
.from(credential)
.leftJoin(account, eq(credential.accountId, account.id))
.where(eq(credential.id, credentialId))
.limit(1)
if (platformCredential) {
if (platformCredential.type === 'service_account') {
if (
workflowId &&
(!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId)
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (!workflowId) {
const access = await getCredentialActorContext(platformCredential.id, requesterUserId)
if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
return NextResponse.json(
{
credentials: [
toCredentialResponse(
platformCredential.id,
platformCredential.displayName,
platformCredential.providerId || 'google-service-account',
platformCredential.updatedAt,
null,
'service_account'
),
],
},
{ status: 200 }
)
}
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}
if (workflowId) {
if (!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
} else {
const access = await getCredentialActorContext(platformCredential.id, requesterUserId)
if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
if (!platformCredential.accountProviderId || !platformCredential.accountUpdatedAt) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}
return NextResponse.json(
{
credentials: [
toCredentialResponse(
platformCredential.id,
platformCredential.displayName,
platformCredential.accountProviderId,
platformCredential.accountUpdatedAt,
platformCredential.accountScope
),
],
},
{ status: 200 }
)
}
}
if (effectiveWorkspaceId && providerParam) {
await syncWorkspaceOAuthCredentialsForUser({
workspaceId: effectiveWorkspaceId,
userId: requesterUserId,
})
const oauthSelect = {
id: credential.id,
displayName: credential.displayName,
providerId: account.providerId,
scope: account.scope,
updatedAt: account.updatedAt,
}
const credentialsData = await db
.select(oauthSelect)
.from(credential)
.innerJoin(account, eq(credential.accountId, account.id))
.leftJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.where(
and(
eq(credential.workspaceId, effectiveWorkspaceId),
eq(credential.type, 'oauth'),
eq(account.providerId, providerParam),
requesterCanAdmin ? undefined : isNotNull(credentialMember.id)
)
)
const results = credentialsData.map((row) =>
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
)
const saProviderId = getServiceAccountProviderForProviderId(providerParam)
if (saProviderId) {
const saSelect = {
id: credential.id,
displayName: credential.displayName,
providerId: credential.providerId,
updatedAt: credential.updatedAt,
}
const serviceAccountCreds = await db
.select(saSelect)
.from(credential)
.leftJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.where(
and(
eq(credential.workspaceId, effectiveWorkspaceId),
eq(credential.type, 'service_account'),
eq(credential.providerId, saProviderId),
requesterCanAdmin ? undefined : isNotNull(credentialMember.id)
)
)
for (const sa of serviceAccountCreds) {
results.push(
toCredentialResponse(
sa.id,
sa.displayName,
sa.providerId || saProviderId,
sa.updatedAt,
null,
'service_account'
)
)
}
}
return NextResponse.json({ credentials: results }, { status: 200 })
}
return NextResponse.json({ credentials: [] }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching OAuth credentials`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,107 @@
/**
* Tests for OAuth disconnect API route
*
* @vitest-environment node
*/
import {
auditMock,
authMockFns,
createMockRequest,
dbChainMock,
dbChainMockFns,
resetDbChainMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@sim/audit', () => auditMock)
import { POST } from '@/app/api/auth/oauth/disconnect/route'
describe('OAuth Disconnect API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
dbChainMockFns.where.mockResolvedValue([])
})
it('should disconnect provider successfully', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
const req = createMockRequest('POST', {
provider: 'google',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
})
it('should disconnect specific provider ID successfully', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
const req = createMockRequest('POST', {
provider: 'google',
providerId: 'google-email',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
})
it('should handle unauthenticated user', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce(null)
const req = createMockRequest('POST', {
provider: 'google',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('User not authenticated')
})
it('should handle missing provider', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
const req = createMockRequest('POST', {})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Provider is required')
})
it('should handle database error', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error'))
const req = createMockRequest('POST', {
provider: 'google',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Internal server error')
})
})
@@ -0,0 +1,125 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { account, credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, like, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { disconnectOAuthContract } from '@/lib/api/contracts/oauth-connections'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { deleteCredential } from '@/lib/credentials/deletion'
import { captureServerEvent } from '@/lib/posthog/server'
export const dynamic = 'force-dynamic'
const logger = createLogger('OAuthDisconnectAPI')
/**
* Disconnect an OAuth provider for the current user
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated disconnect request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const parsed = await parseRequest(
disconnectOAuthContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid disconnect request`, { errors: error.issues })
return NextResponse.json(
{ error: getValidationErrorMessage(error, 'Validation failed') },
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const { provider, providerId, accountId } = parsed.data.body
logger.info(`[${requestId}] Processing OAuth disconnect request`, {
provider,
hasProviderId: !!providerId,
})
// Delete credentials before their accounts so deleteCredential can clear
// stored references first. Otherwise FK CASCADE would orphan them silently.
const accountFilter = accountId
? and(eq(account.userId, session.user.id), eq(account.id, accountId))
: providerId
? and(eq(account.userId, session.user.id), eq(account.providerId, providerId))
: and(
eq(account.userId, session.user.id),
or(eq(account.providerId, provider), like(account.providerId, `${provider}-%`))
)
const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter)
const targetAccountIds = targetAccounts.map((a) => a.id)
if (targetAccountIds.length > 0) {
const credentialsToDelete = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
providerId: credential.providerId,
})
.from(credential)
.where(inArray(credential.accountId, targetAccountIds))
for (const cred of credentialsToDelete) {
await deleteCredential({
credentialId: cred.id,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
reason: 'oauth_disconnect',
request,
})
captureServerEvent(
session.user.id,
'credential_deleted',
{
credential_type: 'oauth',
provider_id: cred.providerId ?? providerId ?? provider,
workspace_id: cred.workspaceId,
},
{ groups: { workspace: cred.workspaceId } }
)
}
await db.delete(account).where(inArray(account.id, targetAccountIds))
}
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.OAUTH_DISCONNECTED,
resourceType: AuditResourceType.OAUTH,
resourceId: providerId ?? provider,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: provider,
description: `Disconnected OAuth provider: ${provider}`,
metadata: { provider, providerId },
request,
})
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error disconnecting OAuth provider`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,123 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { microsoftFileQuerySchema } from '@/lib/api/contracts/selectors/microsoft'
import { getValidationErrorMessage } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('MicrosoftFileAPI')
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const { searchParams } = new URL(request.url)
const parsedQuery = microsoftFileQuerySchema.safeParse({
credentialId: searchParams.get('credentialId') ?? undefined,
fileId: searchParams.get('fileId') ?? undefined,
workflowId: searchParams.get('workflowId') ?? undefined,
})
if (!parsedQuery.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(parsedQuery.error) },
{ status: 400 }
)
}
const { credentialId, fileId, workflowId } = parsedQuery.data
const fileIdValidation = validateMicrosoftGraphId(fileId, 'fileId')
if (!fileIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid file ID: ${fileIdValidation.error}`)
return NextResponse.json({ error: fileIdValidation.error }, { status: 400 })
}
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
const status = authz.error === 'Credential not found' ? 404 : 403
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
}
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accessToken = await refreshAccessTokenIfNeeded(
resolvedCredentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
const response = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/items/${fileId}?$select=id,name,mimeType,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
logger.error(`[${requestId}] Microsoft Graph API error`, {
status: response.status,
error: errorData.error?.message || 'Failed to fetch file from Microsoft OneDrive',
})
return NextResponse.json(
{
error: errorData.error?.message || 'Failed to fetch file from Microsoft OneDrive',
},
{ status: response.status }
)
}
const file = await response.json()
const transformedFile = {
id: file.id,
name: file.name,
mimeType:
file.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
iconLink: file.thumbnails?.[0]?.small?.url,
webViewLink: file.webUrl,
thumbnailLink: file.thumbnails?.[0]?.medium?.url,
createdTime: file.createdDateTime,
modifiedTime: file.lastModifiedDateTime,
size: file.size?.toString(),
owners: file.createdBy
? [
{
displayName: file.createdBy.user?.displayName || 'Unknown',
emailAddress: file.createdBy.user?.email || '',
},
]
: [],
downloadUrl: `https://graph.microsoft.com/v1.0/me/drive/items/${file.id}/content`,
}
return NextResponse.json({ file: transformedFile }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching file from Microsoft OneDrive`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,219 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { microsoftFilesQuerySchema } from '@/lib/api/contracts/selectors/microsoft'
import { getValidationErrorMessage } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validatePathSegment } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils'
import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('MicrosoftFilesAPI')
/**
* Microsoft Graph paginates `search()` results via the `@odata.nextLink`
* absolute URL in the response body. Request the largest page (`$top` caps at
* 999) and drain following nextLink, bounded by a page cap.
* See https://learn.microsoft.com/en-us/graph/paging
*/
const MICROSOFT_FILES_PAGE_SIZE = 999
const MAX_MICROSOFT_FILES_PAGES = 20
interface MicrosoftGraphFile {
id: string
name?: string
mimeType?: string
webUrl?: string
size?: number
createdDateTime?: string
lastModifiedDateTime?: string
thumbnails?: Array<{ small?: { url?: string }; medium?: { url?: string } }>
createdBy?: { user?: { displayName?: string; email?: string } }
}
/**
* The shared `/api/auth/oauth/microsoft/files` route serves both the
* `microsoft.excel` and `microsoft.word` selectors. The two are distinguished
* by the `fileType` query parameter the selector forwards (defaulting to
* `excel` for backward compatibility), which drives both the search-query
* extension hint and the server-side result filter.
*/
const FILE_TYPE_CONFIG = {
excel: {
extension: '.xlsx',
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
},
word: {
extension: '.docx',
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
},
} as const
type MicrosoftFileType = keyof typeof FILE_TYPE_CONFIG
/**
* Get Excel or Word files from Microsoft OneDrive / SharePoint. The
* `fileType` query parameter selects which Office document type to return
* (defaults to `excel`).
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
// Get the credential ID from the query params
const { searchParams } = new URL(request.url)
const parsedQuery = microsoftFilesQuerySchema.safeParse({
credentialId: searchParams.get('credentialId') ?? undefined,
query: searchParams.get('query') ?? undefined,
driveId: searchParams.get('driveId') ?? undefined,
workflowId: searchParams.get('workflowId') ?? undefined,
fileType: searchParams.get('fileType') ?? undefined,
})
if (!parsedQuery.success) {
logger.warn(`[${requestId}] Invalid query parameters`)
return NextResponse.json(
{ error: getValidationErrorMessage(parsedQuery.error) },
{ status: 400 }
)
}
const { credentialId, driveId, workflowId } = parsedQuery.data
const query = parsedQuery.data.query ?? ''
const fileType: MicrosoftFileType = parsedQuery.data.fileType ?? 'excel'
const { extension, mimeType: targetMimeType } = FILE_TYPE_CONFIG[fileType]
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
const status = authz.error === 'Credential not found' ? 404 : 403
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
}
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(
resolvedCredentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Build search query for the requested Office document type
const searchQuery = query ? `${query} ${extension}` : extension
// Build the query parameters for Microsoft Graph API
const searchParams_new = new URLSearchParams()
searchParams_new.append(
'$select',
'id,name,mimeType,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy'
)
searchParams_new.append('$top', String(MICROSOFT_FILES_PAGE_SIZE))
// When driveId is provided (SharePoint), search within that specific drive.
// Otherwise, search the user's personal OneDrive.
if (driveId) {
const driveIdValidation = validatePathSegment(driveId, {
paramName: 'driveId',
customPattern: GRAPH_ID_PATTERN,
})
if (!driveIdValidation.isValid) {
return NextResponse.json({ error: driveIdValidation.error }, { status: 400 })
}
}
const drivePath = driveId ? `drives/${driveId}` : 'me/drive'
const rawFiles: MicrosoftGraphFile[] = []
let nextUrl: string | undefined =
`https://graph.microsoft.com/v1.0/${drivePath}/root/search(q='${encodeURIComponent(searchQuery)}')?${searchParams_new.toString()}`
for (let page = 0; page < MAX_MICROSOFT_FILES_PAGES && nextUrl; page++) {
const response = await fetch(nextUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ error: { message: 'Unknown error' } }))
logger.error(`[${requestId}] Microsoft Graph API error`, {
status: response.status,
error: errorData.error?.message || 'Failed to fetch files from Microsoft OneDrive',
})
return NextResponse.json(
{
error: errorData.error?.message || 'Failed to fetch files from Microsoft OneDrive',
},
{ status: response.status }
)
}
const data = await response.json()
rawFiles.push(...((data.value as MicrosoftGraphFile[]) || []))
const nextLink = getGraphNextPageUrl(data)
nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined
if (nextUrl && page === MAX_MICROSOFT_FILES_PAGES - 1) {
logger.warn(
`[${requestId}] Microsoft files search hit pagination cap; list may be incomplete`,
{ fileType, pages: MAX_MICROSOFT_FILES_PAGES, collected: rawFiles.length }
)
}
}
// Transform Microsoft Graph response and filter to the requested file type
const files = rawFiles
.filter(
(file: MicrosoftGraphFile) =>
file.name?.toLowerCase().endsWith(extension) || file.mimeType === targetMimeType
)
.map((file: MicrosoftGraphFile) => ({
id: file.id,
name: file.name,
mimeType: file.mimeType || targetMimeType,
iconLink: file.thumbnails?.[0]?.small?.url,
webViewLink: file.webUrl,
thumbnailLink: file.thumbnails?.[0]?.medium?.url,
createdTime: file.createdDateTime,
modifiedTime: file.lastModifiedDateTime,
size: file.size?.toString(),
owners: file.createdBy
? [
{
displayName: file.createdBy.user?.displayName || 'Unknown',
emailAddress: file.createdBy.user?.email || '',
},
]
: [],
}))
return NextResponse.json({ files }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching files from Microsoft OneDrive`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,443 @@
/**
* Tests for OAuth token API routes
*
* @vitest-environment node
*/
import {
authOAuthUtilsMock,
authOAuthUtilsMockFns,
createMockRequest,
hybridAuthMockFns,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockAuthorizeCredentialUse } = vi.hoisted(() => ({
mockAuthorizeCredentialUse: vi.fn(),
}))
vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
vi.mock('@/lib/auth/credential-access', () => ({
authorizeCredentialUse: mockAuthorizeCredentialUse,
}))
import { GET, POST } from '@/app/api/auth/oauth/token/route'
describe('OAuth Token API Routes', () => {
beforeEach(() => {
vi.clearAllMocks()
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValue(null)
})
/**
* POST route tests
*/
describe('POST handler', () => {
it('should return access token successfully', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'google',
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({
accessToken: 'fresh-token',
refreshed: false,
})
const req = createMockRequest('POST', {
credentialId: 'credential-id',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(authOAuthUtilsMockFns.mockGetCredential).toHaveBeenCalled()
expect(authOAuthUtilsMockFns.mockRefreshTokenIfNeeded).toHaveBeenCalled()
})
it('should handle workflowId for server-side authentication', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'internal_jwt',
requesterUserId: 'workflow-owner-id',
credentialOwnerUserId: 'workflow-owner-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'google',
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({
accessToken: 'fresh-token',
refreshed: false,
})
const req = createMockRequest('POST', {
credentialId: 'credential-id',
workflowId: 'workflow-id',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(authOAuthUtilsMockFns.mockGetCredential).toHaveBeenCalled()
})
it('should handle missing credentialId', async () => {
const req = createMockRequest('POST', {})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty(
'error',
'Either credentialId or (credentialAccountUserId + providerId) is required'
)
})
it('should handle authentication failure', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: false,
error: 'Authentication required',
})
const req = createMockRequest('POST', {
credentialId: 'credential-id',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty('error')
})
it('should handle workflow not found', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: false, error: 'Workflow not found' })
const req = createMockRequest('POST', {
credentialId: 'credential-id',
workflowId: 'nonexistent-workflow-id',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(403)
})
it('should handle credential not found', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce(undefined)
const req = createMockRequest('POST', {
credentialId: 'nonexistent-credential-id',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(404)
expect(data).toHaveProperty('error')
})
it('should handle token refresh failure', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), // Expired
providerId: 'google',
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockRejectedValueOnce(
new Error('Refresh failure')
)
const req = createMockRequest('POST', {
credentialId: 'credential-id',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'Failed to refresh access token')
})
describe('credentialAccountUserId + providerId path', () => {
it('should reject unauthenticated requests', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: false,
error: 'Authentication required',
})
const req = createMockRequest('POST', {
credentialAccountUserId: 'target-user-id',
providerId: 'google',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'User not authenticated')
expect(authOAuthUtilsMockFns.mockGetOAuthToken).not.toHaveBeenCalled()
})
it('should reject internal JWT authentication', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
authType: 'internal_jwt',
userId: 'test-user-id',
})
const req = createMockRequest('POST', {
credentialAccountUserId: 'test-user-id',
providerId: 'google',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'User not authenticated')
expect(authOAuthUtilsMockFns.mockGetOAuthToken).not.toHaveBeenCalled()
})
it('should reject requests for other users credentials', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'attacker-user-id',
})
const req = createMockRequest('POST', {
credentialAccountUserId: 'victim-user-id',
providerId: 'google',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty('error', 'Unauthorized')
expect(authOAuthUtilsMockFns.mockGetOAuthToken).not.toHaveBeenCalled()
})
it('should allow session-authenticated users to access their own credentials', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
authOAuthUtilsMockFns.mockGetOAuthToken.mockResolvedValueOnce('valid-access-token')
const req = createMockRequest('POST', {
credentialAccountUserId: 'test-user-id',
providerId: 'google',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'valid-access-token')
expect(authOAuthUtilsMockFns.mockGetOAuthToken).toHaveBeenCalledWith(
'test-user-id',
'google'
)
})
it('should return 404 when credential not found for user', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
authOAuthUtilsMockFns.mockGetOAuthToken.mockResolvedValueOnce(null)
const req = createMockRequest('POST', {
credentialAccountUserId: 'test-user-id',
providerId: 'nonexistent-provider',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toContain('No credential found')
})
})
})
/**
* GET route tests
*/
describe('GET handler', () => {
it('should return access token successfully', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'google',
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({
accessToken: 'fresh-token',
refreshed: false,
})
const req = new NextRequest(
'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id'
)
const response = await GET(req as any)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(authOAuthUtilsMockFns.mockGetCredential).toHaveBeenCalled()
expect(authOAuthUtilsMockFns.mockRefreshTokenIfNeeded).toHaveBeenCalled()
})
it('should handle missing credentialId', async () => {
const req = new NextRequest('http://localhost:3000/api/auth/oauth/token')
const response = await GET(req as any)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty('error', 'Credential ID is required')
})
it('should handle authentication failure', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: false,
error: 'Authentication required',
})
const req = new NextRequest(
'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id'
)
const response = await GET(req as any)
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty('error')
})
it('should handle credential not found', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce(undefined)
const req = new NextRequest(
'http://localhost:3000/api/auth/oauth/token?credentialId=nonexistent-credential-id'
)
const response = await GET(req as any)
const data = await response.json()
expect(response.status).toBe(404)
expect(data).toHaveProperty('error')
})
it('should handle missing access token', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: null,
refreshToken: 'refresh-token',
providerId: 'google',
})
const req = new NextRequest(
'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id'
)
const response = await GET(req as any)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty('error')
})
it('should handle token refresh failure', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), // Expired
providerId: 'google',
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockRejectedValueOnce(
new Error('Refresh failure')
)
const req = new NextRequest(
'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id'
)
const response = await GET(req as any)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error')
})
})
})
+384
View File
@@ -0,0 +1,384 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import {
oauthTokenGetContract,
oauthTokenPostContract,
} from '@/lib/api/contracts/oauth-connections'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
getCredential,
getOAuthToken,
refreshTokenIfNeeded,
resolveOAuthAccountId,
resolveServiceAccountToken,
} from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('OAuthTokenAPI')
const SALESFORCE_INSTANCE_URL_REGEX = /__sf_instance__:([^\s]+)/
/**
* Get an access token for a specific credential
* Supports both session-based authentication (for client-side requests)
* and workflow-based authentication (for server-side requests)
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
logger.info(`[${requestId}] OAuth token API POST request received`)
try {
const parsed = await parseRequest(
oauthTokenPostContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid token request`, { errors: error.issues })
return NextResponse.json(
{ error: getValidationErrorMessage(error, 'Validation failed') },
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const {
credentialId,
credentialAccountUserId,
providerId,
workflowId,
scopes,
impersonateEmail,
} = parsed.data.body
const callerUserId = parsed.data.query.userId
if (credentialAccountUserId && providerId) {
logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, {
credentialAccountUserId,
providerId,
})
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || auth.authType !== AuthType.SESSION || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized request for credentialAccountUserId path`, {
success: auth.success,
authType: auth.authType,
})
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
if (auth.userId !== credentialAccountUserId) {
logger.warn(
`[${requestId}] User ${auth.userId} attempted to access credentials for ${credentialAccountUserId}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
try {
const accessToken = await getOAuthToken(credentialAccountUserId, providerId)
if (!accessToken) {
return NextResponse.json(
{
error: `No credential found for user ${credentialAccountUserId} and provider ${providerId}`,
},
{ status: 404 }
)
}
recordAudit({
actorId: auth.userId,
action: AuditAction.CREDENTIAL_ACCESSED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: providerId,
description: `Accessed OAuth credential for provider ${providerId}`,
metadata: {
provider: providerId,
credentialType: 'oauth',
credentialAccountUserId,
},
request,
})
captureServerEvent(auth.userId, 'credential_used', {
credential_type: 'oauth',
provider_id: providerId,
})
return NextResponse.json({ accessToken }, { status: 200 })
} catch (error) {
const message = getErrorMessage(error, 'Failed to get OAuth token')
logger.warn(`[${requestId}] OAuth token error: ${message}`)
return NextResponse.json({ error: message }, { status: 403 })
}
}
if (!credentialId) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const resolved = await resolveOAuthAccountId(credentialId)
if (resolved?.credentialType === 'service_account' && resolved.credentialId) {
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,
requireWorkflowIdForInternal: false,
callerUserId,
})
if (!authz.ok) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const saActorId = authz.requesterUserId
const saWorkspaceId = resolved.workspaceId ?? authz.workspaceId ?? null
const emitServiceAccountAccess = () => {
if (!saActorId) return
recordAudit({
workspaceId: saWorkspaceId,
actorId: saActorId,
action: AuditAction.CREDENTIAL_ACCESSED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: resolved.credentialId ?? credentialId,
description: `Accessed service account credential for provider ${resolved.providerId ?? 'unknown'}`,
metadata: {
provider: resolved.providerId,
credentialType: 'service_account',
},
request,
})
captureServerEvent(
saActorId,
'credential_used',
{
credential_type: 'service_account',
provider_id: resolved.providerId ?? 'unknown',
...(saWorkspaceId ? { workspace_id: saWorkspaceId } : {}),
},
saWorkspaceId ? { groups: { workspace: saWorkspaceId } } : undefined
)
}
try {
const result = await resolveServiceAccountToken(
resolved.credentialId,
resolved.providerId,
scopes ?? [],
impersonateEmail
)
emitServiceAccountAccess()
return NextResponse.json(
{
accessToken: result.accessToken,
cloudId: result.cloudId,
domain: result.domain,
},
{ status: 200 }
)
} catch (error) {
logger.error(`[${requestId}] Service account token error:`, error)
return NextResponse.json({ error: 'Failed to get service account token' }, { status: 401 })
}
}
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,
requireWorkflowIdForInternal: false,
callerUserId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const oauthActorId = authz.requesterUserId
const oauthWorkspaceId = authz.workspaceId ?? null
try {
const { accessToken } = await refreshTokenIfNeeded(
requestId,
credential,
resolvedCredentialId
)
if (oauthActorId) {
recordAudit({
workspaceId: oauthWorkspaceId,
actorId: oauthActorId,
action: AuditAction.CREDENTIAL_ACCESSED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: resolvedCredentialId,
description: `Accessed OAuth credential for provider ${credential.providerId}`,
metadata: {
provider: credential.providerId,
credentialType: 'oauth',
},
request,
})
captureServerEvent(
oauthActorId,
'credential_used',
{
credential_type: 'oauth',
provider_id: credential.providerId,
...(oauthWorkspaceId ? { workspace_id: oauthWorkspaceId } : {}),
},
oauthWorkspaceId ? { groups: { workspace: oauthWorkspaceId } } : undefined
)
}
let instanceUrl: string | undefined
if (credential.providerId === 'salesforce' && credential.scope) {
const instanceMatch = credential.scope.match(SALESFORCE_INSTANCE_URL_REGEX)
if (instanceMatch) {
instanceUrl = instanceMatch[1]
}
}
return NextResponse.json(
{
accessToken,
idToken: credential.idToken || undefined,
...(instanceUrl && { instanceUrl }),
},
{ status: 200 }
)
} catch (error) {
logger.error(`[${requestId}] Failed to refresh access token:`, error)
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
}
} catch (error) {
logger.error(`[${requestId}] Error getting access token`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
/**
* Get the access token for a specific credential
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const parsed = await parseRequest(
oauthTokenGetContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid query parameters`, { errors: error.issues })
return NextResponse.json(
{ error: getValidationErrorMessage(error, 'Validation failed') },
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const { credentialId } = parsed.data.query
const authz = await authorizeCredentialUse(request, {
credentialId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || authz.authType !== AuthType.SESSION || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!credential.accessToken) {
logger.warn(`[${requestId}] No access token available for credential`)
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
}
const actorId = authz.requesterUserId
const workspaceId = authz.workspaceId ?? null
try {
const { accessToken } = await refreshTokenIfNeeded(
requestId,
credential,
resolvedCredentialId
)
if (actorId) {
recordAudit({
workspaceId,
actorId,
action: AuditAction.CREDENTIAL_ACCESSED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: resolvedCredentialId,
description: `Accessed OAuth credential for provider ${credential.providerId}`,
metadata: {
provider: credential.providerId,
credentialType: 'oauth',
},
request,
})
captureServerEvent(
actorId,
'credential_used',
{
credential_type: 'oauth',
provider_id: credential.providerId,
...(workspaceId ? { workspace_id: workspaceId } : {}),
},
workspaceId ? { groups: { workspace: workspaceId } } : undefined
)
}
// For Salesforce, extract instanceUrl from the scope field
let instanceUrl: string | undefined
if (credential.providerId === 'salesforce' && credential.scope) {
const instanceMatch = credential.scope.match(SALESFORCE_INSTANCE_URL_REGEX)
if (instanceMatch) {
instanceUrl = instanceMatch[1]
}
}
return NextResponse.json(
{
accessToken,
idToken: credential.idToken || undefined,
...(instanceUrl && { instanceUrl }),
},
{ status: 200 }
)
} catch (_error) {
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
}
} catch (error) {
logger.error(`[${requestId}] Error fetching access token`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
+334
View File
@@ -0,0 +1,334 @@
/**
* Tests for OAuth utility functions
*
* @vitest-environment node
*/
import { redisConfigMock, redisConfigMockFns } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/oauth/oauth', () => ({
refreshOAuthToken: vi.fn(),
OAUTH_PROVIDERS: {},
}))
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
const { mockDecryptSecret } = vi.hoisted(() => ({ mockDecryptSecret: vi.fn() }))
vi.mock('@/lib/core/security/encryption', () => ({
decryptSecret: mockDecryptSecret,
encryptSecret: vi.fn(async (value: string) => ({ encrypted: value, iv: 'iv' })),
}))
import { db } from '@sim/db'
import { __resetCoalesceLocallyForTests } from '@/lib/concurrency/singleflight'
import { refreshOAuthToken } from '@/lib/oauth'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
SLACK_CUSTOM_BOT_PROVIDER_ID,
} from '@/lib/oauth/types'
import {
getCredential,
refreshAccessTokenIfNeeded,
refreshTokenIfNeeded,
resolveServiceAccountToken,
} from '@/app/api/auth/oauth/utils'
const mockDb = db as any
const mockRefreshOAuthToken = refreshOAuthToken as any
/**
* Creates a chainable mock for db.select() calls.
* Returns a nested chain: select() -> from() -> where() -> limit() / orderBy()
*/
function mockSelectChain(limitResult: unknown[]) {
const mockLimit = vi.fn().mockReturnValue(limitResult)
const mockOrderBy = vi.fn().mockReturnValue(limitResult)
const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit, orderBy: mockOrderBy })
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere })
mockDb.select.mockReturnValueOnce({ from: mockFrom })
return { mockFrom, mockWhere, mockLimit }
}
/**
* Creates a chainable mock for db.update() calls.
* Returns a nested chain: update() -> set() -> where()
*/
function mockUpdateChain() {
const mockWhere = vi.fn().mockResolvedValue({})
const mockSet = vi.fn().mockReturnValue({ where: mockWhere })
mockDb.update.mockReturnValueOnce({ set: mockSet })
return { mockSet, mockWhere }
}
describe('OAuth Utils', () => {
beforeEach(() => {
vi.clearAllMocks()
__resetCoalesceLocallyForTests()
redisConfigMockFns.mockGetRedisClient.mockReturnValue(null)
redisConfigMockFns.mockAcquireLock.mockResolvedValue(true)
redisConfigMockFns.mockReleaseLock.mockResolvedValue(true)
})
afterEach(() => {
vi.clearAllMocks()
})
describe('getCredential', () => {
it('should return credential when found', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'resolved-account-id' }
const mockAccountRow = { id: 'resolved-account-id', userId: 'test-user-id' }
mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
const credential = await getCredential('request-id', 'credential-id', 'test-user-id')
expect(mockDb.select).toHaveBeenCalledTimes(2)
expect(credential).toMatchObject(mockAccountRow)
expect(credential).toMatchObject({ resolvedCredentialId: 'resolved-account-id' })
})
it('should return undefined when credential is not found', async () => {
mockSelectChain([])
mockSelectChain([])
const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id')
expect(credential).toBeUndefined()
})
})
describe('refreshTokenIfNeeded', () => {
it('should return valid token without refresh if not expired', async () => {
const mockCredential = {
id: 'credential-id',
accessToken: 'valid-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'google',
}
const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
expect(mockRefreshOAuthToken).not.toHaveBeenCalled()
expect(result).toEqual({ accessToken: 'valid-token', refreshed: false })
})
it('should refresh token when expired', async () => {
const mockCredential = {
id: 'credential-id',
accessToken: 'expired-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google',
}
mockRefreshOAuthToken.mockResolvedValueOnce({
ok: true,
accessToken: 'new-token',
expiresIn: 3600,
refreshToken: 'new-refresh-token',
})
mockUpdateChain()
const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
expect(mockDb.update).toHaveBeenCalled()
expect(result).toEqual({ accessToken: 'new-token', refreshed: true })
})
it('should handle refresh token error', async () => {
const mockCredential = {
id: 'credential-id',
accessToken: 'expired-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google',
}
mockRefreshOAuthToken.mockResolvedValueOnce({
ok: false,
errorCode: 'invalid_grant',
message: 'Failed',
})
await expect(
refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
).rejects.toThrow('Failed to refresh token')
})
it('should not attempt refresh if no refresh token', async () => {
const mockCredential = {
id: 'credential-id',
accessToken: 'token',
refreshToken: null,
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google',
}
const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
expect(mockRefreshOAuthToken).not.toHaveBeenCalled()
expect(result).toEqual({ accessToken: 'token', refreshed: false })
})
})
describe('refreshAccessTokenIfNeeded', () => {
it('should return valid access token without refresh if not expired', async () => {
const mockResolvedCredential = {
id: 'credential-id',
type: 'oauth',
accountId: 'account-id',
workspaceId: 'workspace-id',
}
const mockAccountRow = {
id: 'account-id',
accessToken: 'valid-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockResolvedCredential])
mockSelectChain([mockAccountRow])
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
expect(mockRefreshOAuthToken).not.toHaveBeenCalled()
expect(token).toBe('valid-token')
})
it('should refresh token when expired', async () => {
const mockResolvedCredential = {
id: 'credential-id',
type: 'oauth',
accountId: 'account-id',
workspaceId: 'workspace-id',
}
const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockResolvedCredential])
mockSelectChain([mockAccountRow])
mockUpdateChain()
mockRefreshOAuthToken.mockResolvedValueOnce({
ok: true,
accessToken: 'new-token',
expiresIn: 3600,
refreshToken: 'new-refresh-token',
})
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
expect(mockDb.update).toHaveBeenCalled()
expect(token).toBe('new-token')
})
it('should return null if credential not found', async () => {
mockSelectChain([])
mockSelectChain([])
const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id')
expect(token).toBeNull()
})
it('should return null if refresh fails', async () => {
const mockResolvedCredential = {
id: 'credential-id',
type: 'oauth',
accountId: 'account-id',
workspaceId: 'workspace-id',
}
const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockResolvedCredential])
mockSelectChain([mockAccountRow])
mockRefreshOAuthToken.mockResolvedValueOnce({
ok: false,
errorCode: 'invalid_grant',
message: 'Failed',
})
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
expect(token).toBeNull()
})
})
describe('resolveServiceAccountToken', () => {
it('throws loudly for an unknown provider (never silently attempts Google)', async () => {
await expect(resolveServiceAccountToken('cred-1', 'mystery-provider')).rejects.toThrow(
/Unsupported service-account provider/
)
})
it('returns the decrypted bot token for a custom Slack bot', async () => {
mockSelectChain([
{
type: 'service_account',
providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
encryptedServiceAccountKey: 'enc',
},
])
mockDecryptSecret.mockResolvedValueOnce({
decrypted: JSON.stringify({ signingSecret: 's', botToken: 'xoxb-tok', teamId: 'T1' }),
})
const result = await resolveServiceAccountToken('cred-1', SLACK_CUSTOM_BOT_PROVIDER_ID)
expect(result.accessToken).toBe('xoxb-tok')
})
it('throws when the Slack bot credential is missing', async () => {
mockSelectChain([])
await expect(
resolveServiceAccountToken('cred-1', SLACK_CUSTOM_BOT_PROVIDER_ID)
).rejects.toThrow(/Slack bot credential not found/)
})
it('returns apiToken + cloudId + domain for Atlassian', async () => {
mockSelectChain([{ encryptedServiceAccountKey: 'enc' }])
mockDecryptSecret.mockResolvedValueOnce({
decrypted: JSON.stringify({
type: 'atlassian_service_account',
apiToken: 'atk',
domain: 'acme.atlassian.net',
cloudId: 'cloud-1',
}),
})
const result = await resolveServiceAccountToken(
'cred-1',
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
)
expect(result).toMatchObject({
accessToken: 'atk',
cloudId: 'cloud-1',
domain: 'acme.atlassian.net',
})
})
it('requires scopes for a Google service account', async () => {
await expect(
resolveServiceAccountToken('cred-1', GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, [])
).rejects.toThrow(/Scopes are required/)
})
})
})
+736
View File
@@ -0,0 +1,736 @@
import { createSign } from 'crypto'
import { db } from '@sim/db'
import { account, credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresErrorCode, toError } from '@sim/utils/errors'
import { and, desc, eq } from 'drizzle-orm'
import { withLeaderLock } from '@/lib/concurrency/leader-lock'
import { coalesceLocally } from '@/lib/concurrency/singleflight'
import { decryptSecret } from '@/lib/core/security/encryption'
import { refreshOAuthToken } from '@/lib/oauth'
import {
getMicrosoftRefreshTokenExpiry,
isMicrosoftProvider,
PROACTIVE_REFRESH_THRESHOLD_DAYS,
} from '@/lib/oauth/microsoft'
import {
getRecentTerminalError,
isTerminalRefreshError,
markCredentialDead,
} from '@/lib/oauth/terminal-errors'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
SLACK_CUSTOM_BOT_PROVIDER_ID,
} from '@/lib/oauth/types'
const logger = createLogger('OAuthUtilsAPI')
export class ServiceAccountTokenError extends Error {
constructor(
public readonly statusCode: number,
public readonly errorDescription: string
) {
super(errorDescription)
this.name = 'ServiceAccountTokenError'
}
}
interface AccountInsertData {
id: string
userId: string
providerId: string
accountId: string
accessToken: string
scope: string
createdAt: Date
updatedAt: Date
refreshToken?: string
idToken?: string
accessTokenExpiresAt?: Date
}
export interface ResolvedCredential {
accountId: string
workspaceId?: string
usedCredentialTable: boolean
credentialType?: string
credentialId?: string
providerId?: string
}
/**
* Resolves a credential ID to its underlying account ID.
* If `credentialId` matches a `credential` row, returns its `accountId` and `workspaceId`.
* For service_account credentials, returns credentialId and type instead of accountId.
* Otherwise assumes `credentialId` is already a raw `account.id` (legacy).
*/
export async function resolveOAuthAccountId(
credentialId: string
): Promise<ResolvedCredential | null> {
const [credentialRow] = await db
.select({
id: credential.id,
type: credential.type,
accountId: credential.accountId,
workspaceId: credential.workspaceId,
providerId: credential.providerId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (credentialRow) {
if (credentialRow.type === 'service_account') {
return {
accountId: '',
credentialId: credentialRow.id,
credentialType: 'service_account',
workspaceId: credentialRow.workspaceId,
providerId: credentialRow.providerId ?? undefined,
usedCredentialTable: true,
}
}
if (credentialRow.type !== 'oauth' || !credentialRow.accountId) {
return null
}
return {
accountId: credentialRow.accountId,
workspaceId: credentialRow.workspaceId,
usedCredentialTable: true,
}
}
return { accountId: credentialId, usedCredentialTable: false }
}
/**
* Userinfo scopes are excluded because service accounts don't represent a user
* and cannot request user identity information. Google rejects token requests
* that include these scopes for service account credentials.
*/
const SA_EXCLUDED_SCOPES = new Set([
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
])
/**
* Generates a short-lived access token for a Google service account credential
* using the two-legged OAuth JWT flow (RFC 7523).
*
* @param impersonateEmail - Optional. Required for Google Workspace APIs (Gmail, Drive, Calendar, etc.)
* where the service account must impersonate a domain user via domain-wide delegation.
* Not needed for project-scoped APIs like BigQuery or Vertex AI where the service account
* authenticates directly with its own IAM permissions.
*/
export async function getServiceAccountToken(
credentialId: string,
scopes: string[],
impersonateEmail?: string
): Promise<string> {
const [credentialRow] = await db
.select({
encryptedServiceAccountKey: credential.encryptedServiceAccountKey,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!credentialRow?.encryptedServiceAccountKey) {
throw new Error('Service account key not found')
}
const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
const keyData = JSON.parse(decrypted) as {
client_email: string
private_key: string
token_uri?: string
}
const filteredScopes = scopes.filter((s) => !SA_EXCLUDED_SCOPES.has(s))
const now = Math.floor(Date.now() / 1000)
const ALLOWED_TOKEN_URIS = new Set(['https://oauth2.googleapis.com/token'])
const tokenUri =
keyData.token_uri && ALLOWED_TOKEN_URIS.has(keyData.token_uri)
? keyData.token_uri
: 'https://oauth2.googleapis.com/token'
const header = { alg: 'RS256', typ: 'JWT' }
const payload: Record<string, unknown> = {
iss: keyData.client_email,
scope: filteredScopes.join(' '),
aud: tokenUri,
iat: now,
exp: now + 3600,
}
if (impersonateEmail) {
payload.sub = impersonateEmail
}
logger.info('Service account JWT payload', {
iss: keyData.client_email,
sub: impersonateEmail || '(none)',
scopes: filteredScopes.join(' '),
aud: tokenUri,
})
const toBase64Url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url')
const signingInput = `${toBase64Url(header)}.${toBase64Url(payload)}`
const signer = createSign('RSA-SHA256')
signer.update(signingInput)
const signature = signer.sign(keyData.private_key, 'base64url')
const jwt = `${signingInput}.${signature}`
const response = await fetch(tokenUri, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt,
}),
})
if (!response.ok) {
const errorBody = await response.text()
logger.error('Service account token exchange failed', {
status: response.status,
body: errorBody,
})
let description = `Token exchange failed: ${response.status}`
try {
const parsed = JSON.parse(errorBody) as { error_description?: string }
if (parsed.error_description) {
const raw = parsed.error_description
if (raw.includes('SignatureException') || raw.includes('Invalid signature')) {
description = 'Invalid account credentials.'
} else {
description = raw
}
}
} catch {
// use default description
}
throw new ServiceAccountTokenError(response.status, description)
}
const tokenData = (await response.json()) as { access_token: string }
return tokenData.access_token
}
export interface SlackBotCredentialSecrets {
signingSecret: string
botToken: string
teamId: string
botUserId?: string
teamName?: string
/** Owning workspace — callers with a user/workflow context must verify it. */
workspaceId: string | null
}
/**
* Decrypt a reusable custom Slack bot credential — a `service_account` credential
* with `providerId='slack-custom-bot'` whose encrypted blob holds the bring-your-own
* app's signing secret + bot token + derived team_id/bot_user_id. Returns null if
* the id is not such a credential (or its blob is incomplete).
*
* @remarks Server-internal. The native custom ingest route authenticates each
* request via the app's signing secret (not a user session), so this reader does
* no per-user authorization; callers with a user context authorize separately.
*/
export async function getSlackBotCredential(
credentialId: string
): Promise<SlackBotCredentialSecrets | null> {
const [row] = await db
.select({
type: credential.type,
providerId: credential.providerId,
encryptedServiceAccountKey: credential.encryptedServiceAccountKey,
workspaceId: credential.workspaceId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (
!row ||
row.type !== 'service_account' ||
row.providerId !== SLACK_CUSTOM_BOT_PROVIDER_ID ||
!row.encryptedServiceAccountKey
) {
return null
}
const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey)
const blob = JSON.parse(decrypted) as Partial<SlackBotCredentialSecrets>
if (!blob.signingSecret || !blob.botToken || !blob.teamId) {
return null
}
return {
signingSecret: blob.signingSecret,
botToken: blob.botToken,
teamId: blob.teamId,
botUserId: blob.botUserId,
teamName: blob.teamName,
workspaceId: row.workspaceId ?? null,
}
}
interface AtlassianServiceAccountSecret {
type: typeof ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE
apiToken: string
domain: string
cloudId: string
atlassianAccountId?: string
}
/**
* Loads the decrypted Atlassian service account secret blob for a credential.
* Throws if the credential is missing or not an Atlassian service account.
*/
export async function getAtlassianServiceAccountSecret(
credentialId: string
): Promise<AtlassianServiceAccountSecret> {
const [credentialRow] = await db
.select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!credentialRow?.encryptedServiceAccountKey) {
throw new Error('Atlassian service account secret not found')
}
const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
const parsed = JSON.parse(decrypted) as AtlassianServiceAccountSecret
if (
parsed.type !== ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE ||
!parsed.apiToken ||
!parsed.cloudId
) {
throw new Error('Stored Atlassian service account secret is malformed')
}
return parsed
}
/**
* For Atlassian service accounts, the API token IS the access token —
* blocks call api.atlassian.com/ex/jira/{cloudId}/... with `Authorization: Bearer {apiToken}`.
* No exchange or refresh is needed; we just decrypt and return the raw token.
*/
export interface ServiceAccountTokenResult {
accessToken: string
/** Atlassian only — the resolved Jira/Confluence cloud id. */
cloudId?: string
/** Atlassian only — the site domain. */
domain?: string
}
/**
* Single dispatch point for turning a `service_account` credential into an
* access token, keyed on `providerId`. Both `refreshAccessTokenIfNeeded` and the
* `POST /api/auth/oauth/token` route go through here, so a new service-account
* provider is one edit and an unknown provider fails loudly instead of silently
* attempting a Google JWT.
*/
export async function resolveServiceAccountToken(
credentialId: string,
providerId: string | null | undefined,
scopes?: string[],
impersonateEmail?: string
): Promise<ServiceAccountTokenResult> {
if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
const secret = await getAtlassianServiceAccountSecret(credentialId)
return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain }
}
if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
const botCredential = await getSlackBotCredential(credentialId)
if (!botCredential) {
throw new Error('Slack bot credential not found')
}
return { accessToken: botCredential.botToken }
}
if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) {
if (!scopes?.length) {
throw new Error('Scopes are required for service account credentials')
}
return { accessToken: await getServiceAccountToken(credentialId, scopes, impersonateEmail) }
}
throw new Error(`Unsupported service-account provider: ${providerId ?? 'unknown'}`)
}
/**
* Safely inserts an account record, handling duplicate constraint violations gracefully.
* If a duplicate is detected (unique constraint violation), logs a warning and returns success.
*/
export async function safeAccountInsert(
data: AccountInsertData,
context: { provider: string; identifier?: string }
): Promise<void> {
try {
await db.insert(account).values(data)
logger.info(`Created new ${context.provider} account for user`, { userId: data.userId })
} catch (error: any) {
if (getPostgresErrorCode(error) === '23505') {
logger.error(`Duplicate ${context.provider} account detected, credential already exists`, {
userId: data.userId,
identifier: context.identifier,
})
} else {
throw error
}
}
}
/**
* Get a credential by resolved account ID and verify it belongs to the user.
*/
async function getCredentialByAccountId(requestId: string, accountId: string, userId: string) {
const credentials = await db
.select()
.from(account)
.where(and(eq(account.id, accountId), eq(account.userId, userId)))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return undefined
}
return {
...credentials[0],
resolvedCredentialId: accountId,
}
}
/**
* Get a credential by ID and verify it belongs to the user.
*/
export async function getCredential(requestId: string, credentialId: string, userId: string) {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
return undefined
}
return getCredentialByAccountId(requestId, resolved.accountId, userId)
}
interface CoalescedRefreshOptions {
accountId: string
providerId: string
refreshToken: string
requestId?: string
userId?: string
}
async function performCoalescedRefresh({
accountId,
providerId,
refreshToken,
requestId,
userId,
}: CoalescedRefreshOptions): Promise<string | null> {
const logContext = {
...(requestId ? { requestId } : {}),
...(userId ? { userId } : {}),
providerId,
accountId,
}
const deadCode = await getRecentTerminalError(accountId)
if (deadCode) {
logger.warn('Skipping refresh: credential recently failed', {
...logContext,
errorCode: deadCode,
})
return null
}
const lockKey = `oauth:refresh:${accountId}`
const refreshPromise = coalesceLocally(lockKey, () =>
withLeaderLock<string>({
key: lockKey,
onLeader: async () => {
try {
const result = await refreshOAuthToken(providerId, refreshToken)
if (!result.ok) {
logger.error('Failed to refresh token', {
...logContext,
errorCode: result.errorCode,
})
if (result.errorCode && isTerminalRefreshError(result.errorCode)) {
await markCredentialDead(accountId, result.errorCode)
}
return null
}
const updateData: Record<string, unknown> = {
accessToken: result.accessToken,
accessTokenExpiresAt: new Date(Date.now() + result.expiresIn * 1000),
updatedAt: new Date(),
}
if (result.refreshToken && result.refreshToken !== refreshToken) {
updateData.refreshToken = result.refreshToken
}
if (isMicrosoftProvider(providerId)) {
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
}
await db.update(account).set(updateData).where(eq(account.id, accountId))
logger.info('Successfully refreshed access token', logContext)
return result.accessToken
} catch (error) {
logger.error('Refresh failed inside leader path', {
...logContext,
error: toError(error).message,
})
return null
}
},
onFollower: async () => {
try {
const [row] = await db
.select({
accessToken: account.accessToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
})
.from(account)
.where(eq(account.id, accountId))
.limit(1)
if (
row?.accessToken &&
row.accessTokenExpiresAt &&
row.accessTokenExpiresAt > new Date()
) {
logger.info('Got fresh access token from coalesced refresh', logContext)
return row.accessToken
}
return null
} catch (error) {
logger.warn('Follower DB read failed during refresh poll', {
...logContext,
error: toError(error).message,
})
return null
}
},
})
)
try {
return await refreshPromise
} catch (error) {
logger.error('Coalesced refresh did not settle', {
...logContext,
error: toError(error).message,
})
return null
}
}
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
const connections = await db
.select({
id: account.id,
accessToken: account.accessToken,
refreshToken: account.refreshToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
idToken: account.idToken,
scope: account.scope,
})
.from(account)
.where(and(eq(account.userId, userId), eq(account.providerId, providerId)))
.orderBy(desc(account.updatedAt))
.limit(1)
if (connections.length === 0) {
logger.warn(`No OAuth token found for user ${userId}, provider ${providerId}`)
return null
}
const credential = connections[0]
// Determine whether we should refresh: missing token OR expired token
const now = new Date()
const tokenExpiry = credential.accessTokenExpiresAt
const shouldAttemptRefresh =
!!credential.refreshToken && (!credential.accessToken || (tokenExpiry && tokenExpiry < now))
if (shouldAttemptRefresh) {
return performCoalescedRefresh({
accountId: credential.id,
providerId,
refreshToken: credential.refreshToken!,
userId,
})
}
if (!credential.accessToken) {
logger.warn(
`Access token is null and no refresh attempted or available for user ${userId}, provider ${providerId}`
)
return null
}
logger.info(`Found valid OAuth token for user ${userId}, provider ${providerId}`)
return credential.accessToken
}
/**
* Refreshes an OAuth token if needed based on credential information.
* Also handles service account credentials by generating a JWT-based token.
* @param credentialId The ID of the credential to check and potentially refresh
* @param userId The user ID who owns the credential (for security verification)
* @param requestId Request ID for log correlation
* @param scopes Optional scopes for service account token generation
* @returns The valid access token or null if refresh fails
*/
export async function refreshAccessTokenIfNeeded(
credentialId: string,
userId: string,
requestId: string,
scopes?: string[],
impersonateEmail?: string
): Promise<string | null> {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return null
}
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
logger.info(`[${requestId}] Using service account token for credential`)
const { accessToken } = await resolveServiceAccountToken(
resolved.credentialId,
resolved.providerId,
scopes,
impersonateEmail
)
return accessToken
}
// Use the already-resolved account ID to avoid a redundant resolveOAuthAccountId query
const credential = await getCredentialByAccountId(requestId, resolved.accountId, userId)
if (!credential) {
return null
}
// Decide if we should refresh: token missing OR expired
const accessTokenExpiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
const now = new Date()
// Check if access token needs refresh (missing or expired)
const accessTokenNeedsRefresh =
!!credential.refreshToken &&
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
// Check if we should proactively refresh to prevent refresh token expiry
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
const proactiveRefreshThreshold = new Date(
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
)
const refreshTokenNeedsProactiveRefresh =
!!credential.refreshToken &&
isMicrosoftProvider(credential.providerId) &&
refreshTokenExpiresAt &&
refreshTokenExpiresAt <= proactiveRefreshThreshold
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
const accessToken = credential.accessToken
if (shouldRefresh) {
const resolvedCredentialId =
(credential as { resolvedCredentialId?: string }).resolvedCredentialId ?? credentialId
const fresh = await performCoalescedRefresh({
accountId: resolvedCredentialId,
providerId: credential.providerId,
refreshToken: credential.refreshToken!,
requestId,
userId: credential.userId,
})
if (fresh) return fresh
// If refresh was only triggered proactively (Microsoft refresh-token aging),
// the still-valid access token is a fine fallback.
if (!accessTokenNeedsRefresh && accessToken) {
logger.info(`[${requestId}] Refresh unavailable; reusing still-valid access token`)
return accessToken
}
return null
}
if (!accessToken) {
// We have no access token and either no refresh token or not eligible to refresh
logger.error(`[${requestId}] Missing access token for credential`)
return null
}
logger.info(`[${requestId}] Access token is valid for credential`)
return accessToken
}
/**
* Enhanced version that returns additional information about the refresh operation
*/
export async function refreshTokenIfNeeded(
requestId: string,
credential: any,
credentialId: string
): Promise<{ accessToken: string; refreshed: boolean }> {
const resolvedCredentialId = credential.resolvedCredentialId ?? credentialId
// Decide if we should refresh: token missing OR expired
const accessTokenExpiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
const now = new Date()
// Check if access token needs refresh (missing or expired)
const accessTokenNeedsRefresh =
!!credential.refreshToken &&
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
// Check if we should proactively refresh to prevent refresh token expiry
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
const proactiveRefreshThreshold = new Date(
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
)
const refreshTokenNeedsProactiveRefresh =
!!credential.refreshToken &&
isMicrosoftProvider(credential.providerId) &&
refreshTokenExpiresAt &&
refreshTokenExpiresAt <= proactiveRefreshThreshold
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
// If token appears valid and present, return it directly
if (!shouldRefresh) {
logger.info(`[${requestId}] Access token is valid`)
return { accessToken: credential.accessToken, refreshed: false }
}
const fresh = await performCoalescedRefresh({
accountId: resolvedCredentialId,
providerId: credential.providerId,
refreshToken: credential.refreshToken!,
requestId,
userId: credential.userId,
})
if (fresh) return { accessToken: fresh, refreshed: true }
if (!accessTokenNeedsRefresh && credential.accessToken) {
logger.info(`[${requestId}] Refresh unavailable; reusing still-valid access token`)
return { accessToken: credential.accessToken, refreshed: false }
}
throw new Error('Failed to refresh token')
}
@@ -0,0 +1,150 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { wealthboxOAuthItemContract } from '@/lib/api/contracts/selectors/wealthbox'
import { parseRequest } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('WealthboxItemAPI')
interface WealthboxItem {
id: string
name: string
type: string
content: string
createdAt: string
updatedAt: string
}
/**
* Get a single item (note, contact, task) from Wealthbox
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const parsed = await parseRequest(wealthboxOAuthItemContract, request, {})
if (!parsed.success) return parsed.response
const { credentialId, itemId, type } = parsed.data.query
const typeValidation = validateEnum(type, ['contact'] as const, 'type')
if (!typeValidation.isValid) {
logger.warn(`[${requestId}] Invalid item type: ${typeValidation.error}`)
return NextResponse.json({ error: typeValidation.error }, { status: 400 })
}
const itemIdValidation = validatePathSegment(itemId, {
paramName: 'itemId',
maxLength: 100,
allowHyphens: true,
allowUnderscores: true,
allowDots: false,
})
if (!itemIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid item ID: ${itemIdValidation.error}`)
return NextResponse.json({ error: itemIdValidation.error }, { status: 400 })
}
const credAccess = await authorizeCredentialUse(request, {
credentialId,
requireWorkflowIdForInternal: false,
})
if (!credAccess.ok || !credAccess.credentialOwnerUserId) {
logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error })
return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
credAccess.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
const endpoints = {
contact: 'contacts',
}
const endpoint = endpoints[type as keyof typeof endpoints]
logger.info(`[${requestId}] Fetching ${type} ${itemId} from Wealthbox`)
const response = await fetch(`https://api.crmworkspace.com/v1/${endpoint}/${itemId}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error(
`[${requestId}] Wealthbox API error: ${response.status} ${response.statusText}`,
{
error: errorText,
endpoint,
itemId,
}
)
if (response.status === 404) {
return NextResponse.json({ error: 'Item not found' }, { status: 404 })
}
return NextResponse.json(
{ error: `Failed to fetch ${type} from Wealthbox` },
{ status: response.status }
)
}
const data = (await response.json()) as Record<string, unknown>
const meta =
data.meta && typeof data.meta === 'object' ? (data.meta as Record<string, unknown>) : null
const totalCount = meta?.total_count ?? 'unknown'
logger.info(`[${requestId}] Wealthbox API response structure`, {
type,
dataKeys: Object.keys(data || {}),
hasContacts: !!data.contacts,
totalCount,
})
let items: WealthboxItem[] = []
if (type === 'contact') {
if (data?.id) {
const firstName = typeof data.first_name === 'string' ? data.first_name : ''
const lastName = typeof data.last_name === 'string' ? data.last_name : ''
const item = {
id: data.id?.toString() || '',
name: `${firstName} ${lastName}`.trim() || `Contact ${data.id}`,
type: 'contact',
content: typeof data.background_info === 'string' ? data.background_info : '',
createdAt: typeof data.created_at === 'string' ? data.created_at : '',
updatedAt: typeof data.updated_at === 'string' ? data.updated_at : '',
}
items = [item]
} else {
logger.warn(`[${requestId}] Unexpected contact response format`, { data })
items = []
}
}
logger.info(
`[${requestId}] Successfully fetched ${items.length} ${type}s from Wealthbox (total: ${totalCount})`
)
return NextResponse.json({ item: items[0] }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching Wealthbox item`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,162 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { wealthboxOAuthItemsContract } from '@/lib/api/contracts/selectors/wealthbox'
import { parseRequest } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validatePathSegment } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('WealthboxItemsAPI')
interface WealthboxItem {
id: string
name: string
type: string
content: string
createdAt: string
updatedAt: string
}
/**
* Get items (notes, contacts, tasks) from Wealthbox
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const parsed = await parseRequest(wealthboxOAuthItemsContract, request, {})
if (!parsed.success) return parsed.response
const { credentialId, type } = parsed.data.query
const query = parsed.data.query.query ?? ''
const credentialIdValidation = validatePathSegment(credentialId, {
paramName: 'credentialId',
maxLength: 100,
allowHyphens: true,
allowUnderscores: true,
allowDots: false,
})
if (!credentialIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid credentialId format: ${credentialId}`)
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
const authz = await authorizeCredentialUse(request, {
credentialId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
const endpoints = {
contact: 'contacts',
}
const endpoint = endpoints[type as keyof typeof endpoints]
const url = new URL(`https://api.crmworkspace.com/v1/${endpoint}`)
logger.info(`[${requestId}] Fetching ${type}s from Wealthbox`, {
endpoint,
url: url.toString(),
hasQuery: !!query.trim(),
})
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error(
`[${requestId}] Wealthbox API error: ${response.status} ${response.statusText}`,
{
error: errorText,
endpoint,
url: url.toString(),
}
)
return NextResponse.json(
{ error: `Failed to fetch ${type}s from Wealthbox` },
{ status: response.status }
)
}
const data = (await response.json()) as { contacts?: Array<Record<string, unknown>> } & Record<
string,
unknown
>
logger.info(`[${requestId}] Wealthbox API response structure`, {
type,
status: response.status,
dataKeys: Object.keys(data || {}),
hasContacts: !!data.contacts,
dataStructure: typeof data === 'object' ? Object.keys(data) : 'not an object',
})
let items: WealthboxItem[] = []
if (type === 'contact') {
const contacts = data.contacts || []
if (!Array.isArray(contacts)) {
logger.warn(`[${requestId}] Contacts is not an array`, {
contacts,
dataType: typeof contacts,
})
return NextResponse.json({ items: [] }, { status: 200 })
}
items = contacts.map((item) => {
const firstName = typeof item.first_name === 'string' ? item.first_name : ''
const lastName = typeof item.last_name === 'string' ? item.last_name : ''
return {
id: item.id?.toString() || '',
name: `${firstName} ${lastName}`.trim() || `Contact ${item.id ?? ''}`,
type: 'contact',
content:
typeof item.background_information === 'string' ? item.background_information : '',
createdAt: typeof item.created_at === 'string' ? item.created_at : '',
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : '',
}
})
}
if (query.trim()) {
const searchTerm = query.trim().toLowerCase()
items = items.filter(
(item) =>
item.name.toLowerCase().includes(searchTerm) ||
item.content.toLowerCase().includes(searchTerm)
)
}
logger.info(`[${requestId}] Successfully fetched ${items.length} ${type}s from Wealthbox`, {
totalItems: items.length,
hasSearchQuery: !!query.trim(),
})
return NextResponse.json({ items }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching Wealthbox items`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,146 @@
import { db } from '@sim/db'
import { pendingCredentialDraft, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, lt } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections'
import { parseRequest } from '@/lib/api/server'
import { auth, getSession } from '@/lib/auth/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getAllOAuthServices } from '@/lib/oauth/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('OAuth2Authorize')
export const dynamic = 'force-dynamic'
const DRAFT_TTL_MS = 15 * 60 * 1000
/**
* Creates the pending credential draft at click time so its TTL starts when the
* user actually initiates the connect. Better Auth's `account.create.after` hook
* consumes this draft to materialize the real credential after the OAuth
* callback; starting the clock here guarantees the draft outlives the (≤5 min)
* OAuth round-trip rather than expiring mid-flow and silently producing no
* credential.
*/
async function createConnectDraft(params: {
userId: string
workspaceId: string
providerId: string
}): Promise<void> {
const { userId, workspaceId, providerId } = params
const service = getAllOAuthServices().find((s) => s.providerId === providerId)
const serviceName = service?.name ?? providerId
let displayName = serviceName
try {
const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId))
if (row?.name) {
displayName = `${row.name}'s ${serviceName}`
}
} catch {
// Fall back to service name only
}
const now = new Date()
const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS)
await db
.delete(pendingCredentialDraft)
.where(
and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now))
)
await db
.insert(pendingCredentialDraft)
.values({
id: generateId(),
userId,
workspaceId,
providerId,
displayName,
expiresAt,
createdAt: now,
})
.onConflictDoUpdate({
target: [
pendingCredentialDraft.userId,
pendingCredentialDraft.providerId,
pendingCredentialDraft.workspaceId,
],
set: { displayName, expiresAt, createdAt: now },
})
logger.info('Created OAuth connect credential draft', { userId, workspaceId, providerId })
}
/**
* Browser-initiated entrypoint for linking a generic OAuth2 account.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const baseUrl = getBaseUrl()
const session = await getSession()
if (!session?.user?.id) {
const loginUrl = new URL('/login', baseUrl)
loginUrl.searchParams.set('callbackUrl', request.nextUrl.pathname + request.nextUrl.search)
return NextResponse.redirect(loginUrl.toString())
}
const userId = session.user.id
const parsed = await parseRequest(authorizeOAuth2Contract, request, {})
if (!parsed.success) return parsed.response
const { providerId, workspaceId, callbackURL: requestedCallback } = parsed.data.query
const callbackURL = requestedCallback?.startsWith(`${baseUrl}/`)
? requestedCallback
: `${baseUrl}/workspace`
try {
const access = await checkWorkspaceAccess(workspaceId, userId)
if (!access.canWrite) {
logger.warn('Workspace write access denied for OAuth2 authorize', {
userId,
workspaceId,
providerId,
})
return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`)
}
// Create the draft before initiating the link so it is guaranteed to exist
// (and freshly clocked) when the OAuth callback's `account.create.after`
// hook runs. If this throws, we never start the OAuth flow.
await createConnectDraft({ userId, workspaceId, providerId })
const linkResponse = await auth.api.oAuth2LinkAccount({
body: { providerId, callbackURL },
headers: request.headers,
asResponse: true,
})
const payload = (await linkResponse.json().catch(() => null)) as { url?: string } | null
if (!linkResponse.ok || !payload?.url) {
logger.error('oAuth2LinkAccount did not return an authorization URL', {
providerId,
status: linkResponse.status,
})
return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`)
}
const response = NextResponse.redirect(payload.url)
// Forward the signed `state` cookie Better Auth set so it lands in the user's
// browser and is present when the provider redirects back to the callback.
const linkHeaders = linkResponse.headers as Headers & {
getSetCookie?: () => string[]
}
for (const cookie of linkHeaders.getSetCookie?.() ?? []) {
response.headers.append('set-cookie', cookie)
}
return response
} catch (error) {
logger.error('Failed to initiate OAuth2 authorization', { providerId, error })
return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`)
}
})
@@ -0,0 +1,169 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import { type NextRequest, NextResponse } from 'next/server'
import {
shopifyCallbackQuerySchema,
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'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('ShopifyCallback')
export const dynamic = 'force-dynamic'
/**
* Validates the HMAC signature from Shopify to ensure the request is authentic
* @see https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens
*/
function validateHmac(searchParams: URLSearchParams, clientSecret: string): boolean {
const hmac = searchParams.get('hmac')
if (!hmac) {
return false
}
const params: Record<string, string> = {}
searchParams.forEach((value, key) => {
if (key !== 'hmac') {
params[key] = value
}
})
const message = Object.keys(params)
.sort()
.map((key) => `${key}=${params[key]}`)
.join('&')
const generatedHmac = hmacSha256Hex(message, clientSecret)
return safeCompare(hmac, generatedHmac)
}
export const GET = withRouteHandler(async (request: NextRequest) => {
const baseUrl = getBaseUrl()
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
}
const { searchParams } = request.nextUrl
const { code, state, shop } = shopifyCallbackQuerySchema.parse({
code: searchParams.get('code') || undefined,
state: searchParams.get('state') || undefined,
shop: searchParams.get('shop') || undefined,
})
const storedState = request.cookies.get('shopify_oauth_state')?.value
const storedShop = request.cookies.get('shopify_shop_domain')?.value
const clientId = env.SHOPIFY_CLIENT_ID
const clientSecret = env.SHOPIFY_CLIENT_SECRET
if (!clientId || !clientSecret) {
logger.error('Shopify credentials not configured')
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_config_error`)
}
if (!validateHmac(searchParams, clientSecret)) {
logger.error('HMAC validation failed in Shopify OAuth callback')
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_hmac_invalid`)
}
if (!state || state !== storedState) {
logger.error('State mismatch in Shopify OAuth callback')
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_state_mismatch`)
}
if (!code) {
logger.error('No code received from Shopify')
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_code`)
}
const shopDomain = shop || storedShop
if (!shopDomain) {
logger.error('No shop domain available')
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_shop`)
}
if (!shopifyShopDomainSchema.safeParse(shopDomain).success) {
logger.error('Invalid shop domain format:', { shopDomain })
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_shop`)
}
const tokenResponse = await fetch(`https://${shopDomain}/admin/oauth/access_token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
code: code,
}),
})
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text()
logger.error('Failed to exchange code for token:', {
status: tokenResponse.status,
body: errorText,
})
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_token_error`)
}
const tokenData = await tokenResponse.json()
const accessToken = tokenData.access_token
const scope = tokenData.scope
logger.info('Shopify token exchange successful:', {
hasAccessToken: !!accessToken,
scope: scope,
})
if (!accessToken) {
logger.error('No access token in response')
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_token`)
}
const storeUrl = new URL(`${baseUrl}/api/auth/oauth2/shopify/store`)
const response = NextResponse.redirect(storeUrl)
response.cookies.set('shopify_pending_token', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60,
path: '/',
})
response.cookies.set('shopify_pending_shop', shopDomain, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60,
path: '/',
})
response.cookies.set('shopify_pending_scope', scope || '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60,
path: '/',
})
response.cookies.delete('shopify_oauth_state')
response.cookies.delete('shopify_shop_domain')
return response
} catch (error) {
logger.error('Error in Shopify OAuth callback:', error)
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_callback_error`)
}
})
@@ -0,0 +1,144 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
shopifyShopDomainSchema,
shopifyStoreCookieSchema,
} from '@/lib/api/contracts/oauth-connections'
import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
const logger = createLogger('ShopifyStore')
export const dynamic = 'force-dynamic'
export const GET = withRouteHandler(async (request: NextRequest) => {
const baseUrl = getBaseUrl()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn('Unauthorized attempt to store Shopify token')
return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
}
const parsedCookies = shopifyStoreCookieSchema.safeParse({
accessToken: request.cookies.get('shopify_pending_token')?.value,
shopDomain: request.cookies.get('shopify_pending_shop')?.value,
scope: request.cookies.get('shopify_pending_scope')?.value || undefined,
returnUrl: request.cookies.get('shopify_return_url')?.value || undefined,
})
if (!parsedCookies.success) {
logger.error('Missing token or shop domain in cookies')
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_missing_data`)
}
const { accessToken, shopDomain, scope, returnUrl } = parsedCookies.data
if (!shopifyShopDomainSchema.safeParse(shopDomain).success) {
logger.error('Invalid shop domain format in cookie', { shopDomain })
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_domain`)
}
const shopResponse = await fetch(`https://${shopDomain}/admin/api/2024-10/shop.json`, {
headers: {
'X-Shopify-Access-Token': accessToken,
'Content-Type': 'application/json',
},
})
if (!shopResponse.ok) {
const errorText = await shopResponse.text()
logger.error('Invalid Shopify token', {
status: shopResponse.status,
error: errorText,
})
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_token`)
}
const shopData = await shopResponse.json()
const shopInfo = shopData.shop
const stableAccountId = shopInfo.id?.toString() || shopDomain
const existing = await db.query.account.findFirst({
where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'shopify'),
eq(account.accountId, stableAccountId)
),
})
const now = new Date()
const accountData = {
accessToken: accessToken,
accountId: stableAccountId,
scope: scope || '',
updatedAt: now,
idToken: shopDomain,
}
if (existing) {
await db.update(account).set(accountData).where(eq(account.id, existing.id))
logger.info('Updated existing Shopify account', { accountId: existing.id })
} else {
await safeAccountInsert(
{
id: `shopify_${session.user.id}_${Date.now()}`,
userId: session.user.id,
providerId: 'shopify',
accountId: accountData.accountId,
accessToken: accountData.accessToken,
scope: accountData.scope,
idToken: accountData.idToken,
createdAt: now,
updatedAt: now,
},
{ provider: 'Shopify', identifier: shopDomain }
)
}
const persisted =
existing ??
(await db.query.account.findFirst({
where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'shopify'),
eq(account.accountId, stableAccountId)
),
}))
if (persisted) {
try {
await processCredentialDraft({
userId: session.user.id,
providerId: 'shopify',
accountId: persisted.id,
})
} catch (error) {
logger.error('Failed to process credential draft for Shopify', { error })
}
}
const redirectUrl = returnUrl && isSameOrigin(returnUrl) ? returnUrl : `${baseUrl}/workspace`
const finalUrl = new URL(redirectUrl)
finalUrl.searchParams.set('shopify_connected', 'true')
const response = NextResponse.redirect(finalUrl.toString())
response.cookies.delete('shopify_pending_token')
response.cookies.delete('shopify_pending_shop')
response.cookies.delete('shopify_pending_scope')
response.cookies.delete('shopify_return_url')
return response
} catch (error) {
logger.error('Error storing Shopify token:', error)
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_store_error`)
}
})
+22
View File
@@ -0,0 +1,22 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getAuthProvidersContract } from '@/lib/api/contracts/auth'
import { parseRequest } from '@/lib/api/server'
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
export const dynamic = 'force-dynamic'
export const GET = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(getAuthProvidersContract, request, {})
if (!parsed.success) return parsed.response
const { githubAvailable, googleAvailable, microsoftAvailable } = await getOAuthProviderStatus()
return NextResponse.json({
githubAvailable,
googleAvailable,
microsoftAvailable,
registrationDisabled: isRegistrationDisabled,
})
})
@@ -0,0 +1,172 @@
/**
* Tests for reset password API route
*
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockResetPassword, mockLogger } = vi.hoisted(() => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
child: vi.fn(),
}
return {
mockResetPassword: vi.fn(),
mockLogger: logger,
}
})
vi.mock('@/lib/auth', () => ({
auth: {
api: {
resetPassword: mockResetPassword,
},
},
}))
vi.mock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
getRequestContext: () => undefined,
}))
import { POST } from '@/app/api/auth/reset-password/route'
describe('Reset Password API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockResetPassword.mockResolvedValue(undefined)
})
afterEach(() => {
vi.clearAllMocks()
})
it('should reset password successfully', async () => {
const req = createMockRequest('POST', {
token: 'valid-reset-token',
newPassword: 'newSecurePassword123!',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(mockResetPassword).toHaveBeenCalledWith({
body: {
token: 'valid-reset-token',
newPassword: 'newSecurePassword123!',
},
method: 'POST',
})
})
it('should handle missing token', async () => {
const req = createMockRequest('POST', {
newPassword: 'newSecurePassword123!',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Token is required')
expect(mockResetPassword).not.toHaveBeenCalled()
})
it('should handle missing new password', async () => {
const req = createMockRequest('POST', {
token: 'valid-reset-token',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Password is required')
expect(mockResetPassword).not.toHaveBeenCalled()
})
it('should handle empty token', async () => {
const req = createMockRequest('POST', {
token: '',
newPassword: 'newSecurePassword123!',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Token is required')
expect(mockResetPassword).not.toHaveBeenCalled()
})
it('should handle empty new password', async () => {
const req = createMockRequest('POST', {
token: 'valid-reset-token',
newPassword: '',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toContain('Password must be at least 8 characters long')
expect(data.message).toContain('Password must contain at least one uppercase letter')
expect(data.message).toContain('Password must contain at least one lowercase letter')
expect(data.message).toContain('Password must contain at least one number')
expect(data.message).toContain('Password must contain at least one special character')
expect(mockResetPassword).not.toHaveBeenCalled()
})
it('should handle auth service error with message', async () => {
const errorMessage = 'Invalid or expired token'
mockResetPassword.mockRejectedValue(new Error(errorMessage))
const req = createMockRequest('POST', {
token: 'invalid-token',
newPassword: 'newSecurePassword123!',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.message).toBe(errorMessage)
expect(mockLogger.error).toHaveBeenCalledWith('Error during password reset:', {
error: expect.any(Error),
})
})
it('should handle unknown error', async () => {
mockResetPassword.mockRejectedValue('Unknown error')
const req = createMockRequest('POST', {
token: 'valid-reset-token',
newPassword: 'newSecurePassword123!',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.message).toBe(
'Failed to reset password. Please try again or request a new reset link.'
)
expect(mockLogger.error).toHaveBeenCalled()
})
})
@@ -0,0 +1,52 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { resetPasswordContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { auth } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
export const dynamic = 'force-dynamic'
const logger = createLogger('PasswordResetAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const parsed = await parseRequest(
resetPasswordContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn('Invalid password reset request data', { errors: error.issues })
const message = error.issues.map((e) => e.message).join(' ')
return NextResponse.json({ message }, { status: 400 })
},
}
)
if (!parsed.success) return parsed.response
const { token, newPassword } = parsed.data.body
await auth.api.resetPassword({
body: {
newPassword,
token,
},
method: 'POST',
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error during password reset:', { error })
return NextResponse.json(
{
message:
error instanceof Error
? error.message
: 'Failed to reset password. Please try again or request a new reset link.',
},
{ status: 500 }
)
}
})
@@ -0,0 +1,226 @@
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
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'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getScopesForService } from '@/lib/oauth/utils'
const logger = createLogger('ShopifyAuthorize')
export const dynamic = 'force-dynamic'
const SHOPIFY_SCOPES = getScopesForService('shopify').join(',')
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const clientId = env.SHOPIFY_CLIENT_ID
if (!clientId) {
logger.error('SHOPIFY_CLIENT_ID not configured')
return NextResponse.json({ error: 'Shopify client ID not configured' }, { status: 500 })
}
const query = shopifyAuthorizeQuerySchema.parse({
shop: request.nextUrl.searchParams.get('shop') || undefined,
returnUrl: request.nextUrl.searchParams.get('returnUrl') || undefined,
})
const { shop: shopDomain, returnUrl } = query
if (!shopDomain) {
const safeReturnUrl =
returnUrl && isSameOrigin(returnUrl) ? encodeURIComponent(returnUrl) : ''
const returnUrlJsLiteral = JSON.stringify(safeReturnUrl)
return new NextResponse(
`<!DOCTYPE html>
<html>
<head>
<title>Connect Shopify Store</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #96BF48 0%, #5C8A23 100%);
}
.container {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
text-align: center;
max-width: 400px;
width: 90%;
}
h2 {
color: #111827;
margin: 0 0 0.5rem 0;
}
p {
color: #6b7280;
margin: 0 0 1.5rem 0;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
margin-bottom: 1rem;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #96BF48;
box-shadow: 0 0 0 3px rgba(150, 191, 72, 0.2);
}
button {
width: 100%;
padding: 0.75rem;
background: #96BF48;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
}
button:hover {
background: #7FA93D;
}
.help {
font-size: 0.875rem;
color: #9ca3af;
margin-top: 1rem;
}
</style>
</head>
<body>
<div class="container">
<h2>Connect Your Shopify Store</h2>
<p>Enter your Shopify store domain to continue</p>
<form onsubmit="handleSubmit(event)">
<input
type="text"
id="shop"
placeholder="mystore.myshopify.com"
required
pattern="[a-zA-Z0-9-]+\\.myshopify\\.com"
/>
<button type="submit">Connect Store</button>
</form>
<p class="help">Your store domain looks like: yourstore.myshopify.com</p>
</div>
<script>
const returnUrl = ${returnUrlJsLiteral};
function handleSubmit(e) {
e.preventDefault();
let shop = document.getElementById('shop').value.trim().toLowerCase();
// Clean up the shop domain
shop = shop.replace('https://', '').replace('http://', '');
if (!shop.endsWith('.myshopify.com')) {
shop = shop.replace('.myshopify.com', '') + '.myshopify.com';
}
let url = window.location.pathname + '?shop=' + encodeURIComponent(shop);
if (returnUrl) {
url += '&returnUrl=' + returnUrl;
}
window.location.href = url;
}
</script>
</body>
</html>`,
{
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
}
)
}
let cleanShop = shopDomain.toLowerCase().trim()
cleanShop = cleanShop.replace('https://', '').replace('http://', '')
if (!cleanShop.endsWith('.myshopify.com')) {
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`
const state = generateId()
const oauthUrl =
`https://${cleanShop}/admin/oauth/authorize?` +
new URLSearchParams({
client_id: clientId,
scope: SHOPIFY_SCOPES,
redirect_uri: redirectUri,
state: state,
}).toString()
logger.info('Initiating Shopify OAuth:', {
shop: cleanShop,
requestedScopes: SHOPIFY_SCOPES,
redirectUri,
returnUrl: returnUrl || 'not specified',
})
const response = NextResponse.redirect(oauthUrl)
response.cookies.set('shopify_oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 10,
path: '/',
})
response.cookies.set('shopify_shop_domain', cleanShop, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 10,
path: '/',
})
if (returnUrl && isSameOrigin(returnUrl)) {
response.cookies.set('shopify_return_url', returnUrl, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 10,
path: '/',
})
}
return response
} catch (error) {
logger.error('Error initiating Shopify authorization:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,54 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { headers } from 'next/headers'
import { type NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { isAuthDisabled } from '@/lib/core/config/env-flags'
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('SocketTokenAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
if (isAuthDisabled) {
return NextResponse.json({ token: 'anonymous-socket-token' })
}
const rateLimited = await enforceIpRateLimit('socket-token', request, {
maxTokens: 30,
refillRate: 30,
refillIntervalMs: 60_000,
})
if (rateLimited) return rateLimited
try {
const hdrs = await headers()
const response = await auth.api.generateOneTimeToken({
headers: hdrs,
})
if (!response?.token) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
return NextResponse.json({ token: response.token })
} catch (error) {
// better-auth's sessionMiddleware throws APIError("UNAUTHORIZED") with no message
// when the session is missing/expired — surface this as a 401, not a 500.
if (
error instanceof Error &&
('statusCode' in error || 'status' in error) &&
((error as Record<string, unknown>).statusCode === 401 ||
(error as Record<string, unknown>).status === 'UNAUTHORIZED')
) {
logger.warn('Socket token request with invalid/expired session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
logger.error('Failed to generate socket token', {
error: toError(error).message,
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json({ error: 'Failed to generate token' }, { status: 500 })
}
})
@@ -0,0 +1,107 @@
import { db, member, ssoProvider } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { listSsoProvidersContract } from '@/lib/api/contracts/auth'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('SSOProvidersRoute')
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
const rateLimited = await enforceIpRateLimit('sso-providers', request, {
maxTokens: 20,
refillRate: 20,
refillIntervalMs: 60_000,
})
if (rateLimited) return rateLimited
}
const parsed = await parseRequest(listSsoProvidersContract, request, {})
if (!parsed.success) return parsed.response
const { organizationId } = parsed.data.query
let providers
if (session?.user?.id) {
const userId = session.user.id
let verifiedOrganizationId: string | null = null
if (organizationId) {
const [membership] = await db
.select({ organizationId: member.organizationId, role: member.role })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (membership.role !== 'owner' && membership.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
verifiedOrganizationId = membership.organizationId
}
const whereClause = verifiedOrganizationId
? eq(ssoProvider.organizationId, verifiedOrganizationId)
: eq(ssoProvider.userId, userId)
const results = await db
.select({
id: ssoProvider.id,
providerId: ssoProvider.providerId,
domain: ssoProvider.domain,
issuer: ssoProvider.issuer,
oidcConfig: ssoProvider.oidcConfig,
samlConfig: ssoProvider.samlConfig,
userId: ssoProvider.userId,
organizationId: ssoProvider.organizationId,
})
.from(ssoProvider)
.where(whereClause)
providers = results.map((provider) => {
let oidcConfig = provider.oidcConfig
if (oidcConfig) {
try {
const parsed = JSON.parse(oidcConfig)
parsed.clientSecret = REDACTED_MARKER
oidcConfig = JSON.stringify(parsed)
} catch {
oidcConfig = null
}
}
return {
...provider,
oidcConfig,
providerType: (provider.samlConfig ? 'saml' : 'oidc') as 'oidc' | 'saml',
}
})
} else {
const results = await db
.select({
domain: ssoProvider.domain,
})
.from(ssoProvider)
providers = results.map((provider) => ({
domain: provider.domain,
}))
}
logger.info('Fetched SSO providers', {
userId: session?.user?.id,
authenticated: !!session?.user?.id,
providerCount: providers.length,
})
return NextResponse.json({ providers })
} catch (error) {
logger.error('Failed to fetch SSO providers', { error })
return NextResponse.json({ error: 'Failed to fetch SSO providers' }, { status: 500 })
}
})
@@ -0,0 +1,364 @@
/**
* @vitest-environment node
*/
import { createEnvMock, createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetSession,
mockRegisterSSOProvider,
mockHasSSOAccess,
mockValidateUrlWithDNS,
mockSecureFetchWithPinnedIP,
dbState,
memberTable,
ssoProviderTable,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockRegisterSSOProvider: vi.fn(),
mockHasSSOAccess: vi.fn(),
mockValidateUrlWithDNS: vi.fn(),
mockSecureFetchWithPinnedIP: vi.fn(),
dbState: { members: [] as any[], providers: [] as any[] },
memberTable: {
userId: 'member.userId',
organizationId: 'member.organizationId',
role: 'member.role',
},
ssoProviderTable: {
id: 'sso.id',
providerId: 'sso.providerId',
domain: 'sso.domain',
issuer: 'sso.issuer',
userId: 'sso.userId',
organizationId: 'sso.organizationId',
oidcConfig: 'sso.oidcConfig',
samlConfig: 'sso.samlConfig',
},
}))
function makeBuilder(rows: any[]): any {
const thenable: any = Promise.resolve(rows)
thenable.where = (condition: any) => {
const values = condition?.values
if (Array.isArray(values) && values.length > 0) {
const target = String(values[values.length - 1]).toLowerCase()
return makeBuilder(rows.filter((r) => String(r.domain ?? '').toLowerCase() === target))
}
return makeBuilder(rows)
}
thenable.limit = () => Promise.resolve(rows)
thenable.orderBy = () => Promise.resolve(rows)
return thenable
}
vi.mock('@sim/db', () => ({
db: {
select: () => ({
from: (table: unknown) =>
makeBuilder(table === memberTable ? dbState.members : dbState.providers),
}),
},
member: memberTable,
ssoProvider: ssoProviderTable,
}))
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
auth: { api: { registerSSOProvider: mockRegisterSSOProvider } },
}))
vi.mock('@/lib/billing', () => ({
hasSSOAccess: mockHasSSOAccess,
}))
vi.mock('@/lib/auth/sso/domain', () => ({
normalizeSSODomain: (input: unknown): string | null => {
if (typeof input !== 'string') return null
const value = input.trim().toLowerCase()
return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(value) ? value : null
},
}))
vi.mock('@/lib/core/security/input-validation.server', () => ({
validateUrlWithDNS: mockValidateUrlWithDNS,
secureFetchWithPinnedIP: mockSecureFetchWithPinnedIP,
}))
vi.mock('@/lib/core/config/env', () => createEnvMock({ SSO_ENABLED: 'true' }))
import { POST } from '@/app/api/auth/sso/register/route'
const OIDC_BODY = {
providerType: 'oidc' as const,
providerId: 'acme-oidc',
issuer: 'https://idp.acme.com',
domain: 'acme.com',
clientId: 'client-id',
clientSecret: 'client-secret',
authorizationEndpoint: 'https://idp.acme.com/authorize',
tokenEndpoint: 'https://idp.acme.com/token',
userInfoEndpoint: 'https://idp.acme.com/userinfo',
jwksEndpoint: 'https://idp.acme.com/jwks',
}
function request(body: Record<string, unknown>) {
return createMockRequest('POST', body)
}
describe('POST /api/auth/sso/register', () => {
beforeEach(() => {
vi.clearAllMocks()
dbState.members = []
dbState.providers = []
mockGetSession.mockResolvedValue({ user: { id: 'u1' } })
mockHasSSOAccess.mockResolvedValue(true)
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' })
mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('discovery not mocked for this test'))
mockRegisterSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' })
})
it('rejects callers without an Enterprise plan', async () => {
mockHasSSOAccess.mockResolvedValue(false)
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(403)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('rejects callers who are not an admin/owner of the target org', async () => {
dbState.members = [{ organizationId: 'org1', role: 'member' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(403)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('rejects an invalid domain', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
const res = await POST(request({ ...OIDC_BODY, domain: 'not-a-domain', orgId: 'org1' }))
expect(res.status).toBe(400)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('rejects a domain already registered by another organization', async () => {
dbState.members = [{ organizationId: 'org-attacker', role: 'owner' }]
dbState.providers = [{ domain: 'acme.com', userId: 'u-victim', organizationId: 'org-victim' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org-attacker' }))
const json = await res.json()
expect(res.status).toBe(409)
expect(json.code).toBe('SSO_DOMAIN_ALREADY_REGISTERED')
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('matches conflicts across casing variants', async () => {
dbState.members = [{ organizationId: 'org-attacker', role: 'owner' }]
dbState.providers = [{ domain: 'ACME.com', userId: 'u-victim', organizationId: 'org-victim' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org-attacker' }))
expect(res.status).toBe(409)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('registers when the domain is unclaimed', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
})
it('allows the owning tenant to update its own provider for the same domain', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
dbState.providers = [{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
})
it('lets an org admin adopt their own user-scoped provider for the same domain', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
dbState.providers = [{ domain: 'acme.com', userId: 'u1', organizationId: null }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
})
it("still blocks an org admin from claiming another user's user-scoped domain", async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
dbState.providers = [{ domain: 'acme.com', userId: 'someone-else', organizationId: null }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(409)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('normalizes the domain before persisting it', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
const res = await POST(request({ ...OIDC_BODY, domain: 'ACME.com', orgId: 'org1' }))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.domain).toBe('acme.com')
})
it('passes skipDiscovery since Sim already resolved and validated the OIDC endpoints', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.skipDiscovery).toBe(true)
})
it('omits userInfoEndpoint when skipUserInfoEndpoint is requested, forcing ID token claims', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.userInfoEndpoint).toBeUndefined()
})
it('does not SSRF-validate userInfoEndpoint when skipUserInfoEndpoint is requested', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => {
if (label === 'OIDC userInfoEndpoint') {
return { isValid: false, error: 'resolves to a private IP address' }
}
return { isValid: true, resolvedIP: '1.2.3.4' }
})
const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.userInfoEndpoint).toBeUndefined()
})
it('does not SSRF-validate a discovered userinfo_endpoint when skipUserInfoEndpoint is requested', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => {
if (label === 'OIDC userinfo_endpoint') {
return { isValid: false, error: 'resolves to a private IP address' }
}
return { isValid: true, resolvedIP: '1.2.3.4' }
})
mockSecureFetchWithPinnedIP.mockResolvedValue({
ok: true,
json: async () => ({
authorization_endpoint: 'https://idp.acme.com/authorize',
token_endpoint: 'https://idp.acme.com/token',
userinfo_endpoint: 'http://169.254.169.254/userinfo',
jwks_uri: 'https://idp.acme.com/jwks',
}),
})
const discoveredBody = {
...OIDC_BODY,
authorizationEndpoint: undefined,
tokenEndpoint: undefined,
jwksEndpoint: undefined,
skipUserInfoEndpoint: true,
}
const res = await POST(request({ ...discoveredBody, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.userInfoEndpoint).toBeUndefined()
})
it('keeps userInfoEndpoint when skipUserInfoEndpoint is not requested', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.userInfoEndpoint).toBe('https://idp.acme.com/userinfo')
})
it('selects tokenEndpointAuthentication from the discovery document when endpoints are auto-discovered', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
mockSecureFetchWithPinnedIP.mockResolvedValue({
ok: true,
json: async () => ({
authorization_endpoint: 'https://idp.acme.com/authorize',
token_endpoint: 'https://idp.acme.com/token',
userinfo_endpoint: 'https://idp.acme.com/userinfo',
jwks_uri: 'https://idp.acme.com/jwks',
token_endpoint_auth_methods_supported: ['client_secret_post'],
}),
})
const discoveredBody = {
...OIDC_BODY,
authorizationEndpoint: undefined,
tokenEndpoint: undefined,
jwksEndpoint: undefined,
}
const res = await POST(request({ ...discoveredBody, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
})
it('still selects tokenEndpointAuthentication from discovery when all endpoints are explicit', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
mockSecureFetchWithPinnedIP.mockResolvedValue({
ok: true,
json: async () => ({
token_endpoint_auth_methods_supported: ['client_secret_post'],
}),
})
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
expect(config.oidcConfig.authorizationEndpoint).toBe(OIDC_BODY.authorizationEndpoint)
})
it('registers successfully when discovery is unreachable and all endpoints are explicit', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('ECONNREFUSED'))
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.skipDiscovery).toBe(true)
expect(config.oidcConfig.authorizationEndpoint).toBe(OIDC_BODY.authorizationEndpoint)
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
})
it('prefers client_secret_post over client_secret_basic when an IdP supports both', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
mockSecureFetchWithPinnedIP.mockResolvedValue({
ok: true,
json: async () => ({
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
}),
})
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
})
it('defaults to client_secret_post when discovery advertises no auth methods', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
mockSecureFetchWithPinnedIP.mockResolvedValue({
ok: true,
json: async () => ({}),
})
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
})
it('surfaces the specific discovery failure reason when endpoints are missing', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => {
if (label === 'OIDC discovery URL') {
return { isValid: false, error: 'resolves to a private IP address' }
}
return { isValid: true, resolvedIP: '1.2.3.4' }
})
const discoveredBody = {
...OIDC_BODY,
authorizationEndpoint: undefined,
tokenEndpoint: undefined,
jwksEndpoint: undefined,
}
const res = await POST(request({ ...discoveredBody, orgId: 'org1' }))
const json = await res.json()
expect(res.status).toBe(400)
expect(json.error).toContain('resolves to a private IP address')
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
})
+535
View File
@@ -0,0 +1,535 @@
import { db, member, ssoProvider } from '@sim/db'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { ssoRegistrationContract } from '@/lib/api/contracts/auth'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { auth, getSession } from '@/lib/auth'
import { normalizeSSODomain } from '@/lib/auth/sso/domain'
import { hasSSOAccess } from '@/lib/billing'
import { env } from '@/lib/core/config/env'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('SSORegisterRoute')
type TokenEndpointAuthMethod = 'client_secret_basic' | 'client_secret_post'
/**
* Prefers client_secret_post over client_secret_basic when an IdP supports both:
* better-auth sends client_secret_basic credentials without URL-encoding per
* RFC 6749 §2.3.1, so a '+' in the client secret is decoded as a space, causing
* invalid_client errors. Matches the same default in register-sso-provider.ts.
*/
function selectTokenEndpointAuthMethod(
supportedMethods: unknown,
existing?: TokenEndpointAuthMethod
): TokenEndpointAuthMethod {
if (existing) return existing
if (!Array.isArray(supportedMethods) || supportedMethods.length === 0) {
return 'client_secret_post'
}
if (supportedMethods.includes('client_secret_post')) return 'client_secret_post'
if (supportedMethods.includes('client_secret_basic')) return 'client_secret_basic'
return 'client_secret_post'
}
type DiscoveryResult =
| { ok: true; discovery: Record<string, unknown> }
| { ok: false; error: string }
const OIDC_DISCOVERY_TIMEOUT_MS = 10000
async function fetchOIDCDiscoveryDocument(discoveryUrl: string): Promise<DiscoveryResult> {
const urlValidation = await validateUrlWithDNS(discoveryUrl, 'OIDC discovery URL')
if (!urlValidation.isValid || !urlValidation.resolvedIP) {
return { ok: false, error: urlValidation.error ?? 'SSRF validation failed' }
}
try {
const response = await secureFetchWithPinnedIP(discoveryUrl, urlValidation.resolvedIP, {
headers: { Accept: 'application/json' },
timeout: OIDC_DISCOVERY_TIMEOUT_MS,
})
if (!response.ok) {
return { ok: false, error: `Discovery request failed with status ${response.status}` }
}
return { ok: true, discovery: (await response.json()) as Record<string, unknown> }
} catch (error) {
return { ok: false, error: getErrorMessage(error, 'Unknown error') }
}
}
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
if (!env.SSO_ENABLED) {
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
}
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const hasAccess = await hasSSOAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json({ error: 'SSO requires an Enterprise plan' }, { status: 403 })
}
const parsed = await parseRequest(
ssoRegistrationContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn('Invalid SSO registration request', { errors: error.issues })
return NextResponse.json(
{ error: getValidationErrorMessage(error, 'Validation failed') },
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const body = parsed.data.body
const { providerId, issuer, providerType, mapping, orgId } = body
if (orgId) {
const [membership] = await db
.select({ organizationId: member.organizationId, role: member.role })
.from(member)
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, orgId)))
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (membership.role !== 'owner' && membership.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const domain = normalizeSSODomain(body.domain)
if (!domain) {
return NextResponse.json({ error: 'Enter a valid domain like company.com' }, { status: 400 })
}
const isOwnedByCaller = (provider: {
userId: string | null
organizationId: string | null
}): boolean => {
if (provider.userId === session.user.id && !provider.organizationId) return true
return orgId ? provider.organizationId === orgId : false
}
const findDomainConflict = async () =>
(
await db
.select({
userId: ssoProvider.userId,
organizationId: ssoProvider.organizationId,
})
.from(ssoProvider)
.where(sql`lower(${ssoProvider.domain}) = ${domain}`)
).find((provider) => !isOwnedByCaller(provider))
const domainConflictResponse = () =>
NextResponse.json(
{
error: 'This domain is already registered for SSO by another organization.',
code: 'SSO_DOMAIN_ALREADY_REGISTERED',
},
{ status: 409 }
)
if (await findDomainConflict()) {
logger.warn('Rejected SSO registration for domain owned by another tenant', {
domain,
orgId,
userId: session.user.id,
})
return domainConflictResponse()
}
const headers: Record<string, string> = {}
request.headers.forEach((value, key) => {
headers[key] = value
})
const providerConfig: any = {
providerId,
issuer,
domain,
mapping,
...(orgId ? { organizationId: orgId } : {}),
}
if (providerType === 'oidc') {
const {
clientId,
clientSecret: rawClientSecret,
scopes,
pkce,
authorizationEndpoint,
tokenEndpoint,
userInfoEndpoint,
skipUserInfoEndpoint,
jwksEndpoint,
} = body
let clientSecret = rawClientSecret
if (rawClientSecret === REDACTED_MARKER) {
const ownerClause = orgId
? and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.organizationId, orgId))
: and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.userId, session.user.id))
const [existing] = await db
.select({ oidcConfig: ssoProvider.oidcConfig })
.from(ssoProvider)
.where(ownerClause)
.limit(1)
if (!existing?.oidcConfig) {
return NextResponse.json(
{ error: 'Cannot update: existing provider not found. Re-enter your client secret.' },
{ status: 400 }
)
}
try {
clientSecret = JSON.parse(existing.oidcConfig).clientSecret
} catch {
return NextResponse.json(
{
error: 'Cannot update: failed to read existing secret. Re-enter your client secret.',
},
{ status: 400 }
)
}
}
const oidcConfig: any = {
clientId,
clientSecret,
scopes: Array.isArray(scopes)
? scopes.filter((s: string) => s !== 'offline_access')
: ['openid', 'profile', 'email'].filter((s: string) => s !== 'offline_access'),
pkce: pkce ?? true,
}
oidcConfig.authorizationEndpoint = authorizationEndpoint
oidcConfig.tokenEndpoint = tokenEndpoint
oidcConfig.userInfoEndpoint = userInfoEndpoint
oidcConfig.jwksEndpoint = jwksEndpoint
const userProvidedEndpoints: Record<string, string | undefined> = {
authorizationEndpoint,
tokenEndpoint,
jwksEndpoint,
...(skipUserInfoEndpoint ? {} : { userInfoEndpoint }),
}
for (const [name, endpointUrl] of Object.entries(userProvidedEndpoints)) {
if (endpointUrl) {
const endpointValidation = await validateUrlWithDNS(endpointUrl, `OIDC ${name}`)
if (!endpointValidation.isValid) {
logger.warn('Explicitly provided OIDC endpoint failed SSRF validation', {
endpoint: name,
url: endpointUrl,
error: endpointValidation.error,
})
return NextResponse.json(
{
error: `OIDC ${name} failed security validation: ${endpointValidation.error}`,
},
{ status: 400 }
)
}
}
}
const needsDiscovery =
!oidcConfig.authorizationEndpoint || !oidcConfig.tokenEndpoint || !oidcConfig.jwksEndpoint
const discoveryUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`
const discoveryResult = await fetchOIDCDiscoveryDocument(discoveryUrl)
if (needsDiscovery) {
logger.info('Fetching OIDC discovery document for missing endpoints', {
discoveryUrl,
hasAuthEndpoint: !!oidcConfig.authorizationEndpoint,
hasTokenEndpoint: !!oidcConfig.tokenEndpoint,
hasJwksEndpoint: !!oidcConfig.jwksEndpoint,
})
if (!discoveryResult.ok) {
logger.error('Failed to fetch OIDC discovery document', { discoveryResult })
return NextResponse.json(
{
error: `Failed to fetch OIDC discovery document: ${discoveryResult.error}. Provide all endpoints explicitly or verify the issuer URL.`,
},
{ status: 400 }
)
}
const { discovery } = discoveryResult
const discoveredEndpoints: Record<string, unknown> = {
authorization_endpoint: discovery.authorization_endpoint,
token_endpoint: discovery.token_endpoint,
jwks_uri: discovery.jwks_uri,
...(skipUserInfoEndpoint ? {} : { userinfo_endpoint: discovery.userinfo_endpoint }),
}
for (const [key, value] of Object.entries(discoveredEndpoints)) {
if (typeof value === 'string') {
const endpointValidation = await validateUrlWithDNS(value, `OIDC ${key}`)
if (!endpointValidation.isValid) {
logger.warn('OIDC discovered endpoint failed SSRF validation', {
endpoint: key,
url: value,
error: endpointValidation.error,
})
return NextResponse.json(
{
error: `Discovered OIDC ${key} failed security validation: ${endpointValidation.error}`,
},
{ status: 400 }
)
}
}
}
oidcConfig.authorizationEndpoint =
oidcConfig.authorizationEndpoint || discovery.authorization_endpoint
oidcConfig.tokenEndpoint = oidcConfig.tokenEndpoint || discovery.token_endpoint
oidcConfig.userInfoEndpoint = oidcConfig.userInfoEndpoint || discovery.userinfo_endpoint
oidcConfig.jwksEndpoint = oidcConfig.jwksEndpoint || discovery.jwks_uri
oidcConfig.tokenEndpointAuthentication = selectTokenEndpointAuthMethod(
discovery.token_endpoint_auth_methods_supported,
oidcConfig.tokenEndpointAuthentication
)
logger.info('Merged OIDC endpoints (user-provided + discovery)', {
providerId,
issuer,
authorizationEndpoint: oidcConfig.authorizationEndpoint,
tokenEndpoint: oidcConfig.tokenEndpoint,
userInfoEndpoint: oidcConfig.userInfoEndpoint,
jwksEndpoint: oidcConfig.jwksEndpoint,
tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication,
})
} else {
logger.info('Using explicitly provided OIDC endpoints (all present)', {
providerId,
issuer,
authorizationEndpoint: oidcConfig.authorizationEndpoint,
tokenEndpoint: oidcConfig.tokenEndpoint,
userInfoEndpoint: oidcConfig.userInfoEndpoint,
jwksEndpoint: oidcConfig.jwksEndpoint,
})
if (!discoveryResult.ok) {
logger.info('OIDC discovery unavailable; falling back to the default token auth method', {
providerId,
discoveryUrl,
})
}
oidcConfig.tokenEndpointAuthentication = selectTokenEndpointAuthMethod(
discoveryResult.ok
? discoveryResult.discovery.token_endpoint_auth_methods_supported
: undefined,
oidcConfig.tokenEndpointAuthentication
)
}
if (skipUserInfoEndpoint) {
oidcConfig.userInfoEndpoint = undefined
logger.info('Skipping UserInfo endpoint for provider, claims will come from the ID token', {
providerId,
})
}
if (
!oidcConfig.authorizationEndpoint ||
!oidcConfig.tokenEndpoint ||
!oidcConfig.jwksEndpoint
) {
const missing: string[] = []
if (!oidcConfig.authorizationEndpoint) missing.push('authorizationEndpoint')
if (!oidcConfig.tokenEndpoint) missing.push('tokenEndpoint')
if (!oidcConfig.jwksEndpoint) missing.push('jwksEndpoint')
logger.error('Missing required OIDC endpoints after discovery merge', {
missing,
authorizationEndpoint: oidcConfig.authorizationEndpoint,
tokenEndpoint: oidcConfig.tokenEndpoint,
jwksEndpoint: oidcConfig.jwksEndpoint,
})
return NextResponse.json(
{
error: `Missing required OIDC endpoints: ${missing.join(', ')}. Please provide these explicitly or verify the issuer supports OIDC discovery.`,
},
{ status: 400 }
)
}
oidcConfig.skipDiscovery = true
providerConfig.oidcConfig = oidcConfig
} else if (providerType === 'saml') {
const {
entryPoint,
cert,
callbackUrl,
audience,
wantAssertionsSigned,
signatureAlgorithm,
digestAlgorithm,
identifierFormat,
idpMetadata,
} = body
const computedCallbackUrl =
callbackUrl || `${getBaseUrl()}/api/auth/sso/saml2/callback/${providerId}`
const escapeXml = (str: string) =>
str.replace(/[<>&"']/g, (c) => {
switch (c) {
case '<':
return '&lt;'
case '>':
return '&gt;'
case '&':
return '&amp;'
case '"':
return '&quot;'
case "'":
return '&apos;'
default:
return c
}
})
const spMetadataXml = `<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${escapeXml(getBaseUrl())}">
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${escapeXml(computedCallbackUrl)}" index="1"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>`
const certBase64 = cert
.replace(/-----BEGIN CERTIFICATE-----/g, '')
.replace(/-----END CERTIFICATE-----/g, '')
.replace(/\s/g, '')
const computedIdpMetadataXml =
idpMetadata ||
`<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${escapeXml(issuer)}">
<IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>${certBase64}</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</KeyDescriptor>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${escapeXml(entryPoint)}"/>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="${escapeXml(entryPoint)}"/>
</IDPSSODescriptor>
</EntityDescriptor>`
const samlConfig: any = {
entryPoint,
cert,
callbackUrl: computedCallbackUrl,
spMetadata: {
metadata: spMetadataXml,
},
idpMetadata: {
metadata: computedIdpMetadataXml,
},
}
if (audience) samlConfig.audience = audience
if (wantAssertionsSigned !== undefined) samlConfig.wantAssertionsSigned = wantAssertionsSigned
if (signatureAlgorithm) samlConfig.signatureAlgorithm = signatureAlgorithm
if (digestAlgorithm) samlConfig.digestAlgorithm = digestAlgorithm
if (identifierFormat) samlConfig.identifierFormat = identifierFormat
providerConfig.samlConfig = samlConfig
}
logger.info('Calling Better Auth registerSSOProvider with config:', {
providerId: providerConfig.providerId,
domain: providerConfig.domain,
hasOidcConfig: !!providerConfig.oidcConfig,
hasSamlConfig: !!providerConfig.samlConfig,
samlConfigKeys: providerConfig.samlConfig ? Object.keys(providerConfig.samlConfig) : [],
fullConfig: JSON.stringify(
{
...providerConfig,
oidcConfig: providerConfig.oidcConfig
? {
...providerConfig.oidcConfig,
clientSecret: REDACTED_MARKER,
}
: undefined,
samlConfig: providerConfig.samlConfig
? {
...providerConfig.samlConfig,
cert: REDACTED_MARKER,
}
: undefined,
},
null,
2
),
})
if (await findDomainConflict()) {
logger.warn('Rejected SSO registration: domain was claimed during registration', {
domain,
orgId,
userId: session.user.id,
})
return domainConflictResponse()
}
const registration = await auth.api.registerSSOProvider({
body: providerConfig,
headers,
})
logger.info('SSO provider registered successfully', {
providerId,
providerType,
domain,
})
return NextResponse.json({
success: true,
providerId: registration.providerId,
providerType,
message: `${providerType.toUpperCase()} provider registered successfully`,
})
} catch (error) {
logger.error('Failed to register SSO provider', {
error,
errorMessage: getErrorMessage(error, 'Unknown error'),
errorStack: error instanceof Error ? error.stack : undefined,
errorDetails: JSON.stringify(error),
})
return NextResponse.json(
{
error: 'Failed to register SSO provider',
details: getErrorMessage(error, 'Unknown error'),
},
{ status: 500 }
)
}
})
@@ -0,0 +1,65 @@
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeTrelloContract } from '@/lib/api/contracts/oauth-connections'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
const logger = createLogger('TrelloAuthorize')
export const dynamic = 'force-dynamic'
const TRELLO_STATE_COOKIE = 'trello_oauth_state'
const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello'
const TRELLO_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(authorizeTrelloContract, request, {})
if (!parsed.success) return parsed.response
const apiKey = env.TRELLO_API_KEY
if (!apiKey) {
logger.error('TRELLO_API_KEY not configured')
return NextResponse.json({ error: 'Trello API key not configured' }, { status: 500 })
}
const baseUrl = getBaseUrl()
const state = generateShortId(32)
const returnUrl = new URL('/api/auth/trello/callback', baseUrl)
returnUrl.searchParams.set('state', state)
const scope = getCanonicalScopesForProvider('trello').join(',')
const authUrl = new URL('https://trello.com/1/authorize')
authUrl.searchParams.set('key', apiKey)
authUrl.searchParams.set('name', 'Sim Studio')
authUrl.searchParams.set('expiration', 'never')
authUrl.searchParams.set('callback_method', 'fragment')
authUrl.searchParams.set('response_type', 'token')
authUrl.searchParams.set('scope', scope)
authUrl.searchParams.set('return_url', returnUrl.toString())
const response = NextResponse.redirect(authUrl.toString())
response.cookies.set(TRELLO_STATE_COOKIE, state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS,
path: TRELLO_STATE_COOKIE_PATH,
})
return response
} catch (error) {
logger.error('Error initiating Trello authorization:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,179 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { trelloCallbackContract } from '@/lib/api/contracts/oauth-connections'
import { parseRequest } from '@/lib/api/server'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('TrelloCallback')
export const dynamic = 'force-dynamic'
const TRELLO_STATE_COOKIE = 'trello_oauth_state'
function escapeForJsString(value: string): string {
return value.replace(/[\\'"<>&\r\n\u2028\u2029]/g, (ch) => {
return `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`
})
}
function renderErrorPage(baseUrl: string, redirectQuery: string) {
return new NextResponse(
`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Trello connection failed</title></head><body><script>window.location.href=${JSON.stringify(`${baseUrl}/workspace?${redirectQuery}`)};</script><p>Trello connection failed. Redirecting...</p></body></html>`,
{
status: 400,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
}
)
}
export const GET = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(trelloCallbackContract, request, {})
if (!parsed.success) return parsed.response
const baseUrl = getBaseUrl()
const queryState = parsed.data.query.state
const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value
if (!queryState || !cookieState || queryState !== cookieState) {
logger.warn('Trello callback rejected: state mismatch or missing state', {
hasQueryState: Boolean(queryState),
hasCookieState: Boolean(cookieState),
})
const response = renderErrorPage(baseUrl, 'error=trello_state_mismatch')
response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: '/api/auth/trello' })
return response
}
const safeState = escapeForJsString(queryState)
return new NextResponse(
`<!DOCTYPE html>
<html>
<head>
<title>Connecting to Trello...</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #0052CC 0%, #0079BF 100%);
}
.container {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
text-align: center;
max-width: 400px;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #0052CC;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error {
color: #ef4444;
margin-top: 1rem;
}
h2 {
color: #111827;
margin: 0 0 0.5rem 0;
}
p {
color: #6b7280;
margin: 0;
}
</style>
</head>
<body>
<div class="container">
<div class="spinner"></div>
<h2>Connecting to Trello</h2>
<p id="status">Processing authorization...</p>
<p id="error" class="error" style="display:none;"></p>
</div>
<script>
(function() {
const statusEl = document.getElementById('status');
const errorEl = document.getElementById('error');
try {
const fragment = window.location.hash.substring(1);
const params = new URLSearchParams(fragment);
const token = params.get('token');
const authError = params.get('error');
if (authError) {
throw new Error(authError);
}
if (!token) {
throw new Error('No token received from Trello');
}
statusEl.textContent = 'Saving your connection...';
fetch('${baseUrl}/api/auth/trello/store', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ token: token, state: '${safeState}' })
})
.then(response => response.json())
.then(data => {
if (data.success) {
statusEl.textContent = 'Success! Redirecting...';
setTimeout(function() {
window.location.href = '${baseUrl}/workspace?trello_connected=true';
}, 500);
} else {
throw new Error(data.error || 'Failed to save connection');
}
})
.catch(error => {
errorEl.textContent = error.message || 'Failed to save connection';
errorEl.style.display = 'block';
statusEl.textContent = 'Connection failed';
setTimeout(function() {
window.location.href = '${baseUrl}/workspace?error=trello_failed';
}, 3000);
});
} catch (error) {
errorEl.textContent = error.message || 'Authorization failed';
errorEl.style.display = 'block';
statusEl.textContent = 'Connection failed';
setTimeout(function() {
window.location.href = '${baseUrl}/workspace?error=trello_auth_failed';
}, 3000);
}
})();
</script>
</body>
</html>`,
{
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
}
)
})
+153
View File
@@ -0,0 +1,153 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { storeTrelloTokenContract } from '@/lib/api/contracts/oauth-connections'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
const logger = createLogger('TrelloStore')
export const dynamic = 'force-dynamic'
const TRELLO_STATE_COOKIE = 'trello_oauth_state'
const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello'
function clearStateCookie(response: NextResponse) {
response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: TRELLO_STATE_COOKIE_PATH })
return response
}
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn('Unauthorized attempt to store Trello token')
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(storeTrelloTokenContract, request, {})
if (!parsed.success) return parsed.response
const { token, state } = parsed.data.body
const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value
if (!cookieState || cookieState !== state) {
logger.warn('Trello store rejected: state mismatch', {
hasCookieState: Boolean(cookieState),
userId: session.user.id,
})
return clearStateCookie(
NextResponse.json(
{ success: false, error: 'Invalid or expired authorization state' },
{ status: 400 }
)
)
}
const apiKey = env.TRELLO_API_KEY
if (!apiKey) {
logger.error('TRELLO_API_KEY not configured')
return NextResponse.json({ success: false, error: 'Trello not configured' }, { status: 500 })
}
const scope = getCanonicalScopesForProvider('trello').join(',')
const validationUrl = `https://api.trello.com/1/members/me?key=${apiKey}&token=${token}&fields=id,username,fullName`
const userResponse = await fetch(validationUrl, {
headers: { Accept: 'application/json' },
})
if (!userResponse.ok) {
const errorText = await userResponse.text()
logger.error('Invalid Trello token', {
status: userResponse.status,
error: errorText,
})
return NextResponse.json(
{ success: false, error: `Invalid Trello token: ${errorText}` },
{ status: 400 }
)
}
const trelloUser = (await userResponse.json().catch(() => null)) as { id?: string } | null
if (typeof trelloUser?.id !== 'string' || trelloUser.id.trim().length === 0) {
logger.error('Trello validation response did not include a valid member id', {
response: trelloUser,
})
return NextResponse.json(
{ success: false, error: 'Invalid Trello member response' },
{ status: 502 }
)
}
const existing = await db.query.account.findFirst({
where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'trello'),
eq(account.accountId, trelloUser.id)
),
})
const now = new Date()
if (existing) {
await db
.update(account)
.set({
accessToken: token,
accountId: trelloUser.id,
scope,
updatedAt: now,
})
.where(eq(account.id, existing.id))
} else {
await safeAccountInsert(
{
id: `trello_${session.user.id}_${Date.now()}`,
userId: session.user.id,
providerId: 'trello',
accountId: trelloUser.id,
accessToken: token,
scope,
createdAt: now,
updatedAt: now,
},
{ provider: 'Trello', identifier: trelloUser.id }
)
}
const persisted =
existing ??
(await db.query.account.findFirst({
where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'trello'),
eq(account.accountId, trelloUser.id)
),
}))
if (persisted) {
try {
await processCredentialDraft({
userId: session.user.id,
providerId: 'trello',
accountId: persisted.id,
})
} catch (error) {
logger.error('Failed to process credential draft for Trello', { error })
}
}
return clearStateCookie(NextResponse.json({ success: true }))
} catch (error) {
logger.error('Error storing Trello token:', error)
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,7 @@
import { toNextJsHandler } from 'better-auth/next-js'
import { auth } from '@/lib/auth'
export const dynamic = 'force-dynamic'
// Handle Stripe webhooks through better-auth
export const { GET, POST } = toNextJsHandler(auth.handler)