Skip to content

Commit a713042

Browse files
waleedlatif1claude
andauthored
improvement(oauth): centralize scopes and remove dead scope evaluation code (#3449)
* improvement(oauth): centralize scopes and remove dead scope evaluation code Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(oauth): fix stale scope-descriptions.ts references and add test coverage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a4d581c commit a713042

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+841
-1660
lines changed

.claude/commands/add-block.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ When the user asks you to create a block:
2020
import { {ServiceName}Icon } from '@/components/icons'
2121
import type { BlockConfig } from '@/blocks/types'
2222
import { AuthMode } from '@/blocks/types'
23+
import { getScopesForService } from '@/lib/oauth/utils'
2324

2425
export const {ServiceName}Block: BlockConfig = {
2526
type: '{service}', // snake_case identifier
@@ -115,12 +116,17 @@ export const {ServiceName}Block: BlockConfig = {
115116
id: 'credential',
116117
title: 'Account',
117118
type: 'oauth-input',
118-
serviceId: '{service}', // Must match OAuth provider
119+
serviceId: '{service}', // Must match OAuth provider service key
120+
requiredScopes: getScopesForService('{service}'), // Import from @/lib/oauth/utils
119121
placeholder: 'Select account',
120122
required: true,
121123
}
122124
```
123125

126+
**Scopes:** Always use `getScopesForService(serviceId)` from `@/lib/oauth/utils` for `requiredScopes`. Never hardcode scope arrays — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`.
127+
128+
**Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`.
129+
124130
### Selectors (with dynamic options)
125131
```typescript
126132
// Channel selector (Slack, Discord, etc.)
@@ -624,6 +630,7 @@ export const registry: Record<string, BlockConfig> = {
624630
import { ServiceIcon } from '@/components/icons'
625631
import type { BlockConfig } from '@/blocks/types'
626632
import { AuthMode } from '@/blocks/types'
633+
import { getScopesForService } from '@/lib/oauth/utils'
627634

628635
export const ServiceBlock: BlockConfig = {
629636
type: 'service',
@@ -654,6 +661,7 @@ export const ServiceBlock: BlockConfig = {
654661
title: 'Service Account',
655662
type: 'oauth-input',
656663
serviceId: 'service',
664+
requiredScopes: getScopesForService('service'),
657665
placeholder: 'Select account',
658666
required: true,
659667
},
@@ -792,7 +800,8 @@ All tool IDs referenced in `tools.access` and returned by `tools.config.tool` MU
792800
- [ ] Conditions use correct syntax (field, value, not, and)
793801
- [ ] DependsOn set for fields that need other values
794802
- [ ] Required fields marked correctly (boolean or condition)
795-
- [ ] OAuth inputs have correct `serviceId`
803+
- [ ] OAuth inputs have correct `serviceId` and `requiredScopes: getScopesForService(serviceId)`
804+
- [ ] Scope descriptions added to `SCOPE_DESCRIPTIONS` in `lib/oauth/utils.ts` for any new scopes
796805
- [ ] Tools.access lists all tool IDs (snake_case)
797806
- [ ] Tools.config.tool returns correct tool ID (snake_case)
798807
- [ ] Outputs match tool outputs

.claude/commands/add-integration.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export const {service}{Action}Tool: ToolConfig<Params, Response> = {
114114
import { {Service}Icon } from '@/components/icons'
115115
import type { BlockConfig } from '@/blocks/types'
116116
import { AuthMode } from '@/blocks/types'
117+
import { getScopesForService } from '@/lib/oauth/utils'
117118

118119
export const {Service}Block: BlockConfig = {
119120
type: '{service}',
@@ -144,6 +145,7 @@ export const {Service}Block: BlockConfig = {
144145
title: '{Service} Account',
145146
type: 'oauth-input',
146147
serviceId: '{service}',
148+
requiredScopes: getScopesForService('{service}'),
147149
required: true,
148150
},
149151
// Conditional fields per operation
@@ -409,7 +411,7 @@ If creating V2 versions (API-aligned outputs):
409411
### Block
410412
- [ ] Created `blocks/blocks/{service}.ts`
411413
- [ ] Defined operation dropdown with all operations
412-
- [ ] Added credential field (oauth-input or short-input)
414+
- [ ] Added credential field with `requiredScopes: getScopesForService('{service}')`
413415
- [ ] Added conditional fields per operation
414416
- [ ] Set up dependsOn for cascading selectors
415417
- [ ] Configured tools.access with all tool IDs
@@ -419,6 +421,12 @@ If creating V2 versions (API-aligned outputs):
419421
- [ ] If triggers: set `triggers.enabled` and `triggers.available`
420422
- [ ] If triggers: spread trigger subBlocks with `getTrigger()`
421423

424+
### OAuth Scopes (if OAuth service)
425+
- [ ] Defined scopes in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS`
426+
- [ ] Added scope descriptions in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
427+
- [ ] Used `getCanonicalScopesForProvider()` in `auth.ts` (never hardcode)
428+
- [ ] Used `getScopesForService()` in block `requiredScopes` (never hardcode)
429+
422430
### Icon
423431
- [ ] Asked user to provide SVG
424432
- [ ] Added icon to `components/icons.tsx`
@@ -717,6 +725,25 @@ Use `wandConfig` for fields that are hard to fill out manually:
717725
}
718726
```
719727

728+
### OAuth Scopes (Centralized System)
729+
730+
Scopes are maintained in a single source of truth and reused everywhere:
731+
732+
1. **Define scopes** in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS[provider].services[service].scopes`
733+
2. **Add descriptions** in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for the OAuth modal UI
734+
3. **Reference in auth.ts** using `getCanonicalScopesForProvider(providerId)` from `@/lib/oauth/utils`
735+
4. **Reference in blocks** using `getScopesForService(serviceId)` from `@/lib/oauth/utils`
736+
737+
**Never hardcode scope arrays** in `auth.ts` or block `requiredScopes`. Always import from the centralized source.
738+
739+
```typescript
740+
// In auth.ts (Better Auth config)
741+
scopes: getCanonicalScopesForProvider('{service}'),
742+
743+
// In block credential sub-block
744+
requiredScopes: getScopesForService('{service}'),
745+
```
746+
720747
### Common Gotchas
721748

722749
1. **OAuth serviceId must match** - The `serviceId` in oauth-input must match the OAuth provider configuration
@@ -729,3 +756,5 @@ Use `wandConfig` for fields that are hard to fill out manually:
729756
8. **Always handle legacy file params** - Keep hidden `fileContent` params for backwards compatibility
730757
9. **Optional fields use advanced mode** - Set `mode: 'advanced'` on rarely-used optional fields
731758
10. **Complex inputs need wandConfig** - Timestamps, JSON arrays, and other hard-to-type values should have `wandConfig` enabled
759+
11. **Never hardcode scopes** - Use `getScopesForService()` in blocks and `getCanonicalScopesForProvider()` in auth.ts
760+
12. **Always add scope descriptions** - New scopes must have entries in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`

.claude/commands/validate-integration.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ apps/sim/blocks/blocks/{service}.ts # Block definition
2626
apps/sim/tools/registry.ts # Tool registry entries for this service
2727
apps/sim/blocks/registry.ts # Block registry entry for this service
2828
apps/sim/components/icons.tsx # Icon definition
29-
apps/sim/lib/auth/auth.ts # OAuth scopes (if OAuth service)
30-
apps/sim/lib/oauth/oauth.ts # OAuth provider config (if OAuth service)
29+
apps/sim/lib/auth/auth.ts # OAuth config — should use getCanonicalScopesForProvider()
30+
apps/sim/lib/oauth/oauth.ts # OAuth provider config — single source of truth for scopes
31+
apps/sim/lib/oauth/utils.ts # Scope utilities, SCOPE_DESCRIPTIONS for modal UI
3132
```
3233

3334
## Step 2: Pull API Documentation
@@ -199,11 +200,14 @@ For **each tool** in `tools.access`:
199200

200201
## Step 5: Validate OAuth Scopes (if OAuth service)
201202

202-
- [ ] `auth.ts` scopes include ALL scopes needed by ALL tools in the integration
203-
- [ ] `oauth.ts` provider config scopes match `auth.ts` scopes
204-
- [ ] Block `requiredScopes` (if defined) matches `auth.ts` scopes
203+
Scopes are centralized — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`.
204+
205+
- [ ] Scopes defined in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS[provider].services[service].scopes`
206+
- [ ] `auth.ts` uses `getCanonicalScopesForProvider(providerId)` — NOT a hardcoded array
207+
- [ ] Block `requiredScopes` uses `getScopesForService(serviceId)` — NOT a hardcoded array
208+
- [ ] No hardcoded scope arrays in `auth.ts` or block files (should all use utility functions)
209+
- [ ] Each scope has a human-readable description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
205210
- [ ] No excess scopes that aren't needed by any tool
206-
- [ ] Each scope has a human-readable description in `oauth-required-modal.tsx`'s `SCOPE_DESCRIPTIONS`
207211

208212
## Step 6: Validate Pagination Consistency
209213

@@ -244,7 +248,8 @@ Group findings by severity:
244248
- Missing `.trim()` on ID fields in request URLs
245249
- Missing `?? null` on nullable response fields
246250
- Block condition array missing an operation that uses that field
247-
- Missing scope description in `oauth-required-modal.tsx`
251+
- Hardcoded scope arrays instead of using `getScopesForService()` / `getCanonicalScopesForProvider()`
252+
- Missing scope description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
248253

249254
**Suggestion** (minor improvements):
250255
- Better description text
@@ -273,7 +278,8 @@ After fixing, confirm:
273278
- [ ] Validated wandConfig on timestamps and complex inputs
274279
- [ ] Validated tools.config mapping, tool selector, and type coercions
275280
- [ ] Validated block outputs match what tools return, with typed JSON where possible
276-
- [ ] Validated OAuth scopes alignment across auth.ts, oauth.ts, block, and modal (if OAuth)
281+
- [ ] Validated OAuth scopes use centralized utilities (getScopesForService, getCanonicalScopesForProvider) — no hardcoded arrays
282+
- [ ] Validated scope descriptions exist in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for all scopes
277283
- [ ] Validated pagination consistency across tools and block
278284
- [ ] Validated error handling (error checks, meaningful messages)
279285
- [ ] Validated registry entries (tools and block, alphabetical, correct imports)

apps/sim/app/api/auth/oauth/connections/route.test.ts

Lines changed: 26 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,40 +6,33 @@
66
import { createMockRequest } from '@sim/testing'
77
import { beforeEach, describe, expect, it, vi } from 'vitest'
88

9-
const {
10-
mockGetSession,
11-
mockDb,
12-
mockLogger,
13-
mockParseProvider,
14-
mockEvaluateScopeCoverage,
15-
mockJwtDecode,
16-
mockEq,
17-
} = vi.hoisted(() => {
18-
const db = {
19-
select: vi.fn().mockReturnThis(),
20-
from: vi.fn().mockReturnThis(),
21-
where: vi.fn().mockReturnThis(),
22-
limit: vi.fn(),
9+
const { mockGetSession, mockDb, mockLogger, mockParseProvider, mockJwtDecode, mockEq } = vi.hoisted(
10+
() => {
11+
const db = {
12+
select: vi.fn().mockReturnThis(),
13+
from: vi.fn().mockReturnThis(),
14+
where: vi.fn().mockReturnThis(),
15+
limit: vi.fn(),
16+
}
17+
const logger = {
18+
info: vi.fn(),
19+
warn: vi.fn(),
20+
error: vi.fn(),
21+
debug: vi.fn(),
22+
trace: vi.fn(),
23+
fatal: vi.fn(),
24+
child: vi.fn(),
25+
}
26+
return {
27+
mockGetSession: vi.fn(),
28+
mockDb: db,
29+
mockLogger: logger,
30+
mockParseProvider: vi.fn(),
31+
mockJwtDecode: vi.fn(),
32+
mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
33+
}
2334
}
24-
const logger = {
25-
info: vi.fn(),
26-
warn: vi.fn(),
27-
error: vi.fn(),
28-
debug: vi.fn(),
29-
trace: vi.fn(),
30-
fatal: vi.fn(),
31-
child: vi.fn(),
32-
}
33-
return {
34-
mockGetSession: vi.fn(),
35-
mockDb: db,
36-
mockLogger: logger,
37-
mockParseProvider: vi.fn(),
38-
mockEvaluateScopeCoverage: vi.fn(),
39-
mockJwtDecode: vi.fn(),
40-
mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
41-
}
42-
})
35+
)
4336

4437
vi.mock('@/lib/auth', () => ({
4538
getSession: mockGetSession,
@@ -66,7 +59,6 @@ vi.mock('@sim/logger', () => ({
6659

6760
vi.mock('@/lib/oauth/utils', () => ({
6861
parseProvider: mockParseProvider,
69-
evaluateScopeCoverage: mockEvaluateScopeCoverage,
7062
}))
7163

7264
import { GET } from '@/app/api/auth/oauth/connections/route'
@@ -83,16 +75,6 @@ describe('OAuth Connections API Route', () => {
8375
baseProvider: providerId.split('-')[0] || providerId,
8476
featureType: providerId.split('-')[1] || 'default',
8577
}))
86-
87-
mockEvaluateScopeCoverage.mockImplementation(
88-
(_providerId: string, _grantedScopes: string[]) => ({
89-
canonicalScopes: ['email', 'profile'],
90-
grantedScopes: ['email', 'profile'],
91-
missingScopes: [],
92-
extraScopes: [],
93-
requiresReauthorization: false,
94-
})
95-
)
9678
})
9779

9880
it('should return connections successfully', async () => {

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

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
66
import { getSession } from '@/lib/auth'
77
import { generateRequestId } from '@/lib/core/utils/request'
88
import type { OAuthProvider } from '@/lib/oauth'
9-
import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth'
9+
import { parseProvider } from '@/lib/oauth'
1010

1111
const logger = createLogger('OAuthConnectionsAPI')
1212

@@ -49,8 +49,7 @@ export async function GET(request: NextRequest) {
4949

5050
for (const acc of accounts) {
5151
const { baseProvider, featureType } = parseProvider(acc.providerId as OAuthProvider)
52-
const grantedScopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
53-
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)
52+
const scopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
5453

5554
if (baseProvider) {
5655
// Try multiple methods to get a user-friendly display name
@@ -96,10 +95,6 @@ export async function GET(request: NextRequest) {
9695
const accountSummary = {
9796
id: acc.id,
9897
name: displayName,
99-
scopes: scopeEvaluation.grantedScopes,
100-
missingScopes: scopeEvaluation.missingScopes,
101-
extraScopes: scopeEvaluation.extraScopes,
102-
requiresReauthorization: scopeEvaluation.requiresReauthorization,
10398
}
10499

105100
if (existingConnection) {
@@ -108,20 +103,8 @@ export async function GET(request: NextRequest) {
108103
existingConnection.accounts.push(accountSummary)
109104

110105
existingConnection.scopes = Array.from(
111-
new Set([...(existingConnection.scopes || []), ...scopeEvaluation.grantedScopes])
106+
new Set([...(existingConnection.scopes || []), ...scopes])
112107
)
113-
existingConnection.missingScopes = Array.from(
114-
new Set([...(existingConnection.missingScopes || []), ...scopeEvaluation.missingScopes])
115-
)
116-
existingConnection.extraScopes = Array.from(
117-
new Set([...(existingConnection.extraScopes || []), ...scopeEvaluation.extraScopes])
118-
)
119-
existingConnection.canonicalScopes =
120-
existingConnection.canonicalScopes && existingConnection.canonicalScopes.length > 0
121-
? existingConnection.canonicalScopes
122-
: scopeEvaluation.canonicalScopes
123-
existingConnection.requiresReauthorization =
124-
existingConnection.requiresReauthorization || scopeEvaluation.requiresReauthorization
125108

126109
const existingTimestamp = existingConnection.lastConnected
127110
? new Date(existingConnection.lastConnected).getTime()
@@ -138,11 +121,7 @@ export async function GET(request: NextRequest) {
138121
baseProvider,
139122
featureType,
140123
isConnected: true,
141-
scopes: scopeEvaluation.grantedScopes,
142-
canonicalScopes: scopeEvaluation.canonicalScopes,
143-
missingScopes: scopeEvaluation.missingScopes,
144-
extraScopes: scopeEvaluation.extraScopes,
145-
requiresReauthorization: scopeEvaluation.requiresReauthorization,
124+
scopes,
146125
lastConnected: acc.updatedAt.toISOString(),
147126
accounts: [accountSummary],
148127
})

apps/sim/app/api/auth/oauth/credentials/route.test.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { NextRequest } from 'next/server'
88
import { beforeEach, describe, expect, it, vi } from 'vitest'
99

10-
const { mockCheckSessionOrInternalAuth, mockEvaluateScopeCoverage, mockLogger } = vi.hoisted(() => {
10+
const { mockCheckSessionOrInternalAuth, mockLogger } = vi.hoisted(() => {
1111
const logger = {
1212
info: vi.fn(),
1313
warn: vi.fn(),
@@ -19,7 +19,6 @@ const { mockCheckSessionOrInternalAuth, mockEvaluateScopeCoverage, mockLogger }
1919
}
2020
return {
2121
mockCheckSessionOrInternalAuth: vi.fn(),
22-
mockEvaluateScopeCoverage: vi.fn(),
2322
mockLogger: logger,
2423
}
2524
})
@@ -28,10 +27,6 @@ vi.mock('@/lib/auth/hybrid', () => ({
2827
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
2928
}))
3029

31-
vi.mock('@/lib/oauth', () => ({
32-
evaluateScopeCoverage: mockEvaluateScopeCoverage,
33-
}))
34-
3530
vi.mock('@/lib/core/utils/request', () => ({
3631
generateRequestId: vi.fn().mockReturnValue('mock-request-id'),
3732
}))
@@ -87,16 +82,6 @@ describe('OAuth Credentials API Route', () => {
8782

8883
beforeEach(() => {
8984
vi.clearAllMocks()
90-
91-
mockEvaluateScopeCoverage.mockImplementation(
92-
(_providerId: string, grantedScopes: string[]) => ({
93-
canonicalScopes: grantedScopes,
94-
grantedScopes,
95-
missingScopes: [],
96-
extraScopes: [],
97-
requiresReauthorization: false,
98-
})
99-
)
10085
})
10186

10287
it('should handle unauthenticated user', async () => {

0 commit comments

Comments
 (0)