From c5a032e8824a75fa90e8bb49de603b8b17fcbd65 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 16 Sep 2026 11:35:54 -0700 Subject: [PATCH] improvement(credentials): allow connecting accounts with different emails --- .../credential-groups/oauth-callback.test.ts | 4 +- .../api/credential-groups/oauth-callback.ts | 8 +- .../oauth/[provider]/callback/route.test.ts | 2 - .../enroll/[token]/page.test.tsx | 2 +- .../credential-groups/enroll/[token]/page.tsx | 1 - apps/sim/hooks/use-member-enrollment.test.tsx | 18 ++-- .../use-personal-source-account.test.tsx | 8 +- .../lib/auth/connectors/managed-oauth.test.ts | 2 - apps/sim/lib/auth/connectors/managed-oauth.ts | 13 +-- apps/sim/lib/credential-groups/README.md | 2 +- .../lib/credential-groups/oauth-completion.ts | 5 +- apps/sim/lib/credential-groups/oauth.test.ts | 23 +++-- .../credential-groups/slack-provider.test.ts | 88 +++++++++++++++---- .../lib/credential-groups/slack-provider.ts | 6 -- .../standard-oauth-provider.test.ts | 58 ++++++++++-- .../standard-oauth-provider.ts | 12 +-- .../application/github-setup.test.ts | 4 +- .../sim/lib/oauth/github-repositories.test.ts | 70 +++++++-------- apps/sim/lib/oauth/github-repositories.ts | 16 +--- apps/sim/lib/oauth/identity-error.ts | 2 +- 20 files changed, 207 insertions(+), 137 deletions(-) diff --git a/apps/sim/app/api/credential-groups/oauth-callback.test.ts b/apps/sim/app/api/credential-groups/oauth-callback.test.ts index 4a15fcadf1a..ba64bcf7698 100644 --- a/apps/sim/app/api/credential-groups/oauth-callback.test.ts +++ b/apps/sim/app/api/credential-groups/oauth-callback.test.ts @@ -65,8 +65,8 @@ describe('GitHub managed OAuth failure presentation', () => { describe.each([false, true])('completion redirect: %s', (completionRedirect) => { it.each([ { - failure: new OAuthIdentityVerificationError('email_mismatch', 'emails'), - status: 'github_email_mismatch', + failure: new OAuthIdentityVerificationError('email_unverified', 'emails'), + status: 'github_email_unverified', }, { failure: new OAuthIdentityVerificationError('email_access_denied', 'emails', 403), diff --git a/apps/sim/app/api/credential-groups/oauth-callback.ts b/apps/sim/app/api/credential-groups/oauth-callback.ts index 1883ecdcb21..5808df6c42d 100644 --- a/apps/sim/app/api/credential-groups/oauth-callback.ts +++ b/apps/sim/app/api/credential-groups/oauth-callback.ts @@ -112,16 +112,14 @@ export async function handleCredentialGroupOAuthCallback({ error instanceof CredentialGroupInvitationUnavailableError ? 'unavailable' : error instanceof CredentialGroupOAuthError && error.statusCode === 403 - ? error.message.startsWith('Sign in with') - ? 'account_mismatch' - : 'permissions_required' + ? 'permissions_required' : error instanceof CredentialGroupOAuthError && error.statusCode === 409 ? 'configuration_changed' : 'failed' if (identityFailure) { switch (identityFailure.reason) { - case 'email_mismatch': - status = provider === 'github-repositories' ? 'github_email_mismatch' : 'account_mismatch' + case 'email_unverified': + status = provider === 'github-repositories' ? 'github_email_unverified' : 'failed' break case 'email_access_denied': status = diff --git a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts index 508dc05267a..98a20503ac6 100644 --- a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts +++ b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts @@ -115,7 +115,6 @@ describe('credential group OAuth callback', () => { it.each([ [new CredentialGroupInvitationUnavailableError(), 'unavailable'], - [new CredentialGroupOAuthError('Sign in with your own account', 403), 'account_mismatch'], [new CredentialGroupOAuthError('Missing scopes', 403), 'permissions_required'], [new CredentialGroupOAuthError('Changed settings', 409), 'configuration_changed'], [new Error('Provider failed'), 'failed'], @@ -250,7 +249,6 @@ describe('credential group OAuth callback', () => { it.each([ [new CredentialGroupInvitationUnavailableError(), 'unavailable'], - [new CredentialGroupOAuthError('Sign in with your own account', 403), 'account_mismatch'], [new CredentialGroupOAuthError('Missing scopes', 403), 'permissions_required'], [new CredentialGroupOAuthError('Changed settings', 409), 'configuration_changed'], [new Error('Provider failed'), 'failed'], diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx index 39bfb5f40e5..05f6c71b810 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx @@ -307,7 +307,7 @@ describe('focused Search enrollment', () => { }) it.each([ - ['github_email_mismatch', 'add and verify the email address'], + ['github_email_unverified', 'verify your primary email address'], ['github_email_access_denied', 'Email addresses: Read-only permission'], ['provider_unavailable', 'Try connecting again in a few minutes'], ])( diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 4550345da53..8c031917faa 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -110,7 +110,6 @@ function UnavailableSearchConnection({ const OAUTH_MESSAGES = { ...CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES, denied: 'Authorization was canceled. Nothing was connected.', - account_mismatch: 'Choose the account matching the email address on this invitation.', permissions_required: 'All requested permissions are required to connect this account.', configuration_changed: 'This credential option changed. Reload the page and try again.', unavailable: 'Account authorization is temporarily unavailable. Please try again.', diff --git a/apps/sim/hooks/use-member-enrollment.test.tsx b/apps/sim/hooks/use-member-enrollment.test.tsx index 4dbd090da1b..e0c8cb9a173 100644 --- a/apps/sim/hooks/use-member-enrollment.test.tsx +++ b/apps/sim/hooks/use-member-enrollment.test.tsx @@ -122,7 +122,7 @@ afterEach(() => { }) describe('useMemberEnrollment', () => { - it('reports an OAuth mismatch once per attempt and allows the same error on a later retry', () => { + it('reports an OAuth failure once per attempt and allows the same error on a later retry', () => { mount(new Set(), true, mocks.connectionError) for (let index = 0; index < 2; index += 1) { act(() => enrollment().connect('kb-1', 'connector-1')) @@ -132,16 +132,20 @@ describe('useMemberEnrollment', () => { }) ) act(() => - mocks.channels[index].onmessage?.(new MessageEvent('message', { data: 'account_mismatch' })) + mocks.channels[index].onmessage?.( + new MessageEvent('message', { data: 'permissions_required' }) + ) ) act(() => - mocks.channels[index].onmessage?.(new MessageEvent('message', { data: 'account_mismatch' })) + mocks.channels[index].onmessage?.( + new MessageEvent('message', { data: 'permissions_required' }) + ) ) expect(mocks.connectionError).toHaveBeenCalledTimes(index + 1) expect(enrollment().isAwaiting('connector-1')).toBe(false) } expect(mocks.connectionError).toHaveBeenLastCalledWith( - 'Choose the account matching your Sim email address.' + 'All requested permissions are required to connect this account.' ) act(() => vi.advanceTimersByTime(10 * 60_000)) expect(mocks.connectionError).toHaveBeenCalledTimes(2) @@ -179,7 +183,7 @@ describe('useMemberEnrollment', () => { act(() => mocks.channels[1].onmessage?.(new MessageEvent('message', { data: 'connected' }))) act(() => vi.advanceTimersByTime(10 * 60_000)) act(() => - mocks.channels[0].onmessage?.(new MessageEvent('message', { data: 'account_mismatch' })) + mocks.channels[0].onmessage?.(new MessageEvent('message', { data: 'permissions_required' })) ) expect(mocks.connectionError).not.toHaveBeenCalled() expect(enrollment().error).toBeNull() @@ -211,10 +215,10 @@ describe('useMemberEnrollment', () => { }) it.each([ - ['existing', 'account_mismatch'], + ['existing', 'permissions_required'], ['existing', 'denied'], ['existing', 'expired'], - ['new', 'account_mismatch'], + ['new', 'permissions_required'], ['new', 'denied'], ['new', 'expired'], ] as const)( diff --git a/apps/sim/hooks/use-personal-source-account.test.tsx b/apps/sim/hooks/use-personal-source-account.test.tsx index cccfe0e977f..b6a571561f9 100644 --- a/apps/sim/hooks/use-personal-source-account.test.tsx +++ b/apps/sim/hooks/use-personal-source-account.test.tsx @@ -108,10 +108,12 @@ describe('personal source account authorization', () => { act(() => root.render()) expect(current.pending).toBe(false) }) - it('shows an account mismatch as a toast and allows a fresh attempt', async () => { + it('shows missing permissions as a toast and allows a fresh attempt', async () => { await act(async () => current.connect()) - act(() => channels[0].onmessage?.({ data: 'account_mismatch' } as MessageEvent)) - expect(mocks.error).toHaveBeenCalledWith('Choose the account matching your Sim email address.') + act(() => channels[0].onmessage?.({ data: 'permissions_required' } as MessageEvent)) + expect(mocks.error).toHaveBeenCalledWith( + 'All requested permissions are required to connect this account.' + ) expect(current.pending).toBe(false) await act(async () => current.connect()) expect(mocks.authorize).toHaveBeenCalledTimes(2) diff --git a/apps/sim/lib/auth/connectors/managed-oauth.test.ts b/apps/sim/lib/auth/connectors/managed-oauth.test.ts index 8e37e850478..df7f69473fb 100644 --- a/apps/sim/lib/auth/connectors/managed-oauth.test.ts +++ b/apps/sim/lib/auth/connectors/managed-oauth.test.ts @@ -45,7 +45,6 @@ describe('Atlassian managed OAuth connector', () => { requiresRefreshToken: true, pkce: false, nonceVerification: 'state_only', - includeLoginHint: false, authorizationUrlParams: { audience: 'api.atlassian.com' }, }) expect(fetchMock).toHaveBeenCalledWith( @@ -490,7 +489,6 @@ describe('Microsoft managed OAuth connector', () => { requiresRefreshToken: true, pkce: true, nonceVerification: 'id_token', - includeLoginHint: true, prompt: 'select_account', }) return policy.getAuthorizationAppId(CLIENT_ID) diff --git a/apps/sim/lib/auth/connectors/managed-oauth.ts b/apps/sim/lib/auth/connectors/managed-oauth.ts index f32fff1e376..bcd6eacdb14 100644 --- a/apps/sim/lib/auth/connectors/managed-oauth.ts +++ b/apps/sim/lib/auth/connectors/managed-oauth.ts @@ -62,14 +62,12 @@ export interface ManagedOAuthConnectorConfig { */ scopeless?: boolean nonceVerification: 'id_token' | 'state_only' - includeLoginHint: boolean prompt?: string authorizationUrlParams?: Record getAuthorizationAppId(clientId: string): string verifyIdentity(params: { tokens: OAuth2Tokens clientId: string - expectedEmail?: string }): Promise hasRequiredScopes(grantedScopes: string[], requiredScopes: string[]): boolean isTerminalRefreshError(errorCode: string | undefined): boolean @@ -120,7 +118,6 @@ export function createGoogleManagedOAuthConnector(providerId: string): ManagedOA requiresRefreshToken: true, pkce: true, nonceVerification: 'id_token', - includeLoginHint: true, prompt: 'consent select_account', authorizationUrlParams: { include_granted_scopes: 'false' }, getAuthorizationAppId(clientId) { @@ -199,7 +196,6 @@ export function createAtlassianManagedOAuthConnector( requiresRefreshToken: true, pkce: false, nonceVerification: 'state_only', - includeLoginHint: false, prompt: 'consent', authorizationUrlParams: { audience: 'api.atlassian.com' }, getAuthorizationAppId(clientId) { @@ -350,7 +346,6 @@ export function createMicrosoftManagedOAuthConnector( requiresRefreshToken: true, pkce: true, nonceVerification: 'id_token', - includeLoginHint: true, prompt: 'select_account', getAuthorizationAppId(clientId) { return `microsoft:${createHash('sha256').update(clientId).digest('hex')}` @@ -521,7 +516,6 @@ export function createUserInfoManagedOAuthConnector( requiresRefreshToken: options.requiresRefreshToken, pkce: options.pkce ?? false, nonceVerification: 'state_only', - includeLoginHint: false, ...(options.scopeless ? { scopeless: true } : {}), ...(options.prompt ? { prompt: options.prompt } : {}), ...(options.authorizationUrlParams @@ -659,7 +653,6 @@ function createAttioManagedOAuthConnector(): ManagedOAuthConnectorConfig { requiresRefreshToken: false, pkce: false, nonceVerification: 'state_only', - includeLoginHint: false, getAuthorizationAppId(clientId) { return `attio:${createHash('sha256').update(clientId).digest('hex')}` }, @@ -745,7 +738,6 @@ function createBitbucketManagedOAuthConnector(): ManagedOAuthConnectorConfig { requiresRefreshToken: true, pkce: false, nonceVerification: 'state_only', - includeLoginHint: false, getAuthorizationAppId(clientId) { return `bitbucket:${createHash('sha256').update(clientId).digest('hex')}` }, @@ -985,12 +977,11 @@ const USER_INFO_MANAGED_OAUTH_CONNECTORS = new Map ManagedOAuthCon pkce: true, scopeless: true, nonceVerification: 'state_only', - includeLoginHint: false, getAuthorizationAppId(clientId) { return `github-repositories:${createHash('sha256').update(clientId).digest('hex')}` }, - verifyIdentity({ tokens, expectedEmail }) { - return verifyGitHubRepositoriesIdentity(tokens.accessToken ?? '', expectedEmail) + verifyIdentity({ tokens }) { + return verifyGitHubRepositoriesIdentity(tokens.accessToken ?? '') }, hasRequiredScopes(_grantedScopes, requiredScopes) { return requiredScopes.length === 0 diff --git a/apps/sim/lib/credential-groups/README.md b/apps/sim/lib/credential-groups/README.md index f955a7f7267..3dba7e478ee 100644 --- a/apps/sim/lib/credential-groups/README.md +++ b/apps/sim/lib/credential-groups/README.md @@ -10,7 +10,7 @@ Both pages include **People → Request connections**, with the existing manual An allowed workspace grants every normally authorized manual and deployed workflow access to every active contribution in this pool. There is no per-workflow resource-policy grant and no per-person filtering for workflow execution. Keep ordinary workspace/workflow authorization and deployment authority: an allowlist entry alone cannot authorize running a workflow. Nested workflows use their actual execution workspace. A workspace move, revocation, inactive enrollment, removed provider, disabled group, or unavailable org entitlement blocks subsequent use. -Standalone Chat uses the signed-in person’s own connections. Invited contributors do not need organization membership. Redemption requires a verified matching Sim email; the enrollment is then bound permanently to that user ID. An email change cannot transfer an enrollment. OAuth callbacks require the same verified signed-in user who started authorization. Search requires current organization membership and applies document permissions using the viewer's own verified provider identities; workspace access to the shared credential pool does not grant access to other people's indexed documents. +Standalone Chat uses the signed-in person’s own connections. Invited contributors do not need organization membership. Redemption requires a verified Sim email matching the invitation; the enrollment is then bound permanently to that user ID. An email change cannot transfer an enrollment. People can connect any provider account they can authorize, even when its email differs from their Sim or invitation email. OAuth callbacks require the same verified signed-in user who started authorization. Search requires current organization membership and applies document permissions using the viewer's own verified provider identities; workspace access to the shared credential pool does not grant access to other people's indexed documents. Disconnect revokes the local grant and invalidates pending invitation-based authorization. Administrators can revoke an enrollment; the person cannot restore it themselves. Removing workspace access stops future authorized calls, but cannot recall a provider request already in flight or erase data already returned to a workflow. Full-pool sharing includes public, scheduled, and webhook deployments that otherwise pass workflow authorization. diff --git a/apps/sim/lib/credential-groups/oauth-completion.ts b/apps/sim/lib/credential-groups/oauth-completion.ts index 1c732bdfc8c..25785030fde 100644 --- a/apps/sim/lib/credential-groups/oauth-completion.ts +++ b/apps/sim/lib/credential-groups/oauth-completion.ts @@ -3,9 +3,8 @@ import { isValidUuid } from '@sim/utils/id' export const CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES = { expired: 'This connection attempt expired. Try connecting your account again.', denied: 'Authorization was canceled. Try connecting your account again.', - account_mismatch: 'Choose the account matching your Sim email address.', - github_email_mismatch: - 'In GitHub Settings → Emails, add and verify the email address used for this Sim connection, then try again. A verified secondary email is supported.', + github_email_unverified: + 'In GitHub Settings → Emails, verify your primary email address, then try again.', github_email_access_denied: 'GitHub did not allow access to your email addresses. Ask an admin to check that the GitHub App has Email addresses: Read-only permission, then authorize the app again.', permissions_required: 'All requested permissions are required to connect this account.', diff --git a/apps/sim/lib/credential-groups/oauth.test.ts b/apps/sim/lib/credential-groups/oauth.test.ts index 0fae1303e67..9837a8eac51 100644 --- a/apps/sim/lib/credential-groups/oauth.test.ts +++ b/apps/sim/lib/credential-groups/oauth.test.ts @@ -98,8 +98,8 @@ describe('credential group OAuth persistence', () => { providerId: POLICY.providerId, providerSubjectId: 'google-subject-1', providerTenantId: null, - displayName: 'person@example.com', - metadata: { email: 'person@example.com' }, + displayName: 'provider@example.com', + metadata: { email: 'provider@example.com' }, accessToken: 'access-token', refreshToken: 'refresh-token', grantedScopes: POLICY.requiredScopes, @@ -177,7 +177,7 @@ describe('credential group OAuth persistence', () => { expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) - it('returns a created event result after inserting a first credential', async () => { + it('persists a different-email provider account under the enrolled Sim user', async () => { dbChainMockFns.limit.mockResolvedValueOnce([{ status: 'invited' }]) queueTableRows(schemaMock.credentialGroup, [GROUP]) queueTableRows(schemaMock.credential, []) @@ -214,10 +214,19 @@ describe('credential group OAuth persistence', () => { credentialGroupOptionId: 'option-1', provider: 'gmail', providerId: 'google-email', - displayName: 'person@example.com', + displayName: 'provider@example.com', enrollmentStatus: 'in_progress', }) expect(dbChainMockFns.insert).toHaveBeenCalledWith(schemaMock.credential) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + createdBy: CONTEXT.credentialOwnerId, + credentialGroupEnrollmentId: CONTEXT.enrollmentId, + providerSubjectId: 'google-subject-1', + displayName: 'provider@example.com', + providerMetadata: { email: 'provider@example.com' }, + }) + ) }) it.each([true, false])( @@ -362,7 +371,7 @@ describe('credential group OAuth persistence', () => { credentialGroupOptionId: 'option-1', provider: 'gmail', providerId: 'google-email', - displayName: 'person@example.com', + displayName: 'provider@example.com', enrollmentStatus: 'completed', }) }) @@ -417,7 +426,7 @@ describe('credential group OAuth persistence', () => { credentialGroupOptionId: 'option-1', provider: 'gmail', providerId: 'google-email', - displayName: 'person@example.com', + displayName: 'provider@example.com', enrollmentStatus: 'completed', }) }) @@ -562,7 +571,7 @@ describe('credential group OAuth persistence', () => { credentialGroupOptionId: 'option-1', provider: 'gmail', providerId: 'google-email', - displayName: 'person@example.com', + displayName: 'provider@example.com', enrollmentStatus: 'completed', }) }) diff --git a/apps/sim/lib/credential-groups/slack-provider.test.ts b/apps/sim/lib/credential-groups/slack-provider.test.ts index 2ec9c3b22d0..88ca1ca474f 100644 --- a/apps/sim/lib/credential-groups/slack-provider.test.ts +++ b/apps/sim/lib/credential-groups/slack-provider.test.ts @@ -66,7 +66,7 @@ describe('Slack member scope policy', () => { workspaceId: 'workspace-1', workspaceName: 'Fixture', workspaceOwnerId: 'owner', - email: 'member@fixture.test', + email: 'sim-member@fixture.test', enrollmentStatus: 'in_progress', option, options: [option], @@ -90,22 +90,83 @@ describe('Slack member scope policy', () => { expect(url.searchParams.get('user_scope')?.split(',')).toEqual([...scopes]) }) - it('accepts a minimal search grant and rejects the same grant for a workflow option', async () => { - for (const scopes of [SLACK_SEARCH_USER_SCOPES, SLACK_MANAGED_USER_SCOPES]) { - const current = context(scopes) - const policy = await adapter.getPolicy(current.option, { - workspaceId: current.workspaceId, + it.each([ + { name: 'search', scopes: SLACK_SEARCH_USER_SCOPES }, + { name: 'workflow', scopes: SLACK_MANAGED_USER_SCOPES }, + ])('accepts a different provider email for a $name option', async ({ scopes }) => { + const current = context(scopes) + mocks.exchange.mockResolvedValueOnce({ + appId: 'A1', + teamId: 'T1', + userId: 'U1', + accessToken: 'fixture-token', + tokenType: 'user', + scopes: [...scopes], + }) + const policy = await adapter.getPolicy(current.option, { + workspaceId: current.workspaceId, + credentialGroupId: current.credentialGroupId, + }) + const result = adapter.exchangeAndVerify({ + context: current, + policy, + code: 'code', + attempt: { + state: 'state', + provider: 'slack', + workspaceId: 'workspace-1', + email: current.email, + nonceHash: 'nonce-hash', + enrollmentId: current.enrollmentId, credentialGroupId: current.credentialGroupId, - }) - const result = adapter.exchangeAndVerify({ + optionId: current.option.id, + authorizationAppId: policy.authorizationAppId, + scopeVersion: policy.scopeVersion, + requiredScopes: policy.requiredScopes, + redirectUri: 'https://sim.fixture.test/api/credential-groups/oauth/slack/callback', + invitationToken: 'invitation', + createdAt: Date.now(), + }, + }) + await expect(result).resolves.toMatchObject({ + providerSubjectId: 'U1', + providerTenantId: 'T1', + displayName: 'member@fixture.test', + metadata: { email: 'member@fixture.test' }, + grantedScopes: [...scopes], + }) + expect(mocks.revoke).not.toHaveBeenCalled() + }) + + it.each([ + { name: 'missing permissions', grant: { scopes: [...SLACK_SEARCH_USER_SCOPES] } }, + { name: 'a different team', grant: { teamId: 'T2' } }, + { name: 'a different app', grant: { appId: 'A2' } }, + ])('still rejects $name and revokes the grant', async ({ grant }) => { + const current = context(SLACK_MANAGED_USER_SCOPES) + const policy = await adapter.getPolicy(current.option, { + workspaceId: current.workspaceId, + credentialGroupId: current.credentialGroupId, + }) + mocks.exchange.mockResolvedValueOnce({ + appId: 'A1', + teamId: 'T1', + userId: 'U1', + accessToken: 'fixture-token', + tokenType: 'user', + scopes: [...SLACK_MANAGED_USER_SCOPES], + ...grant, + }) + await expect( + adapter.exchangeAndVerify({ context: current, policy, code: 'code', attempt: { state: 'state', provider: 'slack', - workspaceId: 'workspace-1', - email: 'person@example.com', + workspaceId: current.workspaceId, + email: current.email, nonceHash: 'nonce-hash', enrollmentId: current.enrollmentId, credentialGroupId: current.credentialGroupId, @@ -118,12 +179,7 @@ describe('Slack member scope policy', () => { createdAt: Date.now(), }, }) - if (scopes === SLACK_SEARCH_USER_SCOPES) - await expect(result).resolves.toMatchObject({ - grantedScopes: [...SLACK_SEARCH_USER_SCOPES], - }) - else await expect(result).rejects.toThrow('All requested Slack permissions') - } + ).rejects.toMatchObject({ statusCode: 403 }) expect(mocks.revoke).toHaveBeenCalledExactlyOnceWith('fixture-token') }) diff --git a/apps/sim/lib/credential-groups/slack-provider.ts b/apps/sim/lib/credential-groups/slack-provider.ts index 0860ff43313..f94d443e428 100644 --- a/apps/sim/lib/credential-groups/slack-provider.ts +++ b/apps/sim/lib/credential-groups/slack-provider.ts @@ -246,12 +246,6 @@ export const slackCredentialGroupProviderAdapter: CredentialGroupProviderAdapter expectedUserId: grant.userId, }) const email = normalizeEmail(identity.email) - if (email !== context.email) { - throw new CredentialGroupOAuthError( - `Sign in with ${context.email} to complete this invitation.`, - 403 - ) - } return { providerId: policy.providerId, 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 ac06e6f834b..f0151f739d3 100644 --- a/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts @@ -34,7 +34,6 @@ vi.mock('@/lib/auth/connectors/managed-oauth', () => ({ requiresRefreshToken: true, pkce: true, nonceVerification: 'id_token', - includeLoginHint: true, prompt: 'consent select_account', authorizationUrlParams: { include_granted_scopes: 'false' }, getAuthorizationAppId: (clientId: string) => `google:${clientId}`, @@ -64,7 +63,6 @@ vi.mock('@/lib/auth/connectors/managed-oauth', () => ({ requiresRefreshToken: true, pkce: false, nonceVerification: 'state_only', - includeLoginHint: false, prompt: 'consent', authorizationUrlParams: { audience: 'api.atlassian.com' }, getAuthorizationAppId: (clientId: string) => `jira:${clientId}`, @@ -174,7 +172,8 @@ describe('standard OAuth Credential Group provider', () => { expect(authorizationUrl.searchParams.get('client_id')).toBe('client-1') expect(authorizationUrl.searchParams.get('state')).toBe('state-1') expect(authorizationUrl.searchParams.get('nonce')).toBe('nonce-1') - expect(authorizationUrl.searchParams.get('login_hint')).toBe('person@example.com') + expect(authorizationUrl.searchParams.has('login_hint')).toBe(false) + expect(authorizationUrl.searchParams.get('prompt')).toBe('consent select_account') expect(authorizationUrl.searchParams.get('include_granted_scopes')).toBe('false') expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256') }) @@ -213,7 +212,52 @@ describe('standard OAuth Credential Group provider', () => { }) }) - it('rejects a different invited email', async () => { + it.each(['workspace', 'organization'] as const)( + 'accepts a different provider email for a %s credential group', + async (scope) => { + mockVerifyIdentity.mockResolvedValueOnce({ + providerSubjectId: 'google-sub-2', + providerTenantId: null, + email: ' Other@Example.com ', + emailVerified: true, + nonce: 'nonce-1', + grantedScopes: ['calendar.read', 'profile', 'openid'], + }) + const context = buildContext() + if (scope === 'organization') { + context.workspaceId = undefined + context.organizationId = 'org-1' + } + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + + await expect( + adapter.exchangeAndVerify({ + context, + attempt: buildAttempt(policy.scopeVersion), + code: 'code-1', + policy, + }) + ).resolves.toMatchObject({ + providerSubjectId: 'google-sub-2', + displayName: 'other@example.com', + metadata: { email: 'other@example.com' }, + }) + expect(mockVerifyIdentity).toHaveBeenCalledExactlyOnceWith({ + tokens: expect.objectContaining({ accessToken: 'access-1' }), + clientId: 'client-1', + }) + } + ) + + it.each([ + { name: 'an unverified email', identity: { emailVerified: false }, statusCode: 502 }, + { name: 'a mismatched nonce', identity: { nonce: 'wrong-nonce' }, statusCode: 502 }, + { name: 'a missing nonce', identity: { nonce: undefined }, statusCode: 502 }, + { name: 'missing permissions', identity: { grantedScopes: ['openid'] }, statusCode: 403 }, + ])('still rejects $name when connecting a different email', async ({ identity, statusCode }) => { mockVerifyIdentity.mockResolvedValueOnce({ providerSubjectId: 'google-sub-2', providerTenantId: null, @@ -221,13 +265,13 @@ describe('standard OAuth Credential Group provider', () => { emailVerified: true, nonce: 'nonce-1', grantedScopes: ['calendar.read', 'profile', 'openid'], + ...identity, }) const context = buildContext() const policy = await adapter.getPolicy(context.option, { workspaceId: context.workspaceId, credentialGroupId: context.credentialGroupId, }) - await expect( adapter.exchangeAndVerify({ context, @@ -235,11 +279,11 @@ describe('standard OAuth Credential Group provider', () => { code: 'code-1', policy, }) - ).rejects.toMatchObject({ statusCode: 403 }) + ).rejects.toMatchObject({ statusCode }) }) it.each([ - new OAuthIdentityVerificationError('email_mismatch', 'emails'), + new OAuthIdentityVerificationError('email_unverified', 'emails'), new OAuthIdentityVerificationError('email_access_denied', 'emails', 403), new OAuthIdentityVerificationError('provider_unavailable', 'profile', 503), ])('preserves safe identity diagnostics through managed authorization: %s', async (failure) => { diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.ts index 4492ad3812d..5c4c343277d 100644 --- a/apps/sim/lib/credential-groups/standard-oauth-provider.ts +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.ts @@ -248,7 +248,7 @@ export function createStandardOAuthCredentialGroupProviderAdapter( async getPolicy() { return getCurrentProvider(provider).policy }, - async prepareAuthorization(context, policy) { + async prepareAuthorization(_context, policy) { const current = getCurrentProvider(provider) assertCurrentPolicy(policy, current.policy) const managed = current.connector.managedOAuth @@ -278,7 +278,6 @@ export function createStandardOAuthCredentialGroupProviderAdapter( accessType: current.connector.accessType, responseType: current.connector.responseType, responseMode: current.connector.responseMode, - loginHint: managed.includeLoginHint ? context.email : undefined, additionalParams: { ...staticParams( current.connector.authorizationUrlParams, @@ -292,7 +291,7 @@ export function createStandardOAuthCredentialGroupProviderAdapter( }, } }, - async exchangeAndVerify({ context, attempt, code, policy }) { + async exchangeAndVerify({ attempt, code, policy }) { const current = getCurrentProvider(provider) assertCurrentPolicy(policy, current.policy) const redirectUri = getRedirectUri(provider, current) @@ -331,7 +330,6 @@ export function createStandardOAuthCredentialGroupProviderAdapter( identity = await managed.verifyIdentity({ tokens, clientId: current.connector.clientId, - expectedEmail: context.email, }) } catch (error) { throw new CredentialGroupOAuthError( @@ -350,12 +348,6 @@ export function createStandardOAuthCredentialGroupProviderAdapter( ) } const email = normalizeEmail(identity.email) - if (email !== context.email) { - throw new CredentialGroupOAuthError( - `Sign in with ${context.email} to complete this invitation.`, - 403 - ) - } if (!managed.hasRequiredScopes(identity.grantedScopes, policy.requiredScopes)) { throw new CredentialGroupOAuthError( `All requested ${service.name} permissions are required to connect this account.`, diff --git a/apps/sim/lib/knowledge/application/github-setup.test.ts b/apps/sim/lib/knowledge/application/github-setup.test.ts index bd1d9939517..6d164a5ff8e 100644 --- a/apps/sim/lib/knowledge/application/github-setup.test.ts +++ b/apps/sim/lib/knowledge/application/github-setup.test.ts @@ -483,11 +483,11 @@ describe('GitHub setup reader OAuth continuation', () => { admin() await continueGitHubSearchSetup.execute({ principal, - input: { ...input, oauth: 'github_email_mismatch' }, + input: { ...input, oauth: 'github_email_unverified' }, }) await expect(status()).resolves.toMatchObject({ status: 'failed', - error: expect.stringContaining('verified secondary email'), + error: expect.stringContaining('verify your primary email address'), }) expect(m.connect).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/oauth/github-repositories.test.ts b/apps/sim/lib/oauth/github-repositories.test.ts index c2820879cc1..4fa8568523e 100644 --- a/apps/sim/lib/oauth/github-repositories.test.ts +++ b/apps/sim/lib/oauth/github-repositories.test.ts @@ -154,7 +154,7 @@ describe('GitHub identity verification', () => { }) }) - it('allows an invitation to match a verified secondary work email', async () => { + it('uses the verified primary email for a managed connection without an invitation email', async () => { vi.stubGlobal( 'fetch', vi @@ -167,44 +167,40 @@ describe('GitHub identity verification', () => { policy.verifyIdentity({ tokens: { accessToken: 'ghu_access' }, clientId: 'app-client', - expectedEmail: 'Work@Example.com', }) - ).resolves.toMatchObject({ email: work.email, providerSubjectId: '1234' }) + ).resolves.toMatchObject({ email: primary.email, providerSubjectId: '1234' }) }) it('checks later email pages without following provider-supplied destinations', async () => { const fetchMock = vi .fn() .mockResolvedValueOnce(response(user)) - .mockResolvedValueOnce(response(Array.from({ length: 100 }, () => primary))) - .mockResolvedValueOnce(response([work])) + .mockResolvedValueOnce(response(Array.from({ length: 100 }, () => work))) + .mockResolvedValueOnce(response([primary])) vi.stubGlobal('fetch', fetchMock) - await expect(verifyGitHubRepositoriesIdentity('ghu_access', work.email)).resolves.toMatchObject( - { - email: work.email, - } - ) + await expect(verifyGitHubRepositoriesIdentity('ghu_access')).resolves.toMatchObject({ + email: primary.email, + }) expect(fetchMock.mock.calls[2]![0]).toBe( 'https://api.github.com/user/emails?per_page=100&page=2' ) }) - it.each([{ emails: [primary] }, { emails: [{ ...work, verified: false }] }, { emails: [] }])( - 'refuses absent or unverified invited email $emails', - async ({ emails }) => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValueOnce(response(user)).mockResolvedValueOnce(response(emails)) - ) - await expect( - verifyGitHubRepositoriesIdentity('ghu_access', work.email) - ).rejects.toMatchObject({ - name: 'OAuthIdentityVerificationError', - reason: 'email_mismatch', - stage: 'emails', - }) - } - ) + it.each([ + { emails: [work] }, + { emails: [{ ...primary, verified: false }, work] }, + { emails: [] }, + ])('refuses absent or unverified primary email $emails', async ({ emails }) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce(response(user)).mockResolvedValueOnce(response(emails)) + ) + await expect(verifyGitHubRepositoriesIdentity('ghu_access')).rejects.toMatchObject({ + name: 'OAuthIdentityVerificationError', + reason: 'email_unverified', + stage: 'emails', + }) + }) it('rejects a bot identity', async () => { const fetchMock = vi.fn().mockResolvedValue(response({ ...user, type: 'Bot' })) @@ -225,7 +221,7 @@ describe('GitHub identity verification', () => { }) }) - it('distinguishes denied email-read permission from a verified email mismatch', async () => { + it('distinguishes denied email-read permission from an unverified email', async () => { vi.stubGlobal( 'fetch', vi @@ -233,7 +229,7 @@ describe('GitHub identity verification', () => { .mockResolvedValueOnce(response(user)) .mockResolvedValueOnce(response({ message: 'Resource not accessible by integration' }, 403)) ) - await expect(verifyGitHubRepositoriesIdentity('ghu_access', work.email)).rejects.toMatchObject({ + await expect(verifyGitHubRepositoriesIdentity('ghu_access')).rejects.toMatchObject({ reason: 'email_access_denied', stage: 'emails', httpStatus: 403, @@ -261,9 +257,7 @@ describe('GitHub identity verification', () => { }) ) ) - await expect( - verifyGitHubRepositoriesIdentity('ghu_access', work.email) - ).rejects.toMatchObject({ + await expect(verifyGitHubRepositoriesIdentity('ghu_access')).rejects.toMatchObject({ reason: 'rate_limited', stage: 'emails', httpStatus: error.status, @@ -273,7 +267,7 @@ describe('GitHub identity verification', () => { it('reports GitHub service failure without retaining its response body', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response({ message: work.email }, 503))) - const failure = await verifyGitHubRepositoriesIdentity('ghu_access', work.email).catch( + const failure = await verifyGitHubRepositoriesIdentity('ghu_access').catch( (error: unknown) => error ) expect(failure).toBeInstanceOf(OAuthIdentityVerificationError) @@ -287,7 +281,7 @@ describe('GitHub identity verification', () => { it('sanitizes network failures instead of retaining a transport error', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('transport failed ghu_access'))) - const failure = await verifyGitHubRepositoriesIdentity('ghu_access', work.email).catch( + const failure = await verifyGitHubRepositoriesIdentity('ghu_access').catch( (error: unknown) => error ) expect(failure).toMatchObject({ reason: 'provider_unavailable', stage: 'profile' }) @@ -295,7 +289,7 @@ describe('GitHub identity verification', () => { expect(failure).not.toHaveProperty('cause') }) - it('distinguishes an invalid email response from a verified email mismatch', async () => { + it('distinguishes an invalid email response from an unverified email', async () => { vi.stubGlobal( 'fetch', vi @@ -310,19 +304,19 @@ describe('GitHub identity verification', () => { ]) ) ) - await expect(verifyGitHubRepositoriesIdentity('ghu_access', work.email)).rejects.toMatchObject({ + await expect(verifyGitHubRepositoriesIdentity('ghu_access')).rejects.toMatchObject({ reason: 'invalid_response', stage: 'emails', }) }) - it('does not claim a mismatch when the bounded email scan cannot finish', async () => { + it('does not claim an unverified email when the bounded email scan cannot finish', async () => { const fetchMock = vi.fn().mockResolvedValueOnce(response(user)) for (let page = 0; page < 10; page++) { - fetchMock.mockResolvedValueOnce(response(Array.from({ length: 100 }, () => primary))) + fetchMock.mockResolvedValueOnce(response(Array.from({ length: 100 }, () => work))) } vi.stubGlobal('fetch', fetchMock) - await expect(verifyGitHubRepositoriesIdentity('ghu_access', work.email)).rejects.toMatchObject({ + await expect(verifyGitHubRepositoriesIdentity('ghu_access')).rejects.toMatchObject({ reason: 'invalid_response', stage: 'emails', }) diff --git a/apps/sim/lib/oauth/github-repositories.ts b/apps/sim/lib/oauth/github-repositories.ts index 93c9a6361e5..320c2658bd0 100644 --- a/apps/sim/lib/oauth/github-repositories.ts +++ b/apps/sim/lib/oauth/github-repositories.ts @@ -44,12 +44,9 @@ export function parseGitHubRepositoriesTokenResponse(value: unknown) { /** * Reads provider-attested identity; a public profile email never establishes ownership. - * A managed invitation may match a verified work address even when it is not primary. + * Both managed and ordinary connections use the account's verified primary email. */ -export async function verifyGitHubRepositoriesIdentity( - accessToken: string, - expectedEmail?: string -) { +export async function verifyGitHubRepositoriesIdentity(accessToken: string) { if (!accessToken.startsWith('ghu_')) { throw new OAuthIdentityVerificationError('provider_rejected', 'token') } @@ -120,7 +117,6 @@ export async function verifyGitHubRepositoriesIdentity( throw new OAuthIdentityVerificationError('invalid_response', 'profile') } const user = parsedUser.data - const normalizedEmail = expectedEmail?.trim().toLowerCase() for (let page = 1; page <= MAX_EMAIL_PAGES; page++) { const parsedEmails = emailsSchema.safeParse( await get(`/user/emails?per_page=${EMAIL_PAGE_SIZE}&page=${page}`, 'emails') @@ -129,11 +125,7 @@ export async function verifyGitHubRepositoriesIdentity( throw new OAuthIdentityVerificationError('invalid_response', 'emails') } const emails = parsedEmails.data - const matching = emails.find( - (entry) => - entry.verified && - (normalizedEmail ? entry.email.toLowerCase() === normalizedEmail : entry.primary) - ) + const matching = emails.find((entry) => entry.verified && entry.primary) if (matching) { return { providerSubjectId: String(user.id), @@ -146,7 +138,7 @@ export async function verifyGitHubRepositoriesIdentity( } } if (emails.length < EMAIL_PAGE_SIZE) { - throw new OAuthIdentityVerificationError('email_mismatch', 'emails') + throw new OAuthIdentityVerificationError('email_unverified', 'emails') } } throw new OAuthIdentityVerificationError('invalid_response', 'emails') diff --git a/apps/sim/lib/oauth/identity-error.ts b/apps/sim/lib/oauth/identity-error.ts index 8fbac5f2a1c..bb344223007 100644 --- a/apps/sim/lib/oauth/identity-error.ts +++ b/apps/sim/lib/oauth/identity-error.ts @@ -1,5 +1,5 @@ export type OAuthIdentityFailureReason = - | 'email_mismatch' + | 'email_unverified' | 'email_access_denied' | 'provider_rejected' | 'rate_limited'