Skip to content

Commit c5a032e

Browse files
improvement(credentials): allow connecting accounts with different emails
1 parent d61006a commit c5a032e

20 files changed

Lines changed: 207 additions & 137 deletions

apps/sim/app/api/credential-groups/oauth-callback.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ describe('GitHub managed OAuth failure presentation', () => {
6565
describe.each([false, true])('completion redirect: %s', (completionRedirect) => {
6666
it.each([
6767
{
68-
failure: new OAuthIdentityVerificationError('email_mismatch', 'emails'),
69-
status: 'github_email_mismatch',
68+
failure: new OAuthIdentityVerificationError('email_unverified', 'emails'),
69+
status: 'github_email_unverified',
7070
},
7171
{
7272
failure: new OAuthIdentityVerificationError('email_access_denied', 'emails', 403),

apps/sim/app/api/credential-groups/oauth-callback.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,16 +112,14 @@ export async function handleCredentialGroupOAuthCallback({
112112
error instanceof CredentialGroupInvitationUnavailableError
113113
? 'unavailable'
114114
: error instanceof CredentialGroupOAuthError && error.statusCode === 403
115-
? error.message.startsWith('Sign in with')
116-
? 'account_mismatch'
117-
: 'permissions_required'
115+
? 'permissions_required'
118116
: error instanceof CredentialGroupOAuthError && error.statusCode === 409
119117
? 'configuration_changed'
120118
: 'failed'
121119
if (identityFailure) {
122120
switch (identityFailure.reason) {
123-
case 'email_mismatch':
124-
status = provider === 'github-repositories' ? 'github_email_mismatch' : 'account_mismatch'
121+
case 'email_unverified':
122+
status = provider === 'github-repositories' ? 'github_email_unverified' : 'failed'
125123
break
126124
case 'email_access_denied':
127125
status =

apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,6 @@ describe('credential group OAuth callback', () => {
115115

116116
it.each([
117117
[new CredentialGroupInvitationUnavailableError(), 'unavailable'],
118-
[new CredentialGroupOAuthError('Sign in with your own account', 403), 'account_mismatch'],
119118
[new CredentialGroupOAuthError('Missing scopes', 403), 'permissions_required'],
120119
[new CredentialGroupOAuthError('Changed settings', 409), 'configuration_changed'],
121120
[new Error('Provider failed'), 'failed'],
@@ -250,7 +249,6 @@ describe('credential group OAuth callback', () => {
250249

251250
it.each([
252251
[new CredentialGroupInvitationUnavailableError(), 'unavailable'],
253-
[new CredentialGroupOAuthError('Sign in with your own account', 403), 'account_mismatch'],
254252
[new CredentialGroupOAuthError('Missing scopes', 403), 'permissions_required'],
255253
[new CredentialGroupOAuthError('Changed settings', 409), 'configuration_changed'],
256254
[new Error('Provider failed'), 'failed'],

apps/sim/app/credential-groups/enroll/[token]/page.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ describe('focused Search enrollment', () => {
307307
})
308308

309309
it.each([
310-
['github_email_mismatch', 'add and verify the email address'],
310+
['github_email_unverified', 'verify your primary email address'],
311311
['github_email_access_denied', 'Email addresses: Read-only permission'],
312312
['provider_unavailable', 'Try connecting again in a few minutes'],
313313
])(

apps/sim/app/credential-groups/enroll/[token]/page.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,6 @@ function UnavailableSearchConnection({
110110
const OAUTH_MESSAGES = {
111111
...CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES,
112112
denied: 'Authorization was canceled. Nothing was connected.',
113-
account_mismatch: 'Choose the account matching the email address on this invitation.',
114113
permissions_required: 'All requested permissions are required to connect this account.',
115114
configuration_changed: 'This credential option changed. Reload the page and try again.',
116115
unavailable: 'Account authorization is temporarily unavailable. Please try again.',

apps/sim/hooks/use-member-enrollment.test.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ afterEach(() => {
122122
})
123123

124124
describe('useMemberEnrollment', () => {
125-
it('reports an OAuth mismatch once per attempt and allows the same error on a later retry', () => {
125+
it('reports an OAuth failure once per attempt and allows the same error on a later retry', () => {
126126
mount(new Set(), true, mocks.connectionError)
127127
for (let index = 0; index < 2; index += 1) {
128128
act(() => enrollment().connect('kb-1', 'connector-1'))
@@ -132,16 +132,20 @@ describe('useMemberEnrollment', () => {
132132
})
133133
)
134134
act(() =>
135-
mocks.channels[index].onmessage?.(new MessageEvent('message', { data: 'account_mismatch' }))
135+
mocks.channels[index].onmessage?.(
136+
new MessageEvent('message', { data: 'permissions_required' })
137+
)
136138
)
137139
act(() =>
138-
mocks.channels[index].onmessage?.(new MessageEvent('message', { data: 'account_mismatch' }))
140+
mocks.channels[index].onmessage?.(
141+
new MessageEvent('message', { data: 'permissions_required' })
142+
)
139143
)
140144
expect(mocks.connectionError).toHaveBeenCalledTimes(index + 1)
141145
expect(enrollment().isAwaiting('connector-1')).toBe(false)
142146
}
143147
expect(mocks.connectionError).toHaveBeenLastCalledWith(
144-
'Choose the account matching your Sim email address.'
148+
'All requested permissions are required to connect this account.'
145149
)
146150
act(() => vi.advanceTimersByTime(10 * 60_000))
147151
expect(mocks.connectionError).toHaveBeenCalledTimes(2)
@@ -179,7 +183,7 @@ describe('useMemberEnrollment', () => {
179183
act(() => mocks.channels[1].onmessage?.(new MessageEvent('message', { data: 'connected' })))
180184
act(() => vi.advanceTimersByTime(10 * 60_000))
181185
act(() =>
182-
mocks.channels[0].onmessage?.(new MessageEvent('message', { data: 'account_mismatch' }))
186+
mocks.channels[0].onmessage?.(new MessageEvent('message', { data: 'permissions_required' }))
183187
)
184188
expect(mocks.connectionError).not.toHaveBeenCalled()
185189
expect(enrollment().error).toBeNull()
@@ -211,10 +215,10 @@ describe('useMemberEnrollment', () => {
211215
})
212216

213217
it.each([
214-
['existing', 'account_mismatch'],
218+
['existing', 'permissions_required'],
215219
['existing', 'denied'],
216220
['existing', 'expired'],
217-
['new', 'account_mismatch'],
221+
['new', 'permissions_required'],
218222
['new', 'denied'],
219223
['new', 'expired'],
220224
] as const)(

apps/sim/hooks/use-personal-source-account.test.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,12 @@ describe('personal source account authorization', () => {
108108
act(() => root.render(<Probe />))
109109
expect(current.pending).toBe(false)
110110
})
111-
it('shows an account mismatch as a toast and allows a fresh attempt', async () => {
111+
it('shows missing permissions as a toast and allows a fresh attempt', async () => {
112112
await act(async () => current.connect())
113-
act(() => channels[0].onmessage?.({ data: 'account_mismatch' } as MessageEvent<unknown>))
114-
expect(mocks.error).toHaveBeenCalledWith('Choose the account matching your Sim email address.')
113+
act(() => channels[0].onmessage?.({ data: 'permissions_required' } as MessageEvent<unknown>))
114+
expect(mocks.error).toHaveBeenCalledWith(
115+
'All requested permissions are required to connect this account.'
116+
)
115117
expect(current.pending).toBe(false)
116118
await act(async () => current.connect())
117119
expect(mocks.authorize).toHaveBeenCalledTimes(2)

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ describe('Atlassian managed OAuth connector', () => {
4545
requiresRefreshToken: true,
4646
pkce: false,
4747
nonceVerification: 'state_only',
48-
includeLoginHint: false,
4948
authorizationUrlParams: { audience: 'api.atlassian.com' },
5049
})
5150
expect(fetchMock).toHaveBeenCalledWith(
@@ -490,7 +489,6 @@ describe('Microsoft managed OAuth connector', () => {
490489
requiresRefreshToken: true,
491490
pkce: true,
492491
nonceVerification: 'id_token',
493-
includeLoginHint: true,
494492
prompt: 'select_account',
495493
})
496494
return policy.getAuthorizationAppId(CLIENT_ID)

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

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,12 @@ export interface ManagedOAuthConnectorConfig {
6262
*/
6363
scopeless?: boolean
6464
nonceVerification: 'id_token' | 'state_only'
65-
includeLoginHint: boolean
6665
prompt?: string
6766
authorizationUrlParams?: Record<string, string>
6867
getAuthorizationAppId(clientId: string): string
6968
verifyIdentity(params: {
7069
tokens: OAuth2Tokens
7170
clientId: string
72-
expectedEmail?: string
7371
}): Promise<ManagedOAuthConnectorIdentity>
7472
hasRequiredScopes(grantedScopes: string[], requiredScopes: string[]): boolean
7573
isTerminalRefreshError(errorCode: string | undefined): boolean
@@ -120,7 +118,6 @@ export function createGoogleManagedOAuthConnector(providerId: string): ManagedOA
120118
requiresRefreshToken: true,
121119
pkce: true,
122120
nonceVerification: 'id_token',
123-
includeLoginHint: true,
124121
prompt: 'consent select_account',
125122
authorizationUrlParams: { include_granted_scopes: 'false' },
126123
getAuthorizationAppId(clientId) {
@@ -199,7 +196,6 @@ export function createAtlassianManagedOAuthConnector(
199196
requiresRefreshToken: true,
200197
pkce: false,
201198
nonceVerification: 'state_only',
202-
includeLoginHint: false,
203199
prompt: 'consent',
204200
authorizationUrlParams: { audience: 'api.atlassian.com' },
205201
getAuthorizationAppId(clientId) {
@@ -350,7 +346,6 @@ export function createMicrosoftManagedOAuthConnector(
350346
requiresRefreshToken: true,
351347
pkce: true,
352348
nonceVerification: 'id_token',
353-
includeLoginHint: true,
354349
prompt: 'select_account',
355350
getAuthorizationAppId(clientId) {
356351
return `microsoft:${createHash('sha256').update(clientId).digest('hex')}`
@@ -521,7 +516,6 @@ export function createUserInfoManagedOAuthConnector(
521516
requiresRefreshToken: options.requiresRefreshToken,
522517
pkce: options.pkce ?? false,
523518
nonceVerification: 'state_only',
524-
includeLoginHint: false,
525519
...(options.scopeless ? { scopeless: true } : {}),
526520
...(options.prompt ? { prompt: options.prompt } : {}),
527521
...(options.authorizationUrlParams
@@ -659,7 +653,6 @@ function createAttioManagedOAuthConnector(): ManagedOAuthConnectorConfig {
659653
requiresRefreshToken: false,
660654
pkce: false,
661655
nonceVerification: 'state_only',
662-
includeLoginHint: false,
663656
getAuthorizationAppId(clientId) {
664657
return `attio:${createHash('sha256').update(clientId).digest('hex')}`
665658
},
@@ -745,7 +738,6 @@ function createBitbucketManagedOAuthConnector(): ManagedOAuthConnectorConfig {
745738
requiresRefreshToken: true,
746739
pkce: false,
747740
nonceVerification: 'state_only',
748-
includeLoginHint: false,
749741
getAuthorizationAppId(clientId) {
750742
return `bitbucket:${createHash('sha256').update(clientId).digest('hex')}`
751743
},
@@ -985,12 +977,11 @@ const USER_INFO_MANAGED_OAUTH_CONNECTORS = new Map<string, () => ManagedOAuthCon
985977
pkce: true,
986978
scopeless: true,
987979
nonceVerification: 'state_only',
988-
includeLoginHint: false,
989980
getAuthorizationAppId(clientId) {
990981
return `github-repositories:${createHash('sha256').update(clientId).digest('hex')}`
991982
},
992-
verifyIdentity({ tokens, expectedEmail }) {
993-
return verifyGitHubRepositoriesIdentity(tokens.accessToken ?? '', expectedEmail)
983+
verifyIdentity({ tokens }) {
984+
return verifyGitHubRepositoriesIdentity(tokens.accessToken ?? '')
994985
},
995986
hasRequiredScopes(_grantedScopes, requiredScopes) {
996987
return requiredScopes.length === 0

apps/sim/lib/credential-groups/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Both pages include **People → Request connections**, with the existing manual
1010

1111
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.
1212

13-
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.
13+
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.
1414

1515
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.
1616

0 commit comments

Comments
 (0)