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
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ interface IntegrationDetailProps {
children?: ReactNode
}

type IntegrationAccount = MemberIntegration['accounts'][number]

/** Sites recorded for an account whose one grant reaches several, such as Confluence or Jira. */
function sitesOf(account: IntegrationAccount) {
return 'sites' in account ? account.sites : []
}

function siteHost(url: string) {
return url.replace(/^https:\/\//, '')
}

function accountSummary(account: IntegrationAccount) {
const status = account.status === 'needs_reauth' ? 'Reconnect needed' : 'Connected'
const count = sitesOf(account).length
return count > 0 ? `${status} · ${count} ${count === 1 ? 'site' : 'sites'}` : status
}

function savedProviderIdFor(provider: string) {
const providerId = liveSearchSavedMemberProvider(provider)
return providerId === 'jama-service-account' || providerId === 'flow-service-account'
Expand Down Expand Up @@ -81,7 +98,11 @@ export function IntegrationDetail({
? undefined
: 'savedProviderId' in target
? () => savedProvider && setSavedConnection({ providerId: savedProvider })
: () => connect.mutate({ organizationId, ...target }, { onError })
: () =>
connect.mutate(
{ organizationId, ...target },
{ onError, onSuccess: () => toast.success(`${integration.name} connected`) }
)
}
onReconnect={() => {
if (!selected) return
Expand All @@ -91,7 +112,11 @@ export function IntegrationDetail({
credentialId: selected.credentialId,
displayName: selected.displayName,
})
else reconnect.mutate(selected.credentialId, { onError })
else
reconnect.mutate(selected.credentialId, {
onError,
onSuccess: () => toast.success(`${integration.name} reconnected`),
})
}}
connectionPending={connect.isPending || reconnect.isPending}
canConnect={integration.ready}
Expand All @@ -117,19 +142,33 @@ export function IntegrationDetail({
This account is no longer connected.
</SettingsEmptyState>
) : selected ? (
<SettingsField
label={selected.status === 'needs_reauth' ? 'Reconnect needed' : 'Connected'}
breakAll
>
{selected.displayName}
</SettingsField>
<div className='flex flex-col gap-4'>
<SettingsField
label={selected.status === 'needs_reauth' ? 'Reconnect needed' : 'Connected'}
breakAll
>
{selected.displayName}
</SettingsField>
{sitesOf(selected).length > 0 && (
<SettingsField label='Sites'>
<ul className='flex flex-col gap-1'>
{sitesOf(selected).map((site) => (
<li key={site.url} className='break-all text-[var(--text-body)] text-sm'>
{site.name}{' '}
<span className='text-[var(--text-muted)]'>{siteHost(site.url)}</span>
</li>
))}
</ul>
</SettingsField>
)}
</div>
) : integration.accounts.length ? (
<div className={RESOURCE_LIST_STACK}>
{integration.accounts.map((account) => (
<SettingsResourceRow
key={account.credentialId}
title={account.displayName}
description={account.status === 'needs_reauth' ? 'Reconnect needed' : 'Connected'}
description={accountSummary(account)}
href={accountHref(account.credentialId)}
clickLabel={`Open ${account.displayName} account`}
navigable
Expand Down
11 changes: 11 additions & 0 deletions apps/sim/lib/api/contracts/organization-accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/pri
import { defineRouteContract } from '@/lib/api/contracts/types'
import { ORGANIZATION_CREDENTIAL_TYPES } from '@/lib/credential-groups/credential-types'
import {
ACCOUNT_SITE_LIMIT,
ACCOUNT_SITE_NAME_MAX_LENGTH,
ACCOUNT_SITE_URL_MAX_LENGTH,
ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT,
ORGANIZATION_VIEWER_ACCOUNT_LIMIT,
} from '@/lib/credential-groups/limits'
Expand Down Expand Up @@ -79,6 +82,14 @@ export const getOrganizationAccountsContract = defineRouteContract({
groupId: z.string().min(1).max(128),
optionId: z.string().min(1).max(128),
status: z.enum(['active', 'needs_reauth']),
sites: z
.array(
z.object({
name: z.string().max(ACCOUNT_SITE_NAME_MAX_LENGTH),
url: z.string().max(ACCOUNT_SITE_URL_MAX_LENGTH),
})
)
.max(ACCOUNT_SITE_LIMIT),
})
)
.max(ORGANIZATION_VIEWER_ACCOUNT_LIMIT)
Expand Down
101 changes: 80 additions & 21 deletions apps/sim/lib/auth/connectors/managed-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,19 @@ const ATLASSIAN_SCOPES = ['read:me', 'read:jira-work', 'offline_access']

describe('Atlassian managed OAuth connector', () => {
it('uses the existing connector callback contract and verifies the current account', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
account_type: 'atlassian',
account_id: 'account-1',
email: 'person@example.com',
name: 'Person',
picture: 'https://example.com/avatar.png',
account_status: 'active',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
const fetchMock = vi.fn().mockImplementation(
async () =>
new Response(
JSON.stringify({
account_type: 'atlassian',
account_id: 'account-1',
email: 'person@example.com',
name: 'Person',
picture: 'https://example.com/avatar.png',
account_status: 'active',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
)
vi.stubGlobal('fetch', fetchMock)
const connector = createAtlassianManagedOAuthConnector('jira')
Expand Down Expand Up @@ -60,18 +61,76 @@ describe('Atlassian managed OAuth connector', () => {
})
})

const ATLASSIAN_PROFILE = {
account_type: 'atlassian',
account_id: 'account-1',
email: 'person@example.com',
account_status: 'active',
}
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
function stubAtlassian(sites: () => Response) {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) =>
url === 'https://api.atlassian.com/me' ? json(ATLASSIAN_PROFILE) : sites()
)
)
}
const verify = (provider: 'jira' | 'confluence' = 'confluence') =>
createAtlassianManagedOAuthConnector(provider).verifyIdentity({
tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: ATLASSIAN_SCOPES },
clientId: 'client-1',
})

it('records the sites the grant reaches, so an added site is visible', async () => {
stubAtlassian(() =>
json([
{ id: 'cloud-1', name: 'Acme', url: 'https://acme.example.net', scopes: [] },
{ id: 'cloud-2', name: 'Beta', url: 'https://beta.example.net/', scopes: [] },
{ id: 'cloud-3', name: 'Insecure', url: 'http://insecure.example.net' },
{ id: 'cloud-4', url: 'https://unnamed.example.net' },
])
)

expect((await verify()).sites).toEqual([
{ name: 'Acme', url: 'https://acme.example.net' },
{ name: 'Beta', url: 'https://beta.example.net' },
])
})

it.each([
['an error status', () => json({ message: 'unavailable' }, 503)],
[
'a network failure',
() => {
throw new TypeError('fetch failed')
},
],
['a malformed body', () => json({ sites: 'nope' })],
])('still connects without a site list after %s', async (_name, sites) => {
stubAtlassian(sites)
const identity = await verify('jira')
expect(identity).toMatchObject({ providerSubjectId: 'account-1' })
expect(identity.sites).toBeUndefined()
})

it('fails closed for an inactive or incomplete Atlassian identity', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
account_type: 'atlassian',
account_id: 'account-1',
account_status: 'inactive',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
vi.fn().mockImplementation(
async () =>
new Response(
JSON.stringify({
account_type: 'atlassian',
account_id: 'account-1',
account_status: 'inactive',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
)
)
const connector = createAtlassianManagedOAuthConnector('confluence')
Expand Down
42 changes: 42 additions & 0 deletions apps/sim/lib/auth/connectors/managed-oauth.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { createHash } from 'node:crypto'
import type { ManagedOAuthSite } from '@sim/db/schema'
import { isRecordLike, toRecord } from '@sim/utils/object'
import type { OAuth2Tokens } from 'better-auth/oauth2'
import type { GenericOAuthConfig } from 'better-auth/plugins'
import { OAuth2Client, type TokenPayload } from 'google-auth-library'
import { createRemoteJWKSet, type JWTPayload, jwtVerify } from 'jose'
import { buildConnectorProviders } from '@/lib/auth/connectors/providers'
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
import { boundedAccountSites } from '@/lib/credential-groups/account-sites'
import { getDocusignOAuthUrl } from '@/lib/oauth/docusign'
import { verifyGitHubRepositoriesIdentity } from '@/lib/oauth/github-repositories'
import { deriveMicrosoftEmailVerified, mapMicrosoftProfileToUser } from '@/lib/oauth/microsoft'
Expand All @@ -24,6 +26,7 @@ const GMAIL_LABELS_SCOPE = 'https://www.googleapis.com/auth/gmail.labels'
const ATLASSIAN_USER_INFO_URL = 'https://api.atlassian.com/me'
const ATLASSIAN_USER_INFO_MAX_BYTES = 256 * 1024
const ATLASSIAN_USER_INFO_TIMEOUT_MS = 10_000
const ATLASSIAN_SITES_URL = 'https://api.atlassian.com/oauth/token/accessible-resources'
const MICROSOFT_JWKS_URL = 'https://login.microsoftonline.com/common/discovery/v2.0/keys'
const MICROSOFT_OIDC_USER_INFO_URL = 'https://graph.microsoft.com/oidc/userinfo'
const MICROSOFT_OIDC_USER_INFO_MAX_BYTES = 256 * 1024
Expand All @@ -49,6 +52,7 @@ export interface ManagedOAuthConnectorIdentity {
avatarUrl?: string
nonce?: string
grantedScopes: string[]
sites?: ManagedOAuthSite[]
}

export interface ManagedOAuthConnectorConfig {
Expand Down Expand Up @@ -187,6 +191,42 @@ function requireAtlassianUserProfile(value: AtlassianUserProfile): {
}
}

function httpsOrigin(value: string): string | null {
try {
const url = new URL(value)
return url.protocol === 'https:' ? url.origin : null
} catch {
return null
}
}

/**
* The sites one Atlassian grant reaches, shown so a person can see a site they added. Display only:
* Search lists sites again on every request, so a missing or stale list never changes access, and
* a failed listing never fails the connection.
*/
async function listAtlassianSites(accessToken: string): Promise<ManagedOAuthSite[] | undefined> {
try {
const response = await fetch(ATLASSIAN_SITES_URL, {
headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
signal: AbortSignal.timeout(ATLASSIAN_USER_INFO_TIMEOUT_MS),
})
const body = await readResponseJsonWithLimit<unknown>(response, {
maxBytes: ATLASSIAN_USER_INFO_MAX_BYTES,
label: 'Atlassian accessible resources response',
})
if (!response.ok || !Array.isArray(body)) return undefined
return boundedAccountSites(
body.map((entry) => {
const site = toRecord(entry)
return { name: site.name, url: typeof site.url === 'string' ? httpsOrigin(site.url) : null }
})
)
} catch {
return undefined
}
}

/** Managed enrollment policy for Atlassian's Jira and Confluence 3LO clients. */
export function createAtlassianManagedOAuthConnector(
providerId: AtlassianManagedOAuthProviderId
Expand All @@ -205,6 +245,7 @@ export function createAtlassianManagedOAuthConnector(
if (!tokens.accessToken || !tokens.scopes?.length) {
throw new Error('Atlassian returned an incomplete authorization')
}
const sites = listAtlassianSites(tokens.accessToken)
const response = await fetch(ATLASSIAN_USER_INFO_URL, {
headers: {
Accept: 'application/json',
Expand All @@ -228,6 +269,7 @@ export function createAtlassianManagedOAuthConnector(
...(identity.name ? { displayName: identity.name } : {}),
...(identity.picture ? { avatarUrl: identity.picture } : {}),
grantedScopes: [...new Set(tokens.scopes)],
...(await sites.then((listed) => (listed ? { sites: listed } : {}))),
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
}
},
hasRequiredScopes(grantedScopes, requiredScopes) {
Expand Down
Loading
Loading