Skip to content

Commit f5b4ab0

Browse files
committed
progress on cred sets
1 parent 852562c commit f5b4ab0

File tree

29 files changed

+2078
-76
lines changed

29 files changed

+2078
-76
lines changed

apps/sim/app/api/auth/oauth/token/route.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,25 @@ import { z } from 'zod'
44
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
55
import { checkHybridAuth } from '@/lib/auth/hybrid'
66
import { generateRequestId } from '@/lib/core/utils/request'
7-
import { getCredential, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
7+
import { getCredential, getOAuthToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
88

99
export const dynamic = 'force-dynamic'
1010

1111
const logger = createLogger('OAuthTokenAPI')
1212

1313
const SALESFORCE_INSTANCE_URL_REGEX = /__sf_instance__:([^\s]+)/
1414

15-
const tokenRequestSchema = z.object({
16-
credentialId: z
17-
.string({ required_error: 'Credential ID is required' })
18-
.min(1, 'Credential ID is required'),
19-
workflowId: z.string().min(1, 'Workflow ID is required').nullish(),
20-
})
15+
const tokenRequestSchema = z
16+
.object({
17+
credentialId: z.string().min(1).optional(),
18+
credentialAccountUserId: z.string().min(1).optional(),
19+
providerId: z.string().min(1).optional(),
20+
workflowId: z.string().min(1).nullish(),
21+
})
22+
.refine(
23+
(data) => data.credentialId || (data.credentialAccountUserId && data.providerId),
24+
'Either credentialId or (credentialAccountUserId + providerId) is required'
25+
)
2126

2227
const tokenQuerySchema = z.object({
2328
credentialId: z
@@ -58,9 +63,31 @@ export async function POST(request: NextRequest) {
5863
)
5964
}
6065

61-
const { credentialId, workflowId } = parseResult.data
66+
const { credentialId, credentialAccountUserId, providerId, workflowId } = parseResult.data
67+
68+
if (credentialAccountUserId && providerId) {
69+
logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, {
70+
credentialAccountUserId,
71+
providerId,
72+
})
73+
74+
const accessToken = await getOAuthToken(credentialAccountUserId, providerId)
75+
if (!accessToken) {
76+
return NextResponse.json(
77+
{
78+
error: `No credential found for user ${credentialAccountUserId} and provider ${providerId}`,
79+
},
80+
{ status: 404 }
81+
)
82+
}
83+
84+
return NextResponse.json({ accessToken }, { status: 200 })
85+
}
86+
87+
if (!credentialId) {
88+
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
89+
}
6290

63-
// We already have workflowId from the parsed body; avoid forcing hybrid auth to re-read it
6491
const authz = await authorizeCredentialUse(request, {
6592
credentialId,
6693
workflowId: workflowId ?? undefined,
@@ -70,15 +97,13 @@ export async function POST(request: NextRequest) {
7097
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
7198
}
7299

73-
// Fetch the credential as the owner to enforce ownership scoping
74100
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
75101

76102
if (!credential) {
77103
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
78104
}
79105

80106
try {
81-
// Refresh the token if needed
82107
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
83108

84109
let instanceUrl: string | undefined

apps/sim/app/api/auth/oauth/utils.ts

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
2-
import { account, workflow } from '@sim/db/schema'
2+
import { account, credentialSetMember, workflow } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, desc, eq } from 'drizzle-orm'
4+
import { and, desc, eq, inArray } from 'drizzle-orm'
55
import { getSession } from '@/lib/auth'
66
import { refreshOAuthToken } from '@/lib/oauth'
77

@@ -335,3 +335,99 @@ export async function refreshTokenIfNeeded(
335335
throw error
336336
}
337337
}
338+
339+
export interface CredentialSetCredential {
340+
userId: string
341+
credentialId: string
342+
accessToken: string
343+
providerId: string
344+
}
345+
346+
export async function getCredentialsForCredentialSet(
347+
credentialSetId: string,
348+
providerId: string
349+
): Promise<CredentialSetCredential[]> {
350+
const members = await db
351+
.select({ userId: credentialSetMember.userId })
352+
.from(credentialSetMember)
353+
.where(
354+
and(
355+
eq(credentialSetMember.credentialSetId, credentialSetId),
356+
eq(credentialSetMember.status, 'active')
357+
)
358+
)
359+
360+
if (members.length === 0) {
361+
logger.warn(`No active members found for credential set ${credentialSetId}`)
362+
return []
363+
}
364+
365+
const userIds = members.map((m) => m.userId)
366+
367+
const credentials = await db
368+
.select({
369+
id: account.id,
370+
userId: account.userId,
371+
providerId: account.providerId,
372+
accessToken: account.accessToken,
373+
refreshToken: account.refreshToken,
374+
accessTokenExpiresAt: account.accessTokenExpiresAt,
375+
})
376+
.from(account)
377+
.where(and(inArray(account.userId, userIds), eq(account.providerId, providerId)))
378+
379+
const results: CredentialSetCredential[] = []
380+
381+
for (const cred of credentials) {
382+
const now = new Date()
383+
const tokenExpiry = cred.accessTokenExpiresAt
384+
const shouldRefresh =
385+
!!cred.refreshToken && (!cred.accessToken || (tokenExpiry && tokenExpiry < now))
386+
387+
let accessToken = cred.accessToken
388+
389+
if (shouldRefresh && cred.refreshToken) {
390+
try {
391+
const refreshResult = await refreshOAuthToken(providerId, cred.refreshToken)
392+
393+
if (refreshResult) {
394+
accessToken = refreshResult.accessToken
395+
396+
const updateData: Record<string, unknown> = {
397+
accessToken: refreshResult.accessToken,
398+
accessTokenExpiresAt: new Date(Date.now() + refreshResult.expiresIn * 1000),
399+
updatedAt: new Date(),
400+
}
401+
402+
if (refreshResult.refreshToken && refreshResult.refreshToken !== cred.refreshToken) {
403+
updateData.refreshToken = refreshResult.refreshToken
404+
}
405+
406+
await db.update(account).set(updateData).where(eq(account.id, cred.id))
407+
408+
logger.info(`Refreshed token for user ${cred.userId}, provider ${providerId}`)
409+
}
410+
} catch (error) {
411+
logger.error(`Failed to refresh token for user ${cred.userId}, provider ${providerId}`, {
412+
error: error instanceof Error ? error.message : String(error),
413+
})
414+
continue
415+
}
416+
}
417+
418+
if (accessToken) {
419+
results.push({
420+
userId: cred.userId,
421+
credentialId: cred.id,
422+
accessToken,
423+
providerId,
424+
})
425+
}
426+
}
427+
428+
logger.info(
429+
`Found ${results.length} valid credentials for credential set ${credentialSetId}, provider ${providerId}`
430+
)
431+
432+
return results
433+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { db } from '@sim/db'
2+
import { credentialSet, credentialSetInvitation, member } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, eq } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { z } from 'zod'
7+
import { getSession } from '@/lib/auth'
8+
9+
const logger = createLogger('CredentialSetInvite')
10+
11+
const createInviteSchema = z.object({
12+
email: z.string().email().optional(),
13+
})
14+
15+
async function getCredentialSetWithAccess(credentialSetId: string, userId: string) {
16+
const [set] = await db
17+
.select({
18+
id: credentialSet.id,
19+
organizationId: credentialSet.organizationId,
20+
name: credentialSet.name,
21+
})
22+
.from(credentialSet)
23+
.where(eq(credentialSet.id, credentialSetId))
24+
.limit(1)
25+
26+
if (!set) return null
27+
28+
const [membership] = await db
29+
.select({ role: member.role })
30+
.from(member)
31+
.where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId)))
32+
.limit(1)
33+
34+
if (!membership) return null
35+
36+
return { set, role: membership.role }
37+
}
38+
39+
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
40+
const session = await getSession()
41+
42+
if (!session?.user?.id) {
43+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
44+
}
45+
46+
const { id } = await params
47+
const result = await getCredentialSetWithAccess(id, session.user.id)
48+
49+
if (!result) {
50+
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
51+
}
52+
53+
const invitations = await db
54+
.select()
55+
.from(credentialSetInvitation)
56+
.where(eq(credentialSetInvitation.credentialSetId, id))
57+
58+
return NextResponse.json({ invitations })
59+
}
60+
61+
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
62+
const session = await getSession()
63+
64+
if (!session?.user?.id) {
65+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
66+
}
67+
68+
const { id } = await params
69+
70+
try {
71+
const result = await getCredentialSetWithAccess(id, session.user.id)
72+
73+
if (!result) {
74+
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
75+
}
76+
77+
if (result.role !== 'admin') {
78+
return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
79+
}
80+
81+
const body = await req.json()
82+
const { email } = createInviteSchema.parse(body)
83+
84+
const token = crypto.randomUUID()
85+
const expiresAt = new Date()
86+
expiresAt.setDate(expiresAt.getDate() + 7)
87+
88+
const invitation = {
89+
id: crypto.randomUUID(),
90+
credentialSetId: id,
91+
email: email || null,
92+
token,
93+
invitedBy: session.user.id,
94+
status: 'pending' as const,
95+
expiresAt,
96+
createdAt: new Date(),
97+
}
98+
99+
await db.insert(credentialSetInvitation).values(invitation)
100+
101+
const inviteUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/credential-account/${token}`
102+
103+
logger.info('Created credential set invitation', {
104+
credentialSetId: id,
105+
invitationId: invitation.id,
106+
userId: session.user.id,
107+
})
108+
109+
return NextResponse.json({
110+
invitation: {
111+
...invitation,
112+
inviteUrl,
113+
},
114+
})
115+
} catch (error) {
116+
if (error instanceof z.ZodError) {
117+
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
118+
}
119+
logger.error('Error creating invitation', error)
120+
return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 })
121+
}
122+
}
123+
124+
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
125+
const session = await getSession()
126+
127+
if (!session?.user?.id) {
128+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
129+
}
130+
131+
const { id } = await params
132+
const { searchParams } = new URL(req.url)
133+
const invitationId = searchParams.get('invitationId')
134+
135+
if (!invitationId) {
136+
return NextResponse.json({ error: 'invitationId is required' }, { status: 400 })
137+
}
138+
139+
try {
140+
const result = await getCredentialSetWithAccess(id, session.user.id)
141+
142+
if (!result) {
143+
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
144+
}
145+
146+
if (result.role !== 'admin') {
147+
return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
148+
}
149+
150+
await db
151+
.update(credentialSetInvitation)
152+
.set({ status: 'cancelled' })
153+
.where(
154+
and(
155+
eq(credentialSetInvitation.id, invitationId),
156+
eq(credentialSetInvitation.credentialSetId, id)
157+
)
158+
)
159+
160+
return NextResponse.json({ success: true })
161+
} catch (error) {
162+
logger.error('Error cancelling invitation', error)
163+
return NextResponse.json({ error: 'Failed to cancel invitation' }, { status: 500 })
164+
}
165+
}

0 commit comments

Comments
 (0)