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
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:
@@ -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 } })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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`)
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 '<'
|
||||
case '>':
|
||||
return '>'
|
||||
case '&':
|
||||
return '&'
|
||||
case '"':
|
||||
return '"'
|
||||
case "'":
|
||||
return '''
|
||||
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',
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user