Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions apps/sim/app/api/auth/oauth/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,12 @@ export async function POST(request: NextRequest) {
{ status: 200 }
)
} catch (error) {
logger.error(`[${requestId}] Failed to refresh access token:`, error)
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
const errorMessage = error instanceof Error ? error.message : 'Failed to refresh access token'
logger.error(`[${requestId}] Failed to refresh access token:`, {
error: errorMessage,
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json({ error: errorMessage }, { status: 401 })
}
} catch (error) {
logger.error(`[${requestId}] Error getting access token`, error)
Expand Down Expand Up @@ -207,8 +211,13 @@ export async function GET(request: NextRequest) {
},
{ status: 200 }
)
} catch (_error) {
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to refresh access token'
logger.error(`[${requestId}] Failed to refresh access token:`, {
error: errorMessage,
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json({ error: errorMessage }, { status: 401 })
}
} catch (error) {
logger.error(`[${requestId}] Error fetching access token`, error)
Expand Down
37 changes: 22 additions & 15 deletions apps/sim/app/api/auth/oauth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,14 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
const refreshResult = await refreshOAuthToken(providerId, credential.refreshToken!)

if (!refreshResult) {
logger.error(`Failed to refresh token for user ${userId}, provider ${providerId}`, {
providerId,
userId,
hasRefreshToken: !!credential.refreshToken,
})
logger.error(
`Failed to refresh token for user ${userId}, provider ${providerId} - no result returned`,
{
providerId,
userId,
hasRefreshToken: !!credential.refreshToken,
}
)
return null
}

Expand Down Expand Up @@ -170,7 +173,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
providerId,
userId,
})
return null
throw error
}
}

Expand Down Expand Up @@ -221,12 +224,15 @@ export async function refreshAccessTokenIfNeeded(
)

if (!refreshedToken) {
logger.error(`[${requestId}] Failed to refresh token for credential: ${credentialId}`, {
credentialId,
providerId: credential.providerId,
userId: credential.userId,
hasRefreshToken: !!credential.refreshToken,
})
logger.error(
`[${requestId}] Failed to refresh token for credential: ${credentialId} - no result returned`,
{
credentialId,
providerId: credential.providerId,
userId: credential.userId,
hasRefreshToken: !!credential.refreshToken,
}
)
return null
}

Expand All @@ -249,14 +255,15 @@ export async function refreshAccessTokenIfNeeded(
logger.info(`[${requestId}] Successfully refreshed access token for credential`)
return refreshedToken.accessToken
} catch (error) {
// Re-throw the error to propagate detailed error messages (e.g., session expiry instructions)
logger.error(`[${requestId}] Error refreshing token for credential`, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
providerId: credential.providerId,
credentialId,
userId: credential.userId,
})
return null
throw error
}
Comment on lines 257 to 267
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing return null to throw error breaks backward compatibility. Many callers expect refreshAccessTokenIfNeeded to return null on failure and check if (!accessToken). This change will cause unhandled exceptions in:

  • apps/sim/lib/webhooks/utils.server.ts:2129,2816,2902
  • apps/sim/lib/webhooks/provider-subscriptions.ts:57,190
  • apps/sim/app/api/tools/gmail/labels/route.ts:70
  • apps/sim/app/api/tools/sharepoint/sites/route.ts:53
  • Plus 20+ more files

Either revert to returning null, or wrap all callers in try-catch blocks.

} else if (!accessToken) {
// We have no access token and either no refresh token or not eligible to refresh
Expand Down Expand Up @@ -292,8 +299,8 @@ export async function refreshTokenIfNeeded(
const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken!)

if (!refreshResult) {
logger.error(`[${requestId}] Failed to refresh token for credential`)
throw new Error('Failed to refresh token')
logger.error(`[${requestId}] Failed to refresh token for credential - no result returned`)
throw new Error('Failed to refresh token: no result returned from provider')
}

const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult
Expand Down
91 changes: 91 additions & 0 deletions apps/sim/blocks/blocks/google_vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,90 @@ Return ONLY the hold name - no explanations, no quotes, no extra text.`,
placeholder: 'Org Unit ID (alternative to emails)',
condition: { field: 'operation', value: ['create_matters_holds', 'create_matters_export'] },
},
// Date filtering for exports and holds (holds only support MAIL and GROUPS corpus)
{
id: 'startTime',
title: 'Start Time',
type: 'short-input',
placeholder: 'YYYY-MM-DDTHH:mm:ssZ',
condition: { field: 'operation', value: ['create_matters_export', 'create_matters_holds'] },
wandConfig: {
Comment on lines +162 to +169
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Date filtering fields show for all corpus types, but the comment states "holds only support MAIL and GROUPS corpus" for date filtering. Consider adding corpus-based conditions:

Suggested change
// Date filtering for exports and holds (holds only support MAIL and GROUPS corpus)
{
id: 'startTime',
title: 'Start Time',
type: 'short-input',
placeholder: 'YYYY-MM-DDTHH:mm:ssZ',
condition: { field: 'operation', value: ['create_matters_export', 'create_matters_holds'] },
wandConfig: {
{
id: 'startTime',
title: 'Start Time',
type: 'short-input',
placeholder: 'YYYY-MM-DDTHH:mm:ssZ',
condition: {
field: 'operation',
value: ['create_matters_export', 'create_matters_holds']
},

Or if holds truly need corpus restriction, the condition should be more complex to check corpus for holds operations.

enabled: true,
prompt: `Generate an ISO 8601 timestamp in GMT based on the user's description for Google Vault date filtering.
The timestamp should be in the format: YYYY-MM-DDTHH:mm:ssZ (UTC timezone).
Note: Google Vault rounds times to 12 AM on the specified date.
Examples:
- "yesterday" -> Calculate yesterday's date at 00:00:00Z
- "last week" -> Calculate 7 days ago at 00:00:00Z
- "beginning of this month" -> Calculate the 1st of current month at 00:00:00Z
- "January 1, 2024" -> 2024-01-01T00:00:00Z

Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
placeholder: 'Describe the start date (e.g., "last month", "January 1, 2024")...',
generationType: 'timestamp',
},
},
{
id: 'endTime',
title: 'End Time',
type: 'short-input',
placeholder: 'YYYY-MM-DDTHH:mm:ssZ',
condition: { field: 'operation', value: ['create_matters_export', 'create_matters_holds'] },
wandConfig: {
enabled: true,
prompt: `Generate an ISO 8601 timestamp in GMT based on the user's description for Google Vault date filtering.
The timestamp should be in the format: YYYY-MM-DDTHH:mm:ssZ (UTC timezone).
Note: Google Vault rounds times to 12 AM on the specified date.
Examples:
- "now" -> Current timestamp
- "today" -> Today's date at 23:59:59Z
- "end of last month" -> Last day of previous month at 23:59:59Z
- "December 31, 2024" -> 2024-12-31T23:59:59Z

Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
placeholder: 'Describe the end date (e.g., "today", "end of last quarter")...',
generationType: 'timestamp',
},
},
{
id: 'terms',
title: 'Search Terms',
type: 'long-input',
placeholder: 'Enter search query (e.g., from:[email protected] subject:confidential)',
condition: { field: 'operation', value: ['create_matters_export', 'create_matters_holds'] },
wandConfig: {
enabled: true,
prompt: `Generate a Google Vault search query based on the user's description.
The query can use Gmail-style search operators for MAIL corpus:
- from:[email protected] - emails from specific sender
- to:[email protected] - emails to specific recipient
- subject:keyword - emails with keyword in subject
- has:attachment - emails with attachments
- filename:pdf - emails with PDF attachments
- before:YYYY/MM/DD - emails before date
- after:YYYY/MM/DD - emails after date

For DRIVE corpus, use Drive search operators:
- owner:[email protected] - files owned by user
- type:document - specific file types

For holds, date filtering only works with MAIL and GROUPS corpus.

Return ONLY the search query - no explanations, no quotes, no extra text.`,
placeholder: 'Describe what content to search for...',
},
},
// Drive-specific option for holds
{
id: 'includeSharedDrives',
title: 'Include Shared Drives',
type: 'switch',
condition: {
field: 'operation',
value: 'create_matters_holds',
and: { field: 'corpus', value: 'DRIVE' },
},
},
{
id: 'exportId',
title: 'Export ID',
Expand Down Expand Up @@ -296,9 +380,16 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
corpus: { type: 'string', description: 'Data corpus (MAIL, DRIVE, GROUPS, etc.)' },
accountEmails: { type: 'string', description: 'Comma-separated account emails' },
orgUnitId: { type: 'string', description: 'Organization unit ID' },
startTime: { type: 'string', description: 'Start time for date filtering (ISO 8601 format)' },
endTime: { type: 'string', description: 'End time for date filtering (ISO 8601 format)' },
terms: { type: 'string', description: 'Search query terms' },

// Create hold inputs
holdName: { type: 'string', description: 'Name for the hold' },
includeSharedDrives: {
type: 'boolean',
description: 'Include files in shared drives (for DRIVE corpus holds)',
},

// Download export file inputs
bucketName: { type: 'string', description: 'GCS bucket name from export' },
Expand Down
29 changes: 27 additions & 2 deletions apps/sim/lib/oauth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1149,7 +1149,7 @@
}
}

throw new Error(`Unknown OAuth provider: ${providerId}`)

Check failure on line 1152 in apps/sim/lib/oauth/oauth.ts

View workflow job for this annotation

GitHub Actions / Test and Build / Test and Build

lib/oauth/oauth.test.ts > OAuth Token Refresh > Error Handling > should return null for unsupported provider

Error: Unknown OAuth provider: unsupported ❯ getBaseProviderForService lib/oauth/oauth.ts:1152:9 ❯ Module.refreshOAuthToken lib/oauth/oauth.ts:1160:22 ❯ lib/oauth/oauth.test.ts:317:9 ❯ withMockFetch lib/oauth/oauth.test.ts:77:10 ❯ lib/oauth/oauth.test.ts:316:28
}

export async function refreshOAuthToken(
Expand All @@ -1171,7 +1171,7 @@

if (!response.ok) {
const errorText = await response.text()
let errorData = errorText
let errorData: any = errorText

try {
errorData = JSON.parse(errorText)
Expand All @@ -1191,7 +1191,30 @@
hasRefreshToken: !!refreshToken,
refreshTokenPrefix: refreshToken ? `${refreshToken.substring(0, 10)}...` : 'none',
})

// Check for Google Workspace session control errors (RAPT - Reauthentication Policy Token)
// This occurs when the organization enforces periodic re-authentication
if (
typeof errorData === 'object' &&
(errorData.error_subtype === 'invalid_rapt' ||
errorData.error_description?.includes('reauth related error'))
Comment on lines +1197 to +1200
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typeof errorData === 'object' is true for null in JavaScript. Should add && errorData !== null to prevent null pointer exceptions when accessing errorData.error_subtype

Suggested change
if (
typeof errorData === 'object' &&
(errorData.error_subtype === 'invalid_rapt' ||
errorData.error_description?.includes('reauth related error'))
if (
typeof errorData === 'object' &&
errorData !== null &&
(errorData.error_subtype === 'invalid_rapt' ||
errorData.error_description?.includes('reauth related error'))

) {
throw new Error(
`Session expired due to organization security policy. Please reconnect your ${providerId} account to continue. Alternatively, ask your Google Workspace admin to exempt this app from session control: Admin Console → Security → Google Cloud session control → "Exempt trusted apps".`
)
}

if (
typeof errorData === 'object' &&
errorData.error === 'invalid_grant' &&
!errorData.error_subtype
Comment on lines +1207 to +1210
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typeof errorData === 'object' is true for null in JavaScript. Should add && errorData !== null

Suggested change
if (
typeof errorData === 'object' &&
errorData.error === 'invalid_grant' &&
!errorData.error_subtype
if (
typeof errorData === 'object' &&
errorData !== null &&
errorData.error === 'invalid_grant' &&
!errorData.error_subtype

) {
throw new Error(
`Access has been revoked or the refresh token is no longer valid. Please reconnect your ${providerId} account.`
)
}

throw new Error(`Failed to refresh token: ${response.status} ${errorText}`)

Check failure on line 1217 in apps/sim/lib/oauth/oauth.ts

View workflow job for this annotation

GitHub Actions / Test and Build / Test and Build

lib/oauth/oauth.test.ts > OAuth Token Refresh > Error Handling > should return null for API error responses

Error: Failed to refresh token: 400 {"error":"invalid_request","error_description":"Invalid refresh token"} ❯ Module.refreshOAuthToken lib/oauth/oauth.ts:1217:13 ❯ lib/oauth/oauth.test.ts:335:22
}

const data = await response.json()
Expand Down Expand Up @@ -1224,6 +1247,8 @@
}
} catch (error) {
logger.error('Error refreshing token:', { error })
return null
// Re-throw specific errors so they propagate with their detailed messages
// Only return null for truly unexpected errors without useful messages
throw error
}
}
19 changes: 18 additions & 1 deletion apps/sim/tools/google_vault/create_matters_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ export const createMattersExportTool: ToolConfig<GoogleVaultCreateMattersExportP
visibility: 'user-only',
description: 'Organization unit ID to scope export (alternative to emails)',
},
startTime: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Start time for date filtering (ISO 8601 format, e.g., 2024-01-01T00:00:00Z)',
},
endTime: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'End time for date filtering (ISO 8601 format, e.g., 2024-12-31T23:59:59Z)',
},
terms: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Search query terms to filter exported content',
},
},

request: {
Expand Down Expand Up @@ -75,7 +93,6 @@ export const createMattersExportTool: ToolConfig<GoogleVaultCreateMattersExportP
terms: params.terms || undefined,
startTime: params.startTime || undefined,
endTime: params.endTime || undefined,
timeZone: params.timeZone || undefined,
...scope,
}

Expand Down
45 changes: 45 additions & 0 deletions apps/sim/tools/google_vault/create_matters_holds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,32 @@ export const createMattersHoldsTool: ToolConfig<GoogleVaultCreateMattersHoldsPar
visibility: 'user-only',
description: 'Organization unit ID to put on hold (alternative to accounts)',
},
// Query parameters for MAIL and GROUPS corpus (date filtering)
terms: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Search terms to filter held content (for MAIL and GROUPS corpus)',
},
startTime: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Start time for date filtering (ISO 8601 format, for MAIL and GROUPS corpus)',
},
endTime: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'End time for date filtering (ISO 8601 format, for MAIL and GROUPS corpus)',
},
// Drive-specific option
includeSharedDrives: {
type: 'boolean',
required: false,
visibility: 'user-only',
description: 'Include files in shared drives (for DRIVE corpus)',
},
},

request: {
Expand Down Expand Up @@ -72,6 +98,25 @@ export const createMattersHoldsTool: ToolConfig<GoogleVaultCreateMattersHoldsPar
body.orgUnit = { orgUnitId: params.orgUnitId }
}

// Build corpus-specific query for date filtering
if (params.corpus === 'MAIL' || params.corpus === 'GROUPS') {
const hasQueryParams = params.terms || params.startTime || params.endTime
if (hasQueryParams) {
const queryObj: any = {}
if (params.terms) queryObj.terms = params.terms
if (params.startTime) queryObj.startTime = params.startTime
if (params.endTime) queryObj.endTime = params.endTime

if (params.corpus === 'MAIL') {
body.query = { mailQuery: queryObj }
} else {
body.query = { groupsQuery: queryObj }
}
}
} else if (params.corpus === 'DRIVE' && params.includeSharedDrives) {
body.query = { driveQuery: { includeSharedDriveFiles: params.includeSharedDrives } }
}

return body
},
},
Expand Down
7 changes: 6 additions & 1 deletion apps/sim/tools/google_vault/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export interface GoogleVaultCreateMattersExportParams extends GoogleVaultCommonP
terms?: string
startTime?: string
endTime?: string
timeZone?: string
includeSharedDrives?: boolean
}

Expand All @@ -39,6 +38,12 @@ export interface GoogleVaultCreateMattersHoldsParams extends GoogleVaultCommonPa
corpus: GoogleVaultCorpus
accountEmails?: string // Comma-separated list or array handled in the tool
orgUnitId?: string
// Query parameters for MAIL and GROUPS corpus (date filtering)
terms?: string
startTime?: string
endTime?: string
// Drive-specific option
includeSharedDrives?: boolean
}

export interface GoogleVaultListMattersHoldsParams extends GoogleVaultCommonParams {
Expand Down
Loading