Skip to content
Merged
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
25 changes: 23 additions & 2 deletions apps/sim/app/api/auth/oauth/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,18 @@ describe('OAuth Utils', () => {
refreshToken: 'new-refresh-token',
})

mockUpdateChain()
const { mockSet } = mockUpdateChain()

const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')

expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
expect(mockDb.update).toHaveBeenCalled()
expect(mockSet).toHaveBeenCalledWith(
expect.objectContaining({
accessToken: 'new-token',
refreshToken: 'new-refresh-token',
accessTokenExpiresAt: expect.any(Date),
})
)
expect(result).toEqual({ accessToken: 'new-token', refreshed: true })
})

Expand Down Expand Up @@ -185,6 +191,21 @@ describe('OAuth Utils', () => {
expect(mockRefreshOAuthToken).not.toHaveBeenCalled()
expect(result).toEqual({ accessToken: 'token', refreshed: false })
})

it('keeps a legacy non-expiring Monday credential usable without refreshing it', async () => {
const legacyCredential = {
id: 'legacy-monday-credential-id',
accessToken: 'legacy-monday-access-token',
refreshToken: null,
accessTokenExpiresAt: null,
providerId: 'monday',
}

const result = await refreshTokenIfNeeded('request-id', legacyCredential, legacyCredential.id)

expect(mockRefreshOAuthToken).not.toHaveBeenCalled()
expect(result).toEqual({ accessToken: 'legacy-monday-access-token', refreshed: false })
})
})

describe('refreshAccessTokenIfNeeded', () => {
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/lib/auth/connectors/managed-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,14 @@ describe('userinfo-backed managed OAuth connectors', () => {
}
)

it('requires PKCE and refresh-token persistence for Monday OAuth 2.1', () => {
expect(policyFor('monday')).toMatchObject({
pkce: true,
requiresRefreshToken: true,
nonceVerification: 'state_only',
})
})

it.each(['linear', 'monday'])(
'treats a partial %s GraphQL response as no identity at all',
async (providerId) => {
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/auth/connectors/managed-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,8 +798,8 @@ const USER_INFO_MANAGED_OAUTH_CONNECTORS = new Map<string, () => ManagedOAuthCon
() =>
createUserInfoManagedOAuthConnector({
providerId: 'monday',
/** monday.com access tokens do not expire and no refresh token is issued. */
requiresRefreshToken: false,
requiresRefreshToken: true,
pkce: true,
scopes: { from: 'token_response' },
userInfo: {
url: MONDAY_API_URL,
Expand Down
73 changes: 63 additions & 10 deletions apps/sim/lib/auth/connectors/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { syntheticConnectorEmail } from '@/lib/auth/connector-email'
import { env } from '@/lib/core/config/env'
import { inspectConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
import {
DEFAULT_MAX_ERROR_BODY_BYTES,
readResponseJsonWithLimit,
readResponseTextWithLimit,
} from '@/lib/core/utils/stream-limits'
Expand All @@ -22,6 +23,11 @@ import {
getBoundMicrosoftDataverseEnvironment,
resolveMicrosoftDataverseOAuthCallbackScopes,
} from '@/lib/oauth/microsoft-dataverse'
import {
exchangeMondayAuthorizationCode,
MONDAY_OAUTH_AUTHORIZATION_URL,
MONDAY_OAUTH_TOKEN_URL,
} from '@/lib/oauth/monday'
import { SALESFORCE_LOGIN_HOSTS } from '@/lib/oauth/salesforce'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
import { MONDAY_API_URL, MONDAY_API_VERSION } from '@/tools/monday/utils'
Expand Down Expand Up @@ -86,6 +92,17 @@ interface AttioWorkspaceMemberResponse {
}
}

interface MondayUserInfoResponse {
data?: {
me?: {
id?: string | number
name?: string | null
email?: string | null
} | null
}
errors?: unknown[]
}

/**
* Shape of `GET https://api.bitbucket.org/2.0/user` for the authenticated user.
* @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-users/#api-user-get
Expand Down Expand Up @@ -1729,15 +1746,29 @@ export function buildConnectorProviders(): GenericOAuthConfig[] {
providerId: 'monday',
clientId: env.MONDAY_CLIENT_ID as string,
clientSecret: env.MONDAY_CLIENT_SECRET as string,
authorizationUrl: 'https://auth.monday.com/oauth2/authorize',
tokenUrl: 'https://auth.monday.com/oauth2/token',
authorizationUrl: MONDAY_OAUTH_AUTHORIZATION_URL,
tokenUrl: MONDAY_OAUTH_TOKEN_URL,
userInfoUrl: 'https://api.monday.com/v2',
scopes: getCanonicalScopesForProvider('monday'),
responseType: 'code',
pkce: false,
pkce: true,
authentication: 'post',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/monday`,
getToken: async ({ code, codeVerifier, redirectURI }) => {
if (!codeVerifier) {
throw new Error('Monday OAuth token exchange requires a PKCE verifier')
}
return exchangeMondayAuthorizationCode({
clientId: env.MONDAY_CLIENT_ID as string,
clientSecret: env.MONDAY_CLIENT_SECRET as string,
code,
codeVerifier,
redirectUri: redirectURI,
})
},
getUserInfo: async (tokens) => {
try {
const signal = AbortSignal.timeout(15_000)
const response = await fetch(MONDAY_API_URL, {
method: 'POST',
headers: {
Expand All @@ -1746,27 +1777,49 @@ export function buildConnectorProviders(): GenericOAuthConfig[] {
Authorization: tokens.accessToken ?? '',
},
body: JSON.stringify({ query: '{ me { id name email } }' }),
signal,
})

if (!response.ok) {
await response.text().catch(() => {})
await readResponseTextWithLimit(response, {
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
label: 'Monday OAuth user info error response',
signal,
}).catch(() => {})
logger.error('Error fetching Monday.com user info:', {
status: response.status,
statusText: response.statusText,
})
return null
}

const data = await response.json()
const data = await readResponseJsonWithLimit<MondayUserInfoResponse>(response, {
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
label: 'Monday OAuth user info response',
signal,
})
if (data.errors?.length) {
logger.error('Monday.com user info returned GraphQL errors', {
errorCount: data.errors.length,
})
return null
}
const user = data.data?.me
if (!user) return null
const userId =
typeof user?.id === 'string' || typeof user?.id === 'number'
? String(user.id)
: undefined
if (!user || !userId) return null

const email = typeof user.email === 'string' ? user.email : undefined
const name = typeof user.name === 'string' ? user.name : undefined

const now = new Date()
return {
id: `${user.id.toString()}-${generateId()}`,
name: user.name || 'Monday.com User',
email: user.email || syntheticConnectorEmail('monday', user.id),
emailVerified: !!user.email,
id: `${userId}-${generateId()}`,
name: name || 'Monday.com User',
email: email || syntheticConnectorEmail('monday', userId),
emailVerified: !!email,
createdAt: now,
updatedAt: now,
}
Expand Down
132 changes: 130 additions & 2 deletions apps/sim/lib/credentials/managed-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
* @vitest-environment node
*/
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
getBilling: vi.fn(),
isAvailable: vi.fn(),
getAdapter: vi.fn(),
decryptSecret: vi.fn(),
encryptSecret: vi.fn(),
}))

vi.mock('@/lib/billing/core/workspace-access', () => ({
Expand All @@ -25,15 +26,44 @@ vi.mock('@/lib/credential-groups/provider-registry', () => ({

vi.mock('@/lib/core/security/encryption', () => ({
decryptSecret: mocks.decryptSecret,
encryptSecret: vi.fn(),
encryptSecret: mocks.encryptSecret,
}))

import { resolveManagedOAuthToken } from '@/lib/credentials/managed-oauth'

function mondayCredentialRow() {
return {
id: 'credential-1',
workspaceId: 'workspace-1',
type: 'managed_oauth',
providerId: 'monday',
authorizationAppId: 'monday:monday-client-1',
managedOauthScopeVersion: 1,
managedOauthStatus: 'active',
grantedScopes: ['boards:read', 'me:read'],
encryptedOauthTokenSet: 'encrypted-token-set',
accessTokenExpiresAt: new Date('2026-09-01T11:00:00.000Z'),
refreshTokenExpiresAt: null,
credentialGroupId: 'group-1',
credentialGroupEnrollmentId: 'enrollment-1',
}
}

function mondayTokenResolutionParams() {
return {
credentialId: 'credential-1',
workspaceId: 'workspace-1',
expectedProviderId: 'monday',
requiredScopes: ['boards:read', 'me:read'],
}
}

describe('managed OAuth token resolution', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-01T12:00:00.000Z'))
mocks.getBilling.mockResolvedValue({ plan: 'enterprise' })
mocks.isAvailable.mockResolvedValue(true)
mocks.decryptSecret.mockResolvedValue({
Expand All @@ -53,6 +83,10 @@ describe('managed OAuth token resolution', () => {
})
})

afterEach(() => {
vi.useRealTimers()
})

it('uses a non-expiring Slack access token without entering refresh', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
Expand Down Expand Up @@ -80,4 +114,98 @@ describe('managed OAuth token resolution', () => {
).resolves.toEqual({ accessToken: 'xoxp-slack-token', refreshed: false })
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
})

it('refreshes an expired Monday credential and persists its rotated token set', async () => {
const row = mondayCredentialRow()
dbChainMockFns.limit.mockResolvedValueOnce([row]).mockResolvedValueOnce([row])
dbChainMockFns.returning.mockResolvedValueOnce([{ id: row.id }])
mocks.decryptSecret.mockResolvedValue({
decrypted: JSON.stringify({
type: 'managed-oauth-token-set',
version: 1,
tokenType: 'Bearer',
accessToken: 'expired-access-token',
refreshToken: 'old-refresh-token',
}),
})
mocks.encryptSecret.mockResolvedValue({ encrypted: 'encrypted-rotated-token-set' })
const refreshToken = vi.fn().mockResolvedValue({
ok: true,
accessToken: 'new-access-token',
refreshToken: 'rotated-refresh-token',
expiresIn: 3600,
})
mocks.getAdapter.mockReturnValue({
getPolicy: vi.fn().mockResolvedValue({
authorizationAppId: row.authorizationAppId,
scopeVersion: 1,
}),
hasRequiredScopes: vi.fn().mockReturnValue(true),
refreshToken,
isTerminalRefreshError: vi.fn().mockReturnValue(false),
})

await expect(resolveManagedOAuthToken(mondayTokenResolutionParams())).resolves.toEqual({
accessToken: 'new-access-token',
refreshed: true,
})

expect(refreshToken).toHaveBeenCalledWith('old-refresh-token')
const [serializedTokenSet] = mocks.encryptSecret.mock.calls[0] as [string]
expect(JSON.parse(serializedTokenSet)).toEqual({
type: 'managed-oauth-token-set',
version: 1,
tokenType: 'Bearer',
accessToken: 'new-access-token',
refreshToken: 'rotated-refresh-token',
})
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({
encryptedOauthTokenSet: 'encrypted-rotated-token-set',
accessTokenExpiresAt: new Date('2026-09-01T13:00:00.000Z'),
lastRefreshedAt: new Date('2026-09-01T12:00:00.000Z'),
})
)
})

it('marks an expired Monday credential for reauthorization after a terminal refresh error', async () => {
const row = mondayCredentialRow()
dbChainMockFns.limit.mockResolvedValueOnce([row]).mockResolvedValueOnce([row])
mocks.decryptSecret.mockResolvedValue({
decrypted: JSON.stringify({
type: 'managed-oauth-token-set',
version: 1,
tokenType: 'Bearer',
accessToken: 'expired-access-token',
refreshToken: 'old-refresh-token',
}),
})
const refreshToken = vi.fn().mockResolvedValue({
ok: false,
errorCode: 'invalid_grant',
message: 'Refresh token rejected',
})
const isTerminalRefreshError = vi.fn().mockReturnValue(true)
mocks.getAdapter.mockReturnValue({
getPolicy: vi.fn().mockResolvedValue({
authorizationAppId: row.authorizationAppId,
scopeVersion: 1,
}),
hasRequiredScopes: vi.fn().mockReturnValue(true),
refreshToken,
isTerminalRefreshError,
})

await expect(resolveManagedOAuthToken(mondayTokenResolutionParams())).rejects.toMatchObject({
code: 'MANAGED_CREDENTIAL_NEEDS_REAUTH',
statusCode: 401,
})

expect(refreshToken).toHaveBeenCalledWith('old-refresh-token')
expect(isTerminalRefreshError).toHaveBeenCalledWith('invalid_grant')
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({ managedOauthStatus: 'needs_reauth' })
)
expect(mocks.encryptSecret).not.toHaveBeenCalled()
})
})
Loading
Loading