Skip to content

Commit c644a23

Browse files
committed
fix(selectors): secure OAuth tokens in JSM and Confluence selector routes
Convert JSM selector-servicedesks, selector-requesttypes, and Confluence selector-spaces routes from GET (with access token in URL query params) to POST with authorizeCredentialUse + refreshAccessTokenIfNeeded pattern. Also adds missing ensureCredential guard to microsoft.planner.plans registry entry.
1 parent 665e22c commit c644a23

File tree

4 files changed

+213
-64
lines changed

4 files changed

+213
-64
lines changed
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { createLogger } from '@sim/logger'
2+
import { NextResponse } from 'next/server'
3+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
4+
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
5+
import { generateRequestId } from '@/lib/core/utils/request'
6+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
7+
import { getConfluenceCloudId } from '@/tools/confluence/utils'
8+
9+
const logger = createLogger('ConfluenceSelectorSpacesAPI')
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
export async function POST(request: Request) {
14+
try {
15+
const requestId = generateRequestId()
16+
const body = await request.json()
17+
const { credential, workflowId, domain } = body
18+
19+
if (!credential) {
20+
logger.error('Missing credential in request')
21+
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
22+
}
23+
24+
if (!domain) {
25+
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
26+
}
27+
28+
const authz = await authorizeCredentialUse(request as any, {
29+
credentialId: credential,
30+
workflowId,
31+
})
32+
if (!authz.ok || !authz.credentialOwnerUserId) {
33+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
34+
}
35+
36+
const accessToken = await refreshAccessTokenIfNeeded(
37+
credential,
38+
authz.credentialOwnerUserId,
39+
requestId
40+
)
41+
if (!accessToken) {
42+
logger.error('Failed to get access token', {
43+
credentialId: credential,
44+
userId: authz.credentialOwnerUserId,
45+
})
46+
return NextResponse.json(
47+
{ error: 'Could not retrieve access token', authRequired: true },
48+
{ status: 401 }
49+
)
50+
}
51+
52+
const cloudId = await getConfluenceCloudId(domain, accessToken)
53+
54+
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
55+
if (!cloudIdValidation.isValid) {
56+
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
57+
}
58+
59+
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces?limit=250`
60+
61+
const response = await fetch(url, {
62+
method: 'GET',
63+
headers: {
64+
Accept: 'application/json',
65+
Authorization: `Bearer ${accessToken}`,
66+
},
67+
})
68+
69+
if (!response.ok) {
70+
const errorData = await response.json().catch(() => null)
71+
logger.error('Confluence API error:', {
72+
status: response.status,
73+
statusText: response.statusText,
74+
error: errorData,
75+
})
76+
const errorMessage =
77+
errorData?.message || `Failed to list Confluence spaces (${response.status})`
78+
return NextResponse.json({ error: errorMessage }, { status: response.status })
79+
}
80+
81+
const data = await response.json()
82+
const spaces = (data.results || []).map(
83+
(space: { id: string; name: string; key: string }) => ({
84+
id: space.id,
85+
name: space.name,
86+
key: space.key,
87+
})
88+
)
89+
90+
return NextResponse.json({ spaces })
91+
} catch (error) {
92+
logger.error('Error listing Confluence spaces:', error)
93+
return NextResponse.json(
94+
{ error: (error as Error).message || 'Internal server error' },
95+
{ status: 500 }
96+
)
97+
}
98+
}

apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,30 @@
11
import { createLogger } from '@sim/logger'
2-
import { type NextRequest, NextResponse } from 'next/server'
3-
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
2+
import { NextResponse } from 'next/server'
3+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
44
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
5+
import { generateRequestId } from '@/lib/core/utils/request'
6+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
57
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
68

79
const logger = createLogger('JsmSelectorRequestTypesAPI')
810

911
export const dynamic = 'force-dynamic'
1012

11-
export async function GET(request: NextRequest) {
13+
export async function POST(request: Request) {
1214
try {
13-
const auth = await checkSessionOrInternalAuth(request)
14-
if (!auth.success || !auth.userId) {
15-
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
16-
}
15+
const requestId = generateRequestId()
16+
const body = await request.json()
17+
const { credential, workflowId, domain, serviceDeskId } = body
1718

18-
const { searchParams } = new URL(request.url)
19-
const domain = searchParams.get('domain')
20-
const accessToken = searchParams.get('accessToken')
21-
const serviceDeskId = searchParams.get('serviceDeskId')
19+
if (!credential) {
20+
logger.error('Missing credential in request')
21+
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
22+
}
2223

2324
if (!domain) {
2425
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
2526
}
2627

27-
if (!accessToken) {
28-
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
29-
}
30-
3128
if (!serviceDeskId) {
3229
return NextResponse.json({ error: 'Service Desk ID is required' }, { status: 400 })
3330
}
@@ -37,6 +34,30 @@ export async function GET(request: NextRequest) {
3734
return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 })
3835
}
3936

37+
const authz = await authorizeCredentialUse(request as any, {
38+
credentialId: credential,
39+
workflowId,
40+
})
41+
if (!authz.ok || !authz.credentialOwnerUserId) {
42+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
43+
}
44+
45+
const accessToken = await refreshAccessTokenIfNeeded(
46+
credential,
47+
authz.credentialOwnerUserId,
48+
requestId
49+
)
50+
if (!accessToken) {
51+
logger.error('Failed to get access token', {
52+
credentialId: credential,
53+
userId: authz.credentialOwnerUserId,
54+
})
55+
return NextResponse.json(
56+
{ error: 'Could not retrieve access token', authRequired: true },
57+
{ status: 401 }
58+
)
59+
}
60+
4061
const cloudId = await getJiraCloudId(domain, accessToken)
4162

4263
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')

apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,52 @@
11
import { createLogger } from '@sim/logger'
2-
import { type NextRequest, NextResponse } from 'next/server'
3-
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
2+
import { NextResponse } from 'next/server'
3+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
44
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
5+
import { generateRequestId } from '@/lib/core/utils/request'
6+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
57
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
68

79
const logger = createLogger('JsmSelectorServiceDesksAPI')
810

911
export const dynamic = 'force-dynamic'
1012

11-
export async function GET(request: NextRequest) {
13+
export async function POST(request: Request) {
1214
try {
13-
const auth = await checkSessionOrInternalAuth(request)
14-
if (!auth.success || !auth.userId) {
15-
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
16-
}
15+
const requestId = generateRequestId()
16+
const body = await request.json()
17+
const { credential, workflowId, domain } = body
1718

18-
const { searchParams } = new URL(request.url)
19-
const domain = searchParams.get('domain')
20-
const accessToken = searchParams.get('accessToken')
19+
if (!credential) {
20+
logger.error('Missing credential in request')
21+
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
22+
}
2123

2224
if (!domain) {
2325
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
2426
}
2527

28+
const authz = await authorizeCredentialUse(request as any, {
29+
credentialId: credential,
30+
workflowId,
31+
})
32+
if (!authz.ok || !authz.credentialOwnerUserId) {
33+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
34+
}
35+
36+
const accessToken = await refreshAccessTokenIfNeeded(
37+
credential,
38+
authz.credentialOwnerUserId,
39+
requestId
40+
)
2641
if (!accessToken) {
27-
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
42+
logger.error('Failed to get access token', {
43+
credentialId: credential,
44+
userId: authz.credentialOwnerUserId,
45+
})
46+
return NextResponse.json(
47+
{ error: 'Could not retrieve access token', authRequired: true },
48+
{ status: 401 }
49+
)
2850
}
2951

3052
const cloudId = await getJiraCloudId(domain, accessToken)

apps/sim/hooks/selectors/registry.ts

Lines changed: 46 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -422,11 +422,15 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
422422
fetchList: async ({ context }: SelectorQueryArgs) => {
423423
const credentialId = ensureCredential(context, 'confluence.spaces')
424424
const domain = ensureDomain(context, 'confluence.spaces')
425-
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
426-
if (!accessToken) throw new Error('Missing Confluence access token')
427-
const data = await fetchJson<{ spaces: ConfluenceSpace[] }>('/api/tools/confluence/spaces', {
428-
searchParams: { domain, accessToken, limit: '250' },
425+
const body = JSON.stringify({
426+
credential: credentialId,
427+
workflowId: context.workflowId,
428+
domain,
429429
})
430+
const data = await fetchJson<{ spaces: ConfluenceSpace[] }>(
431+
'/api/tools/confluence/selector-spaces',
432+
{ method: 'POST', body }
433+
)
430434
return (data.spaces || []).map((space) => ({
431435
id: space.id,
432436
label: `${space.name} (${space.key})`,
@@ -436,11 +440,15 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
436440
if (!detailId) return null
437441
const credentialId = ensureCredential(context, 'confluence.spaces')
438442
const domain = ensureDomain(context, 'confluence.spaces')
439-
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
440-
if (!accessToken) throw new Error('Missing Confluence access token')
441-
const data = await fetchJson<{ spaces: ConfluenceSpace[] }>('/api/tools/confluence/spaces', {
442-
searchParams: { domain, accessToken, limit: '250' },
443+
const body = JSON.stringify({
444+
credential: credentialId,
445+
workflowId: context.workflowId,
446+
domain,
443447
})
448+
const data = await fetchJson<{ spaces: ConfluenceSpace[] }>(
449+
'/api/tools/confluence/selector-spaces',
450+
{ method: 'POST', body }
451+
)
444452
const space = (data.spaces || []).find((s) => s.id === detailId) ?? null
445453
if (!space) return null
446454
return { id: space.id, label: `${space.name} (${space.key})` }
@@ -459,13 +467,14 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
459467
fetchList: async ({ context }: SelectorQueryArgs) => {
460468
const credentialId = ensureCredential(context, 'jsm.serviceDesks')
461469
const domain = ensureDomain(context, 'jsm.serviceDesks')
462-
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
463-
if (!accessToken) throw new Error('Missing JSM access token')
470+
const body = JSON.stringify({
471+
credential: credentialId,
472+
workflowId: context.workflowId,
473+
domain,
474+
})
464475
const data = await fetchJson<{ serviceDesks: JsmServiceDesk[] }>(
465476
'/api/tools/jsm/selector-servicedesks',
466-
{
467-
searchParams: { domain, accessToken },
468-
}
477+
{ method: 'POST', body }
469478
)
470479
return (data.serviceDesks || []).map((sd) => ({
471480
id: sd.id,
@@ -476,13 +485,14 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
476485
if (!detailId) return null
477486
const credentialId = ensureCredential(context, 'jsm.serviceDesks')
478487
const domain = ensureDomain(context, 'jsm.serviceDesks')
479-
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
480-
if (!accessToken) throw new Error('Missing JSM access token')
488+
const body = JSON.stringify({
489+
credential: credentialId,
490+
workflowId: context.workflowId,
491+
domain,
492+
})
481493
const data = await fetchJson<{ serviceDesks: JsmServiceDesk[] }>(
482494
'/api/tools/jsm/selector-servicedesks',
483-
{
484-
searchParams: { domain, accessToken },
485-
}
495+
{ method: 'POST', body }
486496
)
487497
const sd = (data.serviceDesks || []).find((s) => s.id === detailId) ?? null
488498
if (!sd) return null
@@ -505,17 +515,15 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
505515
const credentialId = ensureCredential(context, 'jsm.requestTypes')
506516
const domain = ensureDomain(context, 'jsm.requestTypes')
507517
if (!context.serviceDeskId) throw new Error('Missing serviceDeskId for jsm.requestTypes')
508-
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
509-
if (!accessToken) throw new Error('Missing JSM access token')
518+
const body = JSON.stringify({
519+
credential: credentialId,
520+
workflowId: context.workflowId,
521+
domain,
522+
serviceDeskId: context.serviceDeskId,
523+
})
510524
const data = await fetchJson<{ requestTypes: JsmRequestType[] }>(
511525
'/api/tools/jsm/selector-requesttypes',
512-
{
513-
searchParams: {
514-
domain,
515-
accessToken,
516-
serviceDeskId: context.serviceDeskId,
517-
},
518-
}
526+
{ method: 'POST', body }
519527
)
520528
return (data.requestTypes || []).map((rt) => ({
521529
id: rt.id,
@@ -527,17 +535,15 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
527535
const credentialId = ensureCredential(context, 'jsm.requestTypes')
528536
const domain = ensureDomain(context, 'jsm.requestTypes')
529537
if (!context.serviceDeskId) return null
530-
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
531-
if (!accessToken) throw new Error('Missing JSM access token')
538+
const body = JSON.stringify({
539+
credential: credentialId,
540+
workflowId: context.workflowId,
541+
domain,
542+
serviceDeskId: context.serviceDeskId,
543+
})
532544
const data = await fetchJson<{ requestTypes: JsmRequestType[] }>(
533545
'/api/tools/jsm/selector-requesttypes',
534-
{
535-
searchParams: {
536-
domain,
537-
accessToken,
538-
serviceDeskId: context.serviceDeskId,
539-
},
540-
}
546+
{ method: 'POST', body }
541547
)
542548
const rt = (data.requestTypes || []).find((r) => r.id === detailId) ?? null
543549
if (!rt) return null
@@ -585,15 +591,17 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
585591
],
586592
enabled: ({ context }) => Boolean(context.credentialId),
587593
fetchList: async ({ context }: SelectorQueryArgs) => {
594+
const credentialId = ensureCredential(context, 'microsoft.planner.plans')
588595
const data = await fetchJson<{ plans: PlannerPlan[] }>('/api/tools/microsoft_planner/plans', {
589-
searchParams: { credentialId: context.credentialId },
596+
searchParams: { credentialId },
590597
})
591598
return (data.plans || []).map((plan) => ({ id: plan.id, label: plan.title }))
592599
},
593600
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
594601
if (!detailId) return null
602+
const credentialId = ensureCredential(context, 'microsoft.planner.plans')
595603
const data = await fetchJson<{ plans: PlannerPlan[] }>('/api/tools/microsoft_planner/plans', {
596-
searchParams: { credentialId: context.credentialId },
604+
searchParams: { credentialId },
597605
})
598606
const plan = (data.plans || []).find((p) => p.id === detailId) ?? null
599607
if (!plan) return null

0 commit comments

Comments
 (0)