Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/sim/app/api/credential-groups/oauth-callback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
8 changes: 3 additions & 5 deletions apps/sim/app/api/credential-groups/oauth-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
])(
Expand Down
1 change: 0 additions & 1 deletion apps/sim/app/credential-groups/enroll/[token]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
18 changes: 11 additions & 7 deletions apps/sim/hooks/use-member-enrollment.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)(
Expand Down
8 changes: 5 additions & 3 deletions apps/sim/hooks/use-personal-source-account.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,12 @@ describe('personal source account authorization', () => {
act(() => root.render(<Probe />))
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<unknown>))
expect(mocks.error).toHaveBeenCalledWith('Choose the account matching your Sim email address.')
act(() => channels[0].onmessage?.({ data: 'permissions_required' } as MessageEvent<unknown>))
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)
Expand Down
2 changes: 0 additions & 2 deletions apps/sim/lib/auth/connectors/managed-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 2 additions & 11 deletions apps/sim/lib/auth/connectors/managed-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,12 @@ export interface ManagedOAuthConnectorConfig {
*/
scopeless?: boolean
nonceVerification: 'id_token' | 'state_only'
includeLoginHint: boolean
prompt?: string
authorizationUrlParams?: Record<string, string>
getAuthorizationAppId(clientId: string): string
verifyIdentity(params: {
tokens: OAuth2Tokens
clientId: string
expectedEmail?: string
}): Promise<ManagedOAuthConnectorIdentity>
hasRequiredScopes(grantedScopes: string[], requiredScopes: string[]): boolean
isTerminalRefreshError(errorCode: string | undefined): boolean
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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')}`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')}`
},
Expand Down Expand Up @@ -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')}`
},
Expand Down Expand Up @@ -985,12 +977,11 @@ const USER_INFO_MANAGED_OAUTH_CONNECTORS = new Map<string, () => 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
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/credential-groups/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 2 additions & 3 deletions apps/sim/lib/credential-groups/oauth-completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
23 changes: 16 additions & 7 deletions apps/sim/lib/credential-groups/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, [])
Expand Down Expand Up @@ -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])(
Expand Down Expand Up @@ -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',
})
})
Expand Down Expand Up @@ -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',
})
})
Expand Down Expand Up @@ -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',
})
})
Expand Down
Loading
Loading