feat(scopes): add scopes warning hook (#1842)

* fix(billing): should allow restoring subscription (#1728)

* fix(already-cancelled-sub): UI should allow restoring subscription

* restore functionality fixed

* fix

* Server side logic to check auth scopes

* Fix scopes code

* Remove frontend changes

* Fix tests

* Lint

* Remove log for lint

* Fix scopes check

* Fix conflict

---------

Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
This commit is contained in:
Siddharth Ganesan
2025-11-07 16:31:25 -08:00
committed by GitHub
parent d3e81e97d5
commit 6cdee5351c
6 changed files with 211 additions and 19 deletions

View File

@@ -20,6 +20,8 @@ describe('OAuth Connections API Route', () => {
error: vi.fn(),
debug: vi.fn(),
}
const mockParseProvider = vi.fn()
const mockEvaluateScopeCoverage = vi.fn()
const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'
@@ -52,6 +54,26 @@ describe('OAuth Connections API Route', () => {
vi.doMock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
mockParseProvider.mockImplementation((providerId: string) => ({
baseProvider: providerId.split('-')[0] || providerId,
featureType: providerId.split('-')[1] || 'default',
}))
mockEvaluateScopeCoverage.mockImplementation(
(_providerId: string, _grantedScopes: string[]) => ({
canonicalScopes: ['email', 'profile'],
grantedScopes: ['email', 'profile'],
missingScopes: [],
extraScopes: [],
requiresReauthorization: false,
})
)
vi.doMock('@/lib/oauth/oauth', () => ({
parseProvider: mockParseProvider,
evaluateScopeCoverage: mockEvaluateScopeCoverage,
}))
})
afterEach(() => {

View File

@@ -4,6 +4,8 @@ import { jwtDecode } from 'jwt-decode'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import type { OAuthProvider } from '@/lib/oauth/oauth'
import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth/oauth'
import { generateRequestId } from '@/lib/utils'
const logger = createLogger('OAuthConnectionsAPI')
@@ -46,10 +48,11 @@ export async function GET(request: NextRequest) {
const connections: any[] = []
for (const acc of accounts) {
// Extract the base provider and feature type from providerId (e.g., 'google-email' -> 'google', 'email')
const [provider, featureType = 'default'] = acc.providerId.split('-')
const { baseProvider, featureType } = parseProvider(acc.providerId as OAuthProvider)
const grantedScopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)
if (provider) {
if (baseProvider) {
// Try multiple methods to get a user-friendly display name
let displayName = ''
@@ -70,7 +73,7 @@ export async function GET(request: NextRequest) {
}
// Method 2: For GitHub, the accountId might be the username
if (!displayName && provider === 'github') {
if (!displayName && baseProvider === 'github') {
displayName = `${acc.accountId} (GitHub)`
}
@@ -81,7 +84,7 @@ export async function GET(request: NextRequest) {
// Fallback: Use accountId with provider type as context
if (!displayName) {
displayName = `${acc.accountId} (${provider})`
displayName = `${acc.accountId} (${baseProvider})`
}
// Create a unique connection key that includes the full provider ID
@@ -90,28 +93,58 @@ export async function GET(request: NextRequest) {
// Find existing connection for this specific provider ID
const existingConnection = connections.find((conn) => conn.provider === connectionKey)
const accountSummary = {
id: acc.id,
name: displayName,
scopes: scopeEvaluation.grantedScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}
if (existingConnection) {
// Add account to existing connection
existingConnection.accounts = existingConnection.accounts || []
existingConnection.accounts.push({
id: acc.id,
name: displayName,
})
existingConnection.accounts.push(accountSummary)
existingConnection.scopes = Array.from(
new Set([...(existingConnection.scopes || []), ...scopeEvaluation.grantedScopes])
)
existingConnection.missingScopes = Array.from(
new Set([...(existingConnection.missingScopes || []), ...scopeEvaluation.missingScopes])
)
existingConnection.extraScopes = Array.from(
new Set([...(existingConnection.extraScopes || []), ...scopeEvaluation.extraScopes])
)
existingConnection.canonicalScopes =
existingConnection.canonicalScopes && existingConnection.canonicalScopes.length > 0
? existingConnection.canonicalScopes
: scopeEvaluation.canonicalScopes
existingConnection.requiresReauthorization =
existingConnection.requiresReauthorization || scopeEvaluation.requiresReauthorization
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: provider,
baseProvider,
featureType,
isConnected: true,
scopes: acc.scope ? acc.scope.split(' ') : [],
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
lastConnected: acc.updatedAt.toISOString(),
accounts: [
{
id: acc.id,
name: displayName,
},
],
accounts: [accountSummary],
})
}
}

View File

@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('OAuth Credentials API Route', () => {
const mockGetSession = vi.fn()
const mockParseProvider = vi.fn()
const mockEvaluateScopeCoverage = vi.fn()
const mockDb = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
@@ -41,8 +42,9 @@ describe('OAuth Credentials API Route', () => {
getSession: mockGetSession,
}))
vi.doMock('@/lib/oauth', () => ({
vi.doMock('@/lib/oauth/oauth', () => ({
parseProvider: mockParseProvider,
evaluateScopeCoverage: mockEvaluateScopeCoverage,
}))
vi.doMock('@sim/db', () => ({
@@ -66,6 +68,20 @@ describe('OAuth Credentials API Route', () => {
vi.doMock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
mockParseProvider.mockImplementation((providerId: string) => ({
baseProvider: providerId.split('-')[0] || providerId,
}))
mockEvaluateScopeCoverage.mockImplementation(
(_providerId: string, grantedScopes: string[]) => ({
canonicalScopes: grantedScopes,
grantedScopes,
missingScopes: [],
extraScopes: [],
requiresReauthorization: false,
})
)
})
afterEach(() => {

View File

@@ -6,7 +6,8 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createLogger } from '@/lib/logs/console/logger'
import { parseProvider } from '@/lib/oauth/oauth'
import type { OAuthService } from '@/lib/oauth/oauth'
import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth/oauth'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { generateRequestId } from '@/lib/utils'
@@ -206,12 +207,20 @@ export async function GET(request: NextRequest) {
displayName = `${acc.accountId} (${baseProvider})`
}
const grantedScopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)
return {
id: acc.id,
name: displayName,
provider: acc.providerId,
lastUsed: acc.updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}
})
)

View File

@@ -0,0 +1,45 @@
'use client'
import type { Credential } from '@/lib/oauth/oauth'
export interface OAuthScopeStatus {
requiresReauthorization: boolean
missingScopes: string[]
extraScopes: string[]
canonicalScopes: string[]
grantedScopes: string[]
}
/**
* Extract scope status from a credential
*/
export function getCredentialScopeStatus(credential: Credential): OAuthScopeStatus {
return {
requiresReauthorization: credential.requiresReauthorization || false,
missingScopes: credential.missingScopes || [],
extraScopes: credential.extraScopes || [],
canonicalScopes: credential.canonicalScopes || [],
grantedScopes: credential.scopes || [],
}
}
/**
* Check if a credential needs reauthorization
*/
export function credentialNeedsReauth(credential: Credential): boolean {
return credential.requiresReauthorization || false
}
/**
* Check if any credentials in a list need reauthorization
*/
export function anyCredentialNeedsReauth(credentials: Credential[]): boolean {
return credentials.some(credentialNeedsReauth)
}
/**
* Get all credentials that need reauthorization
*/
export function getCredentialsNeedingReauth(credentials: Credential[]): Credential[] {
return credentials.filter(credentialNeedsReauth)
}

View File

@@ -660,6 +660,68 @@ export function getProviderIdFromServiceId(serviceId: string): string {
return serviceId
}
// Helper to locate a service configuration by its providerId
export function getServiceConfigByProviderId(providerId: string): OAuthServiceConfig | null {
for (const provider of Object.values(OAUTH_PROVIDERS)) {
for (const service of Object.values(provider.services)) {
if (service.providerId === providerId || service.id === providerId) {
return service
}
}
}
return null
}
// Get the canonical scopes for a given providerId (service instance)
export function getCanonicalScopesForProvider(providerId: string): string[] {
const service = getServiceConfigByProviderId(providerId)
return service?.scopes ? [...service.scopes] : []
}
// Normalize scopes by trimming, filtering empties, and deduplicating
export function normalizeScopes(scopes: string[]): string[] {
const seen = new Set<string>()
for (const scope of scopes) {
const trimmed = scope.trim()
if (trimmed && !seen.has(trimmed)) {
seen.add(trimmed)
}
}
return Array.from(seen)
}
export interface ScopeEvaluation {
canonicalScopes: string[]
grantedScopes: string[]
missingScopes: string[]
extraScopes: string[]
requiresReauthorization: boolean
}
// Compare granted scopes with canonical ones for a providerId
export function evaluateScopeCoverage(
providerId: string,
grantedScopes: string[]
): ScopeEvaluation {
const canonicalScopes = getCanonicalScopesForProvider(providerId)
const normalizedGranted = normalizeScopes(grantedScopes)
const canonicalSet = new Set(canonicalScopes)
const grantedSet = new Set(normalizedGranted)
const missingScopes = canonicalScopes.filter((scope) => !grantedSet.has(scope))
const extraScopes = normalizedGranted.filter((scope) => !canonicalSet.has(scope))
return {
canonicalScopes,
grantedScopes: normalizedGranted,
missingScopes,
extraScopes,
requiresReauthorization: missingScopes.length > 0,
}
}
// Interface for credential objects
export interface Credential {
id: string
@@ -668,6 +730,11 @@ export interface Credential {
serviceId?: string
lastUsed?: string
isDefault?: boolean
scopes?: string[]
canonicalScopes?: string[]
missingScopes?: string[]
extraScopes?: string[]
requiresReauthorization?: boolean
}
// Interface for provider configuration