diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 5bcec970c87..70d2ec4e50c 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -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 }) }) @@ -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', () => { diff --git a/apps/sim/lib/auth/connectors/managed-oauth.test.ts b/apps/sim/lib/auth/connectors/managed-oauth.test.ts index 329278549c5..6987f1498cb 100644 --- a/apps/sim/lib/auth/connectors/managed-oauth.test.ts +++ b/apps/sim/lib/auth/connectors/managed-oauth.test.ts @@ -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) => { diff --git a/apps/sim/lib/auth/connectors/managed-oauth.ts b/apps/sim/lib/auth/connectors/managed-oauth.ts index 0c6410aa710..d43d9cf56e3 100644 --- a/apps/sim/lib/auth/connectors/managed-oauth.ts +++ b/apps/sim/lib/auth/connectors/managed-oauth.ts @@ -798,8 +798,8 @@ const USER_INFO_MANAGED_OAUTH_CONNECTORS = new Map 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, diff --git a/apps/sim/lib/auth/connectors/providers.ts b/apps/sim/lib/auth/connectors/providers.ts index 435285d952d..314e71b0631 100644 --- a/apps/sim/lib/auth/connectors/providers.ts +++ b/apps/sim/lib/auth/connectors/providers.ts @@ -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' @@ -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' @@ -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 @@ -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: { @@ -1746,10 +1777,15 @@ 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, @@ -1757,16 +1793,33 @@ export function buildConnectorProviders(): GenericOAuthConfig[] { return null } - const data = await response.json() + const data = await readResponseJsonWithLimit(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, } diff --git a/apps/sim/lib/credentials/managed-oauth.test.ts b/apps/sim/lib/credentials/managed-oauth.test.ts index 1df5b3543e7..cc62461af31 100644 --- a/apps/sim/lib/credentials/managed-oauth.test.ts +++ b/apps/sim/lib/credentials/managed-oauth.test.ts @@ -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', () => ({ @@ -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({ @@ -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([ { @@ -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() + }) }) diff --git a/apps/sim/lib/oauth/monday.test.ts b/apps/sim/lib/oauth/monday.test.ts new file mode 100644 index 00000000000..bf12e745e4f --- /dev/null +++ b/apps/sim/lib/oauth/monday.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + exchangeMondayAuthorizationCode, + MONDAY_OAUTH_TOKEN_URL, + resolveMondayAccessTokenExpiresAt, +} from '@/lib/oauth/monday' + +const SCOPES = [ + 'boards:read', + 'boards:write', + 'updates:read', + 'updates:write', + 'webhooks:read', + 'webhooks:write', + 'me:read', +] + +function unsignedJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url') + const body = Buffer.from(JSON.stringify(payload)).toString('base64url') + return `${header}.${body}.signature` +} + +function tokenResponse(overrides: Record = {}): Response { + return new Response( + JSON.stringify({ + access_token: unsignedJwt({ exp: Math.floor(Date.now() / 1000) + 3600 }), + refresh_token: 'monday-refresh-token', + token_type: 'Bearer', + scope: SCOPES.join(' '), + ...overrides, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) +} + +describe('Monday OAuth 2.1', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('exchanges a PKCE authorization code at the v2 endpoint', async () => { + const fetchMock = vi.fn().mockResolvedValue(tokenResponse()) + vi.stubGlobal('fetch', fetchMock) + + const tokens = await exchangeMondayAuthorizationCode({ + clientId: 'monday-client-id', + clientSecret: 'monday-client-secret', + code: 'authorization-code', + codeVerifier: 'pkce-verifier', + redirectUri: 'https://www.sim.ai/api/auth/oauth2/callback/monday', + }) + + expect(tokens).toMatchObject({ + refreshToken: 'monday-refresh-token', + tokenType: 'Bearer', + scopes: SCOPES, + }) + expect(tokens.accessTokenExpiresAt).toBeInstanceOf(Date) + + const [endpoint, request] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(endpoint).toBe(MONDAY_OAUTH_TOKEN_URL) + expect(request).toMatchObject({ + method: 'POST', + redirect: 'error', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }) + expect(JSON.parse(request.body as string)).toEqual({ + grant_type: 'authorization_code', + client_id: 'monday-client-id', + client_secret: 'monday-client-secret', + code: 'authorization-code', + redirect_uri: 'https://www.sim.ai/api/auth/oauth2/callback/monday', + code_verifier: 'pkce-verifier', + }) + }) + + it('uses the access-token JWT expiration before response and fallback lifetimes', () => { + const now = new Date('2026-09-01T12:00:00.000Z') + const jwtExpirySeconds = Math.floor(now.getTime() / 1000) + 2700 + const expiresAt = resolveMondayAccessTokenExpiresAt( + unsignedJwt({ exp: jwtExpirySeconds }), + 1800, + now + ) + + expect(expiresAt).toEqual(new Date(jwtExpirySeconds * 1000)) + }) + + it('preserves an expired JWT expiration so the credential refreshes immediately', () => { + const now = new Date('2026-09-01T12:00:00.000Z') + const jwtExpirySeconds = Math.floor(now.getTime() / 1000) - 60 + + expect( + resolveMondayAccessTokenExpiresAt(unsignedJwt({ exp: jwtExpirySeconds }), 3600, now) + ).toEqual(new Date(jwtExpirySeconds * 1000)) + }) + + it('falls back to expires_in and then one hour for an opaque access token', () => { + const now = new Date('2026-09-01T12:00:00.000Z') + + expect(resolveMondayAccessTokenExpiresAt('opaque-token', 1200, now)).toEqual( + new Date('2026-09-01T12:20:00.000Z') + ) + expect(resolveMondayAccessTokenExpiresAt('opaque-token', undefined, now)).toEqual( + new Date('2026-09-01T13:00:00.000Z') + ) + }) + + it.each([ + ['missing refresh token', { refresh_token: undefined }], + ['missing access token', { access_token: undefined }], + ['non-bearer token', { token_type: 'mac' }], + ])('rejects an incomplete response: %s', async (_label, overrides) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(tokenResponse(overrides))) + + await expect( + exchangeMondayAuthorizationCode({ + clientId: 'client-id', + clientSecret: 'client-secret', + code: 'authorization-code', + codeVerifier: 'pkce-verifier', + redirectUri: 'https://www.sim.ai/api/auth/oauth2/callback/monday', + }) + ).rejects.toThrow('Monday OAuth token response was incomplete') + }) + + it('does not expose a provider error response or request secrets', async () => { + const providerSecret = 'provider-secret-that-must-not-escape' + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: providerSecret }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) + + const error = await exchangeMondayAuthorizationCode({ + clientId: 'client-id', + clientSecret: 'client-secret-that-must-not-escape', + code: 'authorization-code-that-must-not-escape', + codeVerifier: 'pkce-verifier-that-must-not-escape', + redirectUri: 'https://www.sim.ai/api/auth/oauth2/callback/monday', + }).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toBe('Monday OAuth token exchange failed with HTTP 400') + expect((error as Error).message).not.toContain(providerSecret) + }) +}) diff --git a/apps/sim/lib/oauth/monday.ts b/apps/sim/lib/oauth/monday.ts new file mode 100644 index 00000000000..ace87d76433 --- /dev/null +++ b/apps/sim/lib/oauth/monday.ts @@ -0,0 +1,124 @@ +import type { OAuth2Tokens } from 'better-auth/oauth2' +import { decodeJwt } from 'jose' +import { z } from 'zod' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + readResponseJsonWithLimit, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' + +export const MONDAY_OAUTH_AUTHORIZATION_URL = 'https://auth.monday.com/oauth2/authorize' +export const MONDAY_OAUTH_TOKEN_URL = 'https://auth.monday.com/oauth_ms/oauth/token' + +const MONDAY_OAUTH_TOKEN_TIMEOUT_MS = 15_000 +const MONDAY_ACCESS_TOKEN_FALLBACK_LIFETIME_SECONDS = 60 * 60 +const MONDAY_ACCESS_TOKEN_MAX_RESPONSE_LIFETIME_SECONDS = 24 * 60 * 60 + +const mondayOAuthTokenResponseSchema = z.object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1), + token_type: z.string().min(1), + expires_in: z.union([z.number(), z.string()]).optional(), + scope: z.string().optional(), +}) + +interface ExchangeMondayAuthorizationCodeParams { + clientId: string + clientSecret: string + code: string + codeVerifier: string + redirectUri: string +} + +function parsePositiveLifetimeSeconds(value: unknown): number | undefined { + const parsed = typeof value === 'number' || typeof value === 'string' ? Number(value) : Number.NaN + return Number.isFinite(parsed) && + parsed > 0 && + parsed <= MONDAY_ACCESS_TOKEN_MAX_RESPONSE_LIFETIME_SECONDS + ? parsed + : undefined +} + +/** + * Resolves monday.com's access-token expiry for storage and refresh scheduling. + * + * OAuth 2.1 access tokens are JWTs and monday.com documents the `exp` claim as + * authoritative. The response lifetime and one-hour documented default keep + * credentials refreshable if a deployment temporarily receives an opaque token. + */ +export function resolveMondayAccessTokenExpiresAt( + accessToken: string, + expiresIn?: unknown, + now = new Date() +): Date { + try { + const { exp } = decodeJwt(accessToken) + if (typeof exp === 'number' && Number.isFinite(exp)) { + const expiresAt = new Date(exp * 1000) + if (!Number.isNaN(expiresAt.getTime())) return expiresAt + } + } catch {} + + const lifetimeSeconds = + parsePositiveLifetimeSeconds(expiresIn) ?? MONDAY_ACCESS_TOKEN_FALLBACK_LIFETIME_SECONDS + return new Date(now.getTime() + lifetimeSeconds * 1000) +} + +/** Exchanges a monday.com OAuth 2.1 authorization code without exposing token material. */ +export async function exchangeMondayAuthorizationCode({ + clientId, + clientSecret, + code, + codeVerifier, + redirectUri, +}: ExchangeMondayAuthorizationCodeParams): Promise { + const signal = AbortSignal.timeout(MONDAY_OAUTH_TOKEN_TIMEOUT_MS) + const response = await fetch(MONDAY_OAUTH_TOKEN_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + grant_type: 'authorization_code', + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + redirect: 'error', + signal, + }) + if (!response.ok) { + await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Monday OAuth token error response', + signal, + }).catch(() => {}) + throw new Error(`Monday OAuth token exchange failed with HTTP ${response.status}`) + } + + const payload = await readResponseJsonWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Monday OAuth token response', + signal, + }) + + const parsed = mondayOAuthTokenResponseSchema.safeParse(payload) + if (!parsed.success || parsed.data.token_type.toLowerCase() !== 'bearer') { + throw new Error('Monday OAuth token response was incomplete') + } + + const scopes = parsed.data.scope?.split(/\s+/).filter(Boolean) + return { + accessToken: parsed.data.access_token, + refreshToken: parsed.data.refresh_token, + tokenType: parsed.data.token_type, + accessTokenExpiresAt: resolveMondayAccessTokenExpiresAt( + parsed.data.access_token, + parsed.data.expires_in + ), + ...(scopes ? { scopes } : {}), + } +} diff --git a/apps/sim/lib/oauth/oauth.test.ts b/apps/sim/lib/oauth/oauth.test.ts index 3aadd1da130..d5411450953 100644 --- a/apps/sim/lib/oauth/oauth.test.ts +++ b/apps/sim/lib/oauth/oauth.test.ts @@ -1,5 +1,5 @@ import { createMockFetch, resetEnvMock, setEnv } from '@sim/testing' -import { getOAuth2Tokens } from 'better-auth/oauth2' +import { createAuthorizationURL, getOAuth2Tokens } from 'better-auth/oauth2' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' beforeAll(() => { @@ -50,7 +50,7 @@ beforeAll(() => { SALESFORCE_CLIENT_ID: 'salesforce_client_id', SALESFORCE_CLIENT_SECRET: 'salesforce_client_secret', ZOHO_CLIENT_ID: 'zoho_client_id', - ZOHO_CLIENT_SECRET: 'zoho_client_secret', + ZOHO_CLIENT_SECRET: undefined, SHOPIFY_CLIENT_ID: 'shopify_client_id', SHOPIFY_CLIENT_SECRET: 'shopify_client_secret', ZOOM_CLIENT_ID: 'zoom_client_id', @@ -61,7 +61,7 @@ beforeAll(() => { SPOTIFY_CLIENT_SECRET: 'spotify_client_secret', CALCOM_CLIENT_ID: 'calcom_client_id', MONDAY_CLIENT_ID: 'monday_client_id', - MONDAY_CLIENT_SECRET: undefined, + MONDAY_CLIENT_SECRET: 'monday_client_secret', }) }) @@ -93,6 +93,12 @@ const defaultOAuthResponse = { }, } +function oauthTestJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url') + const body = Buffer.from(JSON.stringify(payload)).toString('base64url') + return `${header}.${body}.signature` +} + /** * Helper to run a function with a mocked global fetch. */ @@ -146,6 +152,74 @@ describe('Atlassian OAuth connectors', () => { ) }) +function getMondayConnector() { + const connector = buildConnectorProviders().find((candidate) => candidate.providerId === 'monday') + if (!connector) throw new Error('Monday OAuth connector is not configured in this test') + return connector +} + +describe('Monday OAuth connector', () => { + it('generates the OAuth 2.1 authorization request from the connector contract', async () => { + const connector = getMondayConnector() + expect(connector).toMatchObject({ + providerId: 'monday', + authorizationUrl: 'https://auth.monday.com/oauth2/authorize', + tokenUrl: 'https://auth.monday.com/oauth_ms/oauth/token', + scopes: [ + 'boards:read', + 'boards:write', + 'updates:read', + 'updates:write', + 'webhooks:read', + 'webhooks:write', + 'me:read', + ], + responseType: 'code', + pkce: true, + authentication: 'post', + redirectURI: 'http://localhost:3000/api/auth/oauth2/callback/monday', + }) + const authorizationUrl = await createAuthorizationURL({ + id: connector.providerId, + options: { + clientId: connector.clientId, + clientSecret: connector.clientSecret, + redirectURI: connector.redirectURI, + }, + authorizationEndpoint: connector.authorizationUrl!, + state: 'state-1', + codeVerifier: 'a'.repeat(128), + scopes: connector.scopes, + redirectURI: connector.redirectURI!, + responseType: connector.responseType, + }) + + expect(authorizationUrl.searchParams.get('redirect_uri')).toBe( + 'http://localhost:3000/api/auth/oauth2/callback/monday' + ) + expect(authorizationUrl.searchParams.get('scope')).toBe(connector.scopes?.join(' ')) + expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256') + expect(authorizationUrl.searchParams.get('code_challenge')).toBeTruthy() + }) + + it('rejects GraphQL errors returned with HTTP 200 during user-info lookup', async () => { + const getUserInfo = getMondayConnector().getUserInfo + if (!getUserInfo) throw new Error('Monday OAuth connector must define getUserInfo') + + const userInfo = await withMockFetch( + createMockFetch({ + json: { + data: { me: { id: 'user-1', name: 'Person', email: 'person@example.com' } }, + errors: [{ message: 'Permission denied' }], + }, + }), + () => getUserInfo({ accessToken: 'access-token' }) + ) + + expect(userInfo).toBeNull() + }) +}) + describe('Microsoft Dataverse OAuth connector', () => { it('keeps static connector scopes empty and supplies the canonical legacy grant per request', () => { const connector = buildConnectorProviders().find( @@ -645,13 +719,13 @@ describe('OAuth Token Refresh', () => { const mockFetch = createMockFetch(defaultOAuthResponse) const result = await withMockFetch(mockFetch, () => - refreshOAuthToken('monday', 'test_refresh_token') + refreshOAuthToken('zoho-desk', 'test_refresh_token') ) expect(result).toEqual({ ok: false, message: - 'OAuth client monday is partially configured — missing MONDAY_CLIENT_SECRET. Run npx sim-setup add integration monday.', + 'OAuth client zoho-desk is partially configured — missing ZOHO_CLIENT_SECRET. Run npx sim-setup add integration zoho-desk.', }) expect(mockFetch).not.toHaveBeenCalled() }) @@ -827,6 +901,59 @@ describe('OAuth Token Refresh', () => { }) }) + it.concurrent('refreshes Monday with JSON body credentials and rotates its token', async () => { + const expiresAtSeconds = Math.floor(Date.now() / 1000) + 2700 + const mockFetch = createMockFetch({ + json: { + access_token: oauthTestJwt({ exp: expiresAtSeconds }), + refresh_token: 'rotated-monday-refresh-token', + token_type: 'Bearer', + scope: 'boards:read me:read', + }, + }) + + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('monday', 'old-monday-refresh-token') + ) + + expect(result).toMatchObject({ + ok: true, + refreshToken: 'rotated-monday-refresh-token', + }) + if (result.ok) { + expect(result.expiresIn).toBeGreaterThanOrEqual(2699) + expect(result.expiresIn).toBeLessThanOrEqual(2700) + } + + const [endpoint, request] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(endpoint).toBe('https://auth.monday.com/oauth_ms/oauth/token') + expect(request.headers).toMatchObject({ 'Content-Type': 'application/json' }) + expect(JSON.parse(request.body as string)).toEqual({ + grant_type: 'refresh_token', + refresh_token: 'old-monday-refresh-token', + client_id: 'monday_client_id', + client_secret: 'monday_client_secret', + }) + }) + + it.concurrent('rejects a Monday refresh response that omits token rotation', async () => { + const mockFetch = createMockFetch({ + json: { + access_token: oauthTestJwt({ exp: Math.floor(Date.now() / 1000) + 3600 }), + token_type: 'Bearer', + }, + }) + + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('monday', 'old-monday-refresh-token') + ) + + expect(result).toEqual({ + ok: false, + message: 'Invalid Monday token refresh response', + }) + }) + it.concurrent('should return Bitbucket rotating refresh tokens', async () => { const mockFetch = createMockFetch({ json: { diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index a8d07519aaa..b77cc07bd95 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -78,6 +78,7 @@ import { } from '@/lib/core/utils/stream-limits' import { getDocusignOAuthUrl } from '@/lib/oauth/docusign' import { parseInstagramLongLivedToken } from '@/lib/oauth/instagram' +import { MONDAY_OAUTH_TOKEN_URL, resolveMondayAccessTokenExpiresAt } from '@/lib/oauth/monday' import { SALESFORCE_ADDITIONAL_PROVIDER_IDS, SALESFORCE_LOGIN_HOSTS, @@ -1891,11 +1892,12 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { 'MONDAY_CLIENT_SECRET' ) return { - tokenEndpoint: 'https://auth.monday.com/oauth2/token', + tokenEndpoint: MONDAY_OAUTH_TOKEN_URL, clientId, clientSecret, useBasicAuth: false, - supportsRefreshTokenRotation: false, + useJsonBody: true, + supportsRefreshTokenRotation: true, } } case 'zoho-desk': { @@ -2206,14 +2208,29 @@ export async function refreshOAuthToken( newRefreshToken = data.refresh_token logger.info(`Received new refresh token from ${provider}`) } + if (provider === 'monday' && !newRefreshToken) { + logger.warn('Monday token refresh response omitted its rotating refresh token') + return { ok: false, message: 'Invalid Monday token refresh response' } + } const rawExpiresIn = data.expires_in ?? data.expiresIn const parsedExpiresIn = typeof rawExpiresIn === 'number' || typeof rawExpiresIn === 'string' ? Number(rawExpiresIn) : Number.NaN + const responseExpiresIn = + Number.isFinite(parsedExpiresIn) && parsedExpiresIn > 0 ? parsedExpiresIn : undefined const expiresIn = - Number.isFinite(parsedExpiresIn) && parsedExpiresIn > 0 ? parsedExpiresIn : 3600 + provider === 'monday' && accessToken + ? Math.max( + 1, + Math.ceil( + (resolveMondayAccessTokenExpiresAt(accessToken, responseExpiresIn).getTime() - + Date.now()) / + 1000 + ) + ) + : (responseExpiresIn ?? 3600) if (!accessToken) { // Log only the shape, never `data` itself - on a partial success it can