From 35c688393e4d3a9b2c04eaac2e4ec1a7110ae240 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 1 Sep 2026 19:40:49 -0700 Subject: [PATCH 1/2] fix(monday): support OAuth 2.1 --- apps/sim/app/api/auth/oauth/utils.test.ts | 44 +++++ .../lib/auth/connectors/managed-oauth.test.ts | 8 + apps/sim/lib/auth/connectors/managed-oauth.ts | 4 +- apps/sim/lib/auth/connectors/providers.ts | 73 ++++++-- .../standard-oauth-provider.test.ts | 103 +++++++++++ apps/sim/lib/oauth/monday.test.ts | 167 ++++++++++++++++++ apps/sim/lib/oauth/monday.ts | 124 +++++++++++++ apps/sim/lib/oauth/oauth.test.ts | 140 ++++++++++++++- apps/sim/lib/oauth/oauth.ts | 23 ++- 9 files changed, 666 insertions(+), 20 deletions(-) create mode 100644 apps/sim/lib/oauth/monday.test.ts create mode 100644 apps/sim/lib/oauth/monday.ts diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 5bcec970c87..d213877331e 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -151,6 +151,35 @@ describe('OAuth Utils', () => { expect(result).toEqual({ accessToken: 'new-token', refreshed: true }) }) + it('persists a rotated Monday refresh token with the refreshed access token', async () => { + const credential = { + id: 'monday-credential-id', + accessToken: 'expired-monday-token', + refreshToken: 'old-monday-refresh-token', + accessTokenExpiresAt: new Date(Date.now() - 60_000), + providerId: 'monday', + } + mockRefreshOAuthToken.mockResolvedValueOnce({ + ok: true, + accessToken: 'new-monday-token', + expiresIn: 3600, + refreshToken: 'rotated-monday-refresh-token', + }) + const { mockSet } = mockUpdateChain() + + const result = await refreshTokenIfNeeded('request-id', credential, credential.id) + + expect(mockRefreshOAuthToken).toHaveBeenCalledWith('monday', 'old-monday-refresh-token') + expect(mockSet).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: 'new-monday-token', + refreshToken: 'rotated-monday-refresh-token', + accessTokenExpiresAt: expect.any(Date), + }) + ) + expect(result).toEqual({ accessToken: 'new-monday-token', refreshed: true }) + }) + it('should handle refresh token error', async () => { const mockCredential = { id: 'credential-id', @@ -185,6 +214,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/credential-groups/standard-oauth-provider.test.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts index 0e40b14acc1..2e043cd9838 100644 --- a/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts @@ -44,6 +44,32 @@ vi.mock('@/lib/auth/connectors/managed-oauth', () => ({ }, } } + if (providerId === 'monday') { + return { + providerId, + clientId: 'monday-client-1', + clientSecret: 'monday-secret-1', + authorizationUrl: 'https://auth.monday.com/oauth2/authorize', + tokenUrl: 'https://auth.monday.com/oauth_ms/oauth/token', + redirectURI: 'https://sim.example.com/api/auth/oauth2/callback/monday', + scopes: ['boards:read', 'me:read'], + responseType: 'code', + authentication: 'post', + getToken: mockGetToken, + managedOAuth: { + additionalScopes: [], + requiresRefreshToken: true, + pkce: true, + nonceVerification: 'state_only', + includeLoginHint: false, + getAuthorizationAppId: (clientId: string) => `monday:${clientId}`, + verifyIdentity: mockVerifyIdentity, + hasRequiredScopes: (granted: string[], required: string[]) => + required.every((scope) => granted.includes(scope)), + isTerminalRefreshError: (errorCode: string | undefined) => errorCode === 'invalid_grant', + }, + } + } if (providerId === 'jira') { return { providerId, @@ -82,6 +108,7 @@ import { createStandardOAuthCredentialGroupProviderAdapter } from '@/lib/credent const adapter = createStandardOAuthCredentialGroupProviderAdapter('google-calendar') const jiraAdapter = createStandardOAuthCredentialGroupProviderAdapter('jira') +const mondayAdapter = createStandardOAuthCredentialGroupProviderAdapter('monday') function buildContext(): CredentialGroupOAuthContext { return { @@ -210,6 +237,82 @@ describe('standard OAuth Credential Group provider', () => { }) }) + it('uses PKCE and persists expiring rotating credentials for managed Monday OAuth', async () => { + const requiredScopes = ['boards:read', 'me:read'] + const context: CredentialGroupOAuthContext = { + ...buildContext(), + option: { + ...buildContext().option, + provider: 'monday', + label: 'Monday.com', + authorizationAppId: 'monday:monday-client-1', + requiredScopes, + }, + } + const policy = await mondayAdapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + const prepared = await mondayAdapter.prepareAuthorization(context, policy) + const authorizationUrl = new URL( + await prepared.buildAuthorizationUrl({ state: 'monday-state-1', nonce: 'nonce-ignored' }) + ) + + expect(mondayAdapter.requiresRefreshToken).toBe(true) + expect(prepared.codeVerifier).toHaveLength(86) + expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256') + expect(authorizationUrl.searchParams.get('code_challenge')).toBeTruthy() + + const accessTokenExpiresAt = new Date('2026-08-14T01:00:00Z') + mockGetToken.mockResolvedValueOnce({ + tokenType: 'Bearer', + accessToken: 'monday-access-1', + refreshToken: 'monday-refresh-1', + accessTokenExpiresAt, + scopes: requiredScopes, + }) + mockVerifyIdentity.mockResolvedValueOnce({ + providerSubjectId: 'monday-user-1', + providerTenantId: null, + email: 'person@example.com', + emailVerified: true, + grantedScopes: requiredScopes, + }) + + const grant = await mondayAdapter.exchangeAndVerify({ + context, + attempt: { + state: 'monday-state-1', + provider: 'monday', + nonceHash: 'unused-for-state-bound-provider', + enrollmentId: context.enrollmentId, + credentialGroupId: context.credentialGroupId, + optionId: context.option.id, + authorizationAppId: policy.authorizationAppId, + scopeVersion: policy.scopeVersion, + requiredScopes, + redirectUri: prepared.redirectUri, + codeVerifier: prepared.codeVerifier, + invitationToken: 'invitation-1', + createdAt: Date.now(), + }, + code: 'monday-code-1', + policy, + }) + + expect(mockGetToken).toHaveBeenLastCalledWith({ + code: 'monday-code-1', + redirectURI: 'https://sim.example.com/api/auth/oauth2/callback/monday', + codeVerifier: prepared.codeVerifier, + }) + expect(grant).toMatchObject({ + accessToken: 'monday-access-1', + refreshToken: 'monday-refresh-1', + accessTokenExpiresAt, + grantedScopes: requiredScopes, + }) + }) + it('rejects a different invited email', async () => { mockVerifyIdentity.mockResolvedValueOnce({ providerSubjectId: 'google-sub-2', diff --git a/apps/sim/lib/oauth/monday.test.ts b/apps/sim/lib/oauth/monday.test.ts new file mode 100644 index 00000000000..029992d1024 --- /dev/null +++ b/apps/sim/lib/oauth/monday.test.ts @@ -0,0 +1,167 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DEFAULT_MAX_ERROR_BODY_BYTES } from '@/lib/core/utils/stream-limits' +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('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) + }) + + it('bounds the token endpoint response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('x'.repeat(DEFAULT_MAX_ERROR_BODY_BYTES + 1))) + ) + + 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('exceeds maximum size') + }) +}) diff --git a/apps/sim/lib/oauth/monday.ts b/apps/sim/lib/oauth/monday.ts new file mode 100644 index 00000000000..9c0fda617b3 --- /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) && exp * 1000 > now.getTime()) { + 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..eed633539ee 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,77 @@ 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('uses the OAuth 2.1 token endpoint, PKCE, and canonical callback and scopes', () => { + expect(getMondayConnector()).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', + }) + }) + + it('generates an S256 authorization request with the exact callback and scopes', async () => { + const connector = getMondayConnector() + 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 +722,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 +904,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 From 080818dfa010da2fef991e7c5d2d8a5bda69c812 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 1 Sep 2026 19:57:46 -0700 Subject: [PATCH 2/2] fix(monday): address OAuth review feedback --- apps/sim/app/api/auth/oauth/utils.test.ts | 31 +--- .../standard-oauth-provider.test.ts | 103 -------------- .../sim/lib/credentials/managed-oauth.test.ts | 132 +++++++++++++++++- apps/sim/lib/oauth/monday.test.ts | 27 ++-- apps/sim/lib/oauth/monday.ts | 2 +- apps/sim/lib/oauth/oauth.test.ts | 9 +- 6 files changed, 147 insertions(+), 157 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index d213877331e..70d2ec4e50c 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -142,42 +142,19 @@ 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(result).toEqual({ accessToken: 'new-token', refreshed: true }) - }) - - it('persists a rotated Monday refresh token with the refreshed access token', async () => { - const credential = { - id: 'monday-credential-id', - accessToken: 'expired-monday-token', - refreshToken: 'old-monday-refresh-token', - accessTokenExpiresAt: new Date(Date.now() - 60_000), - providerId: 'monday', - } - mockRefreshOAuthToken.mockResolvedValueOnce({ - ok: true, - accessToken: 'new-monday-token', - expiresIn: 3600, - refreshToken: 'rotated-monday-refresh-token', - }) - const { mockSet } = mockUpdateChain() - - const result = await refreshTokenIfNeeded('request-id', credential, credential.id) - - expect(mockRefreshOAuthToken).toHaveBeenCalledWith('monday', 'old-monday-refresh-token') expect(mockSet).toHaveBeenCalledWith( expect.objectContaining({ - accessToken: 'new-monday-token', - refreshToken: 'rotated-monday-refresh-token', + accessToken: 'new-token', + refreshToken: 'new-refresh-token', accessTokenExpiresAt: expect.any(Date), }) ) - expect(result).toEqual({ accessToken: 'new-monday-token', refreshed: true }) + expect(result).toEqual({ accessToken: 'new-token', refreshed: true }) }) it('should handle refresh token error', async () => { diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts index 2e043cd9838..0e40b14acc1 100644 --- a/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts @@ -44,32 +44,6 @@ vi.mock('@/lib/auth/connectors/managed-oauth', () => ({ }, } } - if (providerId === 'monday') { - return { - providerId, - clientId: 'monday-client-1', - clientSecret: 'monday-secret-1', - authorizationUrl: 'https://auth.monday.com/oauth2/authorize', - tokenUrl: 'https://auth.monday.com/oauth_ms/oauth/token', - redirectURI: 'https://sim.example.com/api/auth/oauth2/callback/monday', - scopes: ['boards:read', 'me:read'], - responseType: 'code', - authentication: 'post', - getToken: mockGetToken, - managedOAuth: { - additionalScopes: [], - requiresRefreshToken: true, - pkce: true, - nonceVerification: 'state_only', - includeLoginHint: false, - getAuthorizationAppId: (clientId: string) => `monday:${clientId}`, - verifyIdentity: mockVerifyIdentity, - hasRequiredScopes: (granted: string[], required: string[]) => - required.every((scope) => granted.includes(scope)), - isTerminalRefreshError: (errorCode: string | undefined) => errorCode === 'invalid_grant', - }, - } - } if (providerId === 'jira') { return { providerId, @@ -108,7 +82,6 @@ import { createStandardOAuthCredentialGroupProviderAdapter } from '@/lib/credent const adapter = createStandardOAuthCredentialGroupProviderAdapter('google-calendar') const jiraAdapter = createStandardOAuthCredentialGroupProviderAdapter('jira') -const mondayAdapter = createStandardOAuthCredentialGroupProviderAdapter('monday') function buildContext(): CredentialGroupOAuthContext { return { @@ -237,82 +210,6 @@ describe('standard OAuth Credential Group provider', () => { }) }) - it('uses PKCE and persists expiring rotating credentials for managed Monday OAuth', async () => { - const requiredScopes = ['boards:read', 'me:read'] - const context: CredentialGroupOAuthContext = { - ...buildContext(), - option: { - ...buildContext().option, - provider: 'monday', - label: 'Monday.com', - authorizationAppId: 'monday:monday-client-1', - requiredScopes, - }, - } - const policy = await mondayAdapter.getPolicy(context.option, { - workspaceId: context.workspaceId, - credentialGroupId: context.credentialGroupId, - }) - const prepared = await mondayAdapter.prepareAuthorization(context, policy) - const authorizationUrl = new URL( - await prepared.buildAuthorizationUrl({ state: 'monday-state-1', nonce: 'nonce-ignored' }) - ) - - expect(mondayAdapter.requiresRefreshToken).toBe(true) - expect(prepared.codeVerifier).toHaveLength(86) - expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256') - expect(authorizationUrl.searchParams.get('code_challenge')).toBeTruthy() - - const accessTokenExpiresAt = new Date('2026-08-14T01:00:00Z') - mockGetToken.mockResolvedValueOnce({ - tokenType: 'Bearer', - accessToken: 'monday-access-1', - refreshToken: 'monday-refresh-1', - accessTokenExpiresAt, - scopes: requiredScopes, - }) - mockVerifyIdentity.mockResolvedValueOnce({ - providerSubjectId: 'monday-user-1', - providerTenantId: null, - email: 'person@example.com', - emailVerified: true, - grantedScopes: requiredScopes, - }) - - const grant = await mondayAdapter.exchangeAndVerify({ - context, - attempt: { - state: 'monday-state-1', - provider: 'monday', - nonceHash: 'unused-for-state-bound-provider', - enrollmentId: context.enrollmentId, - credentialGroupId: context.credentialGroupId, - optionId: context.option.id, - authorizationAppId: policy.authorizationAppId, - scopeVersion: policy.scopeVersion, - requiredScopes, - redirectUri: prepared.redirectUri, - codeVerifier: prepared.codeVerifier, - invitationToken: 'invitation-1', - createdAt: Date.now(), - }, - code: 'monday-code-1', - policy, - }) - - expect(mockGetToken).toHaveBeenLastCalledWith({ - code: 'monday-code-1', - redirectURI: 'https://sim.example.com/api/auth/oauth2/callback/monday', - codeVerifier: prepared.codeVerifier, - }) - expect(grant).toMatchObject({ - accessToken: 'monday-access-1', - refreshToken: 'monday-refresh-1', - accessTokenExpiresAt, - grantedScopes: requiredScopes, - }) - }) - it('rejects a different invited email', async () => { mockVerifyIdentity.mockResolvedValueOnce({ providerSubjectId: 'google-sub-2', 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 index 029992d1024..bf12e745e4f 100644 --- a/apps/sim/lib/oauth/monday.test.ts +++ b/apps/sim/lib/oauth/monday.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { DEFAULT_MAX_ERROR_BODY_BYTES } from '@/lib/core/utils/stream-limits' import { exchangeMondayAuthorizationCode, MONDAY_OAUTH_TOKEN_URL, @@ -94,6 +93,15 @@ describe('Monday OAuth 2.1', () => { 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') @@ -147,21 +155,4 @@ describe('Monday OAuth 2.1', () => { expect((error as Error).message).toBe('Monday OAuth token exchange failed with HTTP 400') expect((error as Error).message).not.toContain(providerSecret) }) - - it('bounds the token endpoint response', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue(new Response('x'.repeat(DEFAULT_MAX_ERROR_BODY_BYTES + 1))) - ) - - 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('exceeds maximum size') - }) }) diff --git a/apps/sim/lib/oauth/monday.ts b/apps/sim/lib/oauth/monday.ts index 9c0fda617b3..ace87d76433 100644 --- a/apps/sim/lib/oauth/monday.ts +++ b/apps/sim/lib/oauth/monday.ts @@ -53,7 +53,7 @@ export function resolveMondayAccessTokenExpiresAt( ): Date { try { const { exp } = decodeJwt(accessToken) - if (typeof exp === 'number' && Number.isFinite(exp) && exp * 1000 > now.getTime()) { + if (typeof exp === 'number' && Number.isFinite(exp)) { const expiresAt = new Date(exp * 1000) if (!Number.isNaN(expiresAt.getTime())) return expiresAt } diff --git a/apps/sim/lib/oauth/oauth.test.ts b/apps/sim/lib/oauth/oauth.test.ts index eed633539ee..d5411450953 100644 --- a/apps/sim/lib/oauth/oauth.test.ts +++ b/apps/sim/lib/oauth/oauth.test.ts @@ -159,8 +159,9 @@ function getMondayConnector() { } describe('Monday OAuth connector', () => { - it('uses the OAuth 2.1 token endpoint, PKCE, and canonical callback and scopes', () => { - expect(getMondayConnector()).toMatchObject({ + 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', @@ -178,10 +179,6 @@ describe('Monday OAuth connector', () => { authentication: 'post', redirectURI: 'http://localhost:3000/api/auth/oauth2/callback/monday', }) - }) - - it('generates an S256 authorization request with the exact callback and scopes', async () => { - const connector = getMondayConnector() const authorizationUrl = await createAuthorizationURL({ id: connector.providerId, options: {