Skip to content

Commit 35c6883

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(monday): support OAuth 2.1
1 parent 51d2118 commit 35c6883

9 files changed

Lines changed: 666 additions & 20 deletions

File tree

apps/sim/app/api/auth/oauth/utils.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,35 @@ describe('OAuth Utils', () => {
151151
expect(result).toEqual({ accessToken: 'new-token', refreshed: true })
152152
})
153153

154+
it('persists a rotated Monday refresh token with the refreshed access token', async () => {
155+
const credential = {
156+
id: 'monday-credential-id',
157+
accessToken: 'expired-monday-token',
158+
refreshToken: 'old-monday-refresh-token',
159+
accessTokenExpiresAt: new Date(Date.now() - 60_000),
160+
providerId: 'monday',
161+
}
162+
mockRefreshOAuthToken.mockResolvedValueOnce({
163+
ok: true,
164+
accessToken: 'new-monday-token',
165+
expiresIn: 3600,
166+
refreshToken: 'rotated-monday-refresh-token',
167+
})
168+
const { mockSet } = mockUpdateChain()
169+
170+
const result = await refreshTokenIfNeeded('request-id', credential, credential.id)
171+
172+
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('monday', 'old-monday-refresh-token')
173+
expect(mockSet).toHaveBeenCalledWith(
174+
expect.objectContaining({
175+
accessToken: 'new-monday-token',
176+
refreshToken: 'rotated-monday-refresh-token',
177+
accessTokenExpiresAt: expect.any(Date),
178+
})
179+
)
180+
expect(result).toEqual({ accessToken: 'new-monday-token', refreshed: true })
181+
})
182+
154183
it('should handle refresh token error', async () => {
155184
const mockCredential = {
156185
id: 'credential-id',
@@ -185,6 +214,21 @@ describe('OAuth Utils', () => {
185214
expect(mockRefreshOAuthToken).not.toHaveBeenCalled()
186215
expect(result).toEqual({ accessToken: 'token', refreshed: false })
187216
})
217+
218+
it('keeps a legacy non-expiring Monday credential usable without refreshing it', async () => {
219+
const legacyCredential = {
220+
id: 'legacy-monday-credential-id',
221+
accessToken: 'legacy-monday-access-token',
222+
refreshToken: null,
223+
accessTokenExpiresAt: null,
224+
providerId: 'monday',
225+
}
226+
227+
const result = await refreshTokenIfNeeded('request-id', legacyCredential, legacyCredential.id)
228+
229+
expect(mockRefreshOAuthToken).not.toHaveBeenCalled()
230+
expect(result).toEqual({ accessToken: 'legacy-monday-access-token', refreshed: false })
231+
})
188232
})
189233

190234
describe('refreshAccessTokenIfNeeded', () => {

apps/sim/lib/auth/connectors/managed-oauth.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,14 @@ describe('userinfo-backed managed OAuth connectors', () => {
296296
}
297297
)
298298

299+
it('requires PKCE and refresh-token persistence for Monday OAuth 2.1', () => {
300+
expect(policyFor('monday')).toMatchObject({
301+
pkce: true,
302+
requiresRefreshToken: true,
303+
nonceVerification: 'state_only',
304+
})
305+
})
306+
299307
it.each(['linear', 'monday'])(
300308
'treats a partial %s GraphQL response as no identity at all',
301309
async (providerId) => {

apps/sim/lib/auth/connectors/managed-oauth.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -798,8 +798,8 @@ const USER_INFO_MANAGED_OAUTH_CONNECTORS = new Map<string, () => ManagedOAuthCon
798798
() =>
799799
createUserInfoManagedOAuthConnector({
800800
providerId: 'monday',
801-
/** monday.com access tokens do not expire and no refresh token is issued. */
802-
requiresRefreshToken: false,
801+
requiresRefreshToken: true,
802+
pkce: true,
803803
scopes: { from: 'token_response' },
804804
userInfo: {
805805
url: MONDAY_API_URL,

apps/sim/lib/auth/connectors/providers.ts

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { syntheticConnectorEmail } from '@/lib/auth/connector-email'
1010
import { env } from '@/lib/core/config/env'
1111
import { inspectConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
1212
import {
13+
DEFAULT_MAX_ERROR_BODY_BYTES,
1314
readResponseJsonWithLimit,
1415
readResponseTextWithLimit,
1516
} from '@/lib/core/utils/stream-limits'
@@ -22,6 +23,11 @@ import {
2223
getBoundMicrosoftDataverseEnvironment,
2324
resolveMicrosoftDataverseOAuthCallbackScopes,
2425
} from '@/lib/oauth/microsoft-dataverse'
26+
import {
27+
exchangeMondayAuthorizationCode,
28+
MONDAY_OAUTH_AUTHORIZATION_URL,
29+
MONDAY_OAUTH_TOKEN_URL,
30+
} from '@/lib/oauth/monday'
2531
import { SALESFORCE_LOGIN_HOSTS } from '@/lib/oauth/salesforce'
2632
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
2733
import { MONDAY_API_URL, MONDAY_API_VERSION } from '@/tools/monday/utils'
@@ -86,6 +92,17 @@ interface AttioWorkspaceMemberResponse {
8692
}
8793
}
8894

95+
interface MondayUserInfoResponse {
96+
data?: {
97+
me?: {
98+
id?: string | number
99+
name?: string | null
100+
email?: string | null
101+
} | null
102+
}
103+
errors?: unknown[]
104+
}
105+
89106
/**
90107
* Shape of `GET https://api.bitbucket.org/2.0/user` for the authenticated user.
91108
* @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-users/#api-user-get
@@ -1729,15 +1746,29 @@ export function buildConnectorProviders(): GenericOAuthConfig[] {
17291746
providerId: 'monday',
17301747
clientId: env.MONDAY_CLIENT_ID as string,
17311748
clientSecret: env.MONDAY_CLIENT_SECRET as string,
1732-
authorizationUrl: 'https://auth.monday.com/oauth2/authorize',
1733-
tokenUrl: 'https://auth.monday.com/oauth2/token',
1749+
authorizationUrl: MONDAY_OAUTH_AUTHORIZATION_URL,
1750+
tokenUrl: MONDAY_OAUTH_TOKEN_URL,
17341751
userInfoUrl: 'https://api.monday.com/v2',
17351752
scopes: getCanonicalScopesForProvider('monday'),
17361753
responseType: 'code',
1737-
pkce: false,
1754+
pkce: true,
1755+
authentication: 'post',
17381756
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/monday`,
1757+
getToken: async ({ code, codeVerifier, redirectURI }) => {
1758+
if (!codeVerifier) {
1759+
throw new Error('Monday OAuth token exchange requires a PKCE verifier')
1760+
}
1761+
return exchangeMondayAuthorizationCode({
1762+
clientId: env.MONDAY_CLIENT_ID as string,
1763+
clientSecret: env.MONDAY_CLIENT_SECRET as string,
1764+
code,
1765+
codeVerifier,
1766+
redirectUri: redirectURI,
1767+
})
1768+
},
17391769
getUserInfo: async (tokens) => {
17401770
try {
1771+
const signal = AbortSignal.timeout(15_000)
17411772
const response = await fetch(MONDAY_API_URL, {
17421773
method: 'POST',
17431774
headers: {
@@ -1746,27 +1777,49 @@ export function buildConnectorProviders(): GenericOAuthConfig[] {
17461777
Authorization: tokens.accessToken ?? '',
17471778
},
17481779
body: JSON.stringify({ query: '{ me { id name email } }' }),
1780+
signal,
17491781
})
17501782

17511783
if (!response.ok) {
1752-
await response.text().catch(() => {})
1784+
await readResponseTextWithLimit(response, {
1785+
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
1786+
label: 'Monday OAuth user info error response',
1787+
signal,
1788+
}).catch(() => {})
17531789
logger.error('Error fetching Monday.com user info:', {
17541790
status: response.status,
17551791
statusText: response.statusText,
17561792
})
17571793
return null
17581794
}
17591795

1760-
const data = await response.json()
1796+
const data = await readResponseJsonWithLimit<MondayUserInfoResponse>(response, {
1797+
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
1798+
label: 'Monday OAuth user info response',
1799+
signal,
1800+
})
1801+
if (data.errors?.length) {
1802+
logger.error('Monday.com user info returned GraphQL errors', {
1803+
errorCount: data.errors.length,
1804+
})
1805+
return null
1806+
}
17611807
const user = data.data?.me
1762-
if (!user) return null
1808+
const userId =
1809+
typeof user?.id === 'string' || typeof user?.id === 'number'
1810+
? String(user.id)
1811+
: undefined
1812+
if (!user || !userId) return null
1813+
1814+
const email = typeof user.email === 'string' ? user.email : undefined
1815+
const name = typeof user.name === 'string' ? user.name : undefined
17631816

17641817
const now = new Date()
17651818
return {
1766-
id: `${user.id.toString()}-${generateId()}`,
1767-
name: user.name || 'Monday.com User',
1768-
email: user.email || syntheticConnectorEmail('monday', user.id),
1769-
emailVerified: !!user.email,
1819+
id: `${userId}-${generateId()}`,
1820+
name: name || 'Monday.com User',
1821+
email: email || syntheticConnectorEmail('monday', userId),
1822+
emailVerified: !!email,
17701823
createdAt: now,
17711824
updatedAt: now,
17721825
}

apps/sim/lib/credential-groups/standard-oauth-provider.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,32 @@ vi.mock('@/lib/auth/connectors/managed-oauth', () => ({
4444
},
4545
}
4646
}
47+
if (providerId === 'monday') {
48+
return {
49+
providerId,
50+
clientId: 'monday-client-1',
51+
clientSecret: 'monday-secret-1',
52+
authorizationUrl: 'https://auth.monday.com/oauth2/authorize',
53+
tokenUrl: 'https://auth.monday.com/oauth_ms/oauth/token',
54+
redirectURI: 'https://sim.example.com/api/auth/oauth2/callback/monday',
55+
scopes: ['boards:read', 'me:read'],
56+
responseType: 'code',
57+
authentication: 'post',
58+
getToken: mockGetToken,
59+
managedOAuth: {
60+
additionalScopes: [],
61+
requiresRefreshToken: true,
62+
pkce: true,
63+
nonceVerification: 'state_only',
64+
includeLoginHint: false,
65+
getAuthorizationAppId: (clientId: string) => `monday:${clientId}`,
66+
verifyIdentity: mockVerifyIdentity,
67+
hasRequiredScopes: (granted: string[], required: string[]) =>
68+
required.every((scope) => granted.includes(scope)),
69+
isTerminalRefreshError: (errorCode: string | undefined) => errorCode === 'invalid_grant',
70+
},
71+
}
72+
}
4773
if (providerId === 'jira') {
4874
return {
4975
providerId,
@@ -82,6 +108,7 @@ import { createStandardOAuthCredentialGroupProviderAdapter } from '@/lib/credent
82108

83109
const adapter = createStandardOAuthCredentialGroupProviderAdapter('google-calendar')
84110
const jiraAdapter = createStandardOAuthCredentialGroupProviderAdapter('jira')
111+
const mondayAdapter = createStandardOAuthCredentialGroupProviderAdapter('monday')
85112

86113
function buildContext(): CredentialGroupOAuthContext {
87114
return {
@@ -210,6 +237,82 @@ describe('standard OAuth Credential Group provider', () => {
210237
})
211238
})
212239

240+
it('uses PKCE and persists expiring rotating credentials for managed Monday OAuth', async () => {
241+
const requiredScopes = ['boards:read', 'me:read']
242+
const context: CredentialGroupOAuthContext = {
243+
...buildContext(),
244+
option: {
245+
...buildContext().option,
246+
provider: 'monday',
247+
label: 'Monday.com',
248+
authorizationAppId: 'monday:monday-client-1',
249+
requiredScopes,
250+
},
251+
}
252+
const policy = await mondayAdapter.getPolicy(context.option, {
253+
workspaceId: context.workspaceId,
254+
credentialGroupId: context.credentialGroupId,
255+
})
256+
const prepared = await mondayAdapter.prepareAuthorization(context, policy)
257+
const authorizationUrl = new URL(
258+
await prepared.buildAuthorizationUrl({ state: 'monday-state-1', nonce: 'nonce-ignored' })
259+
)
260+
261+
expect(mondayAdapter.requiresRefreshToken).toBe(true)
262+
expect(prepared.codeVerifier).toHaveLength(86)
263+
expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256')
264+
expect(authorizationUrl.searchParams.get('code_challenge')).toBeTruthy()
265+
266+
const accessTokenExpiresAt = new Date('2026-08-14T01:00:00Z')
267+
mockGetToken.mockResolvedValueOnce({
268+
tokenType: 'Bearer',
269+
accessToken: 'monday-access-1',
270+
refreshToken: 'monday-refresh-1',
271+
accessTokenExpiresAt,
272+
scopes: requiredScopes,
273+
})
274+
mockVerifyIdentity.mockResolvedValueOnce({
275+
providerSubjectId: 'monday-user-1',
276+
providerTenantId: null,
277+
email: 'person@example.com',
278+
emailVerified: true,
279+
grantedScopes: requiredScopes,
280+
})
281+
282+
const grant = await mondayAdapter.exchangeAndVerify({
283+
context,
284+
attempt: {
285+
state: 'monday-state-1',
286+
provider: 'monday',
287+
nonceHash: 'unused-for-state-bound-provider',
288+
enrollmentId: context.enrollmentId,
289+
credentialGroupId: context.credentialGroupId,
290+
optionId: context.option.id,
291+
authorizationAppId: policy.authorizationAppId,
292+
scopeVersion: policy.scopeVersion,
293+
requiredScopes,
294+
redirectUri: prepared.redirectUri,
295+
codeVerifier: prepared.codeVerifier,
296+
invitationToken: 'invitation-1',
297+
createdAt: Date.now(),
298+
},
299+
code: 'monday-code-1',
300+
policy,
301+
})
302+
303+
expect(mockGetToken).toHaveBeenLastCalledWith({
304+
code: 'monday-code-1',
305+
redirectURI: 'https://sim.example.com/api/auth/oauth2/callback/monday',
306+
codeVerifier: prepared.codeVerifier,
307+
})
308+
expect(grant).toMatchObject({
309+
accessToken: 'monday-access-1',
310+
refreshToken: 'monday-refresh-1',
311+
accessTokenExpiresAt,
312+
grantedScopes: requiredScopes,
313+
})
314+
})
315+
213316
it('rejects a different invited email', async () => {
214317
mockVerifyIdentity.mockResolvedValueOnce({
215318
providerSubjectId: 'google-sub-2',

0 commit comments

Comments
 (0)