feat(credentials): add Atlassian service account credentials (#4432)

* v0.6.29: login improvements, posthog telemetry (#4026)

* feat(posthog): Add tracking on mothership abort (#4023)

Co-authored-by: Theodore Li <theo@sim.ai>

* fix(login): fix captcha headers for manual login  (#4025)

* fix(signup): fix turnstile key loading

* fix(login): fix captcha header passing

* Catch user already exists, remove login form captcha

* feat(credentials): add Atlassian service account credentials

* improvement(credentials): tighten Atlassian service account plumbing

- Collapse fetchOAuthTokenBundle into fetchOAuthToken (returns the bundle)
- Reuse serviceAccountJsonSchema in the JSON form instead of hand-rolled checks
- Use parseAtlassianErrorMessage for log details; drop one-line bearer helper
- Extract ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID/_SECRET_TYPE constants
- Use Drizzle .returning() instead of post-insert SELECT
- Helper for the duplicated 401/403 + non-OK pattern in the validator

* docs(credentials): add Atlassian service account setup guide

- New /integrations/atlassian-service-account doc covers token creation,
  scope selection, and adding the credential to Sim
- Form's "View setup guide" link now points at the doc
- Fix the existing Google form link that pointed to the wrong path

Screenshot TODOs left inline as MDX comments for the docs team.

* docs(credentials): add Atlassian service account screenshots

- Auth type picker, Sim add-credential modal, Jira block credential dropdown
- Scope-picker screenshot still TODO

* docs(credentials): add Atlassian scope picker screenshot

* fix(credentials): address greptile feedback on Atlassian SA

- Drop stale 'email and API token' copy from the service description
  (we only collect a token + domain, no email field)
- Move duplicate display-name check inside the create transaction so
  concurrent POSTs can't both pass the check and insert duplicates

* fix(docs): move Atlassian screenshots to docs/public

Docs site serves /static/* from apps/docs/public, not apps/sim/public —
matches the existing google-service-account screenshot convention.

* fix(credentials): address review feedback on Atlassian SA

- SSRF: only accept *.atlassian.net / *.jira-dev.com hosts before fetching
  tenant_info, blocking probes against localhost/internal IPs
- Confluence spaces selector: pull cloudId from the SA secret instead of
  calling accessible-resources, which 401s for scoped service-account tokens
- Case-insensitive https?:// strip so HTTPS://team.atlassian.net normalizes
  correctly

* chore: merge staging and bump API validation route baseline to 727

* perf(credentials): single-resolve in confluence spaces selector

Atlassian SAs were hitting resolveOAuthAccountId twice (once via
refreshAccessTokenIfNeeded, once directly to read cloudId) and
decrypting the secret twice (via getAtlassianServiceAccountToken
inside refresh, then again via getAtlassianServiceAccountSecret).

Resolve once up front and branch the whole flow on the result —
SA path skips refresh entirely and pulls token+cloudId from a
single secret read.

* refactor(credentials): consolidate Atlassian SA creation into /api/credentials

Atlassian service-account creation lived in its own route, contract, and
mutation hook, copy-pasting ~140 lines of insert/membership/audit/posthog
boilerplate from /api/credentials. Two endpoints means two authz paths,
two audit shapes, two TOCTOU stories — they will drift.

Fold Atlassian into the existing service_account branch of /api/credentials,
dispatching by providerId. The Atlassian validator (tenant_info + Bearer
/myself, SSRF host allowlist, typed error codes) lives in
lib/credentials/atlassian-service-account.ts and is the only Atlassian-
specific piece left. AtlassianValidationError maps to a {code, error} 400
in the existing catch block; the rest of the flow (transaction, members,
audit, posthog, dup-check) is now shared with Google SA + env credentials.

Delete:
- /api/auth/atlassian-service-account route
- contracts/atlassian-service-account.ts + barrel export
- useCreateAtlassianServiceAccount hook
- API audit baseline 727 → 726

Both forms (Google JSON-key, Atlassian token+domain) now call
useCreateWorkspaceCredential with the appropriate body shape.

* fix(credentials): close TOCTOU and restore typed errors after consolidation

- Add inner duplicate-guard inside the create transaction (DuplicateCredentialError)
  to close the race that the outer findExistingCredentialBySource leaves open.
  service_account rows have no DB-level unique index on (workspaceId, providerId,
  displayName), so this is the actual safety net. Tx-internal check applies to
  Google + env_workspace too — race-safety win for all credential types.
- Re-emit {code: 'duplicate_display_name', error: ...} on conflict so the form's
  ERROR_MESSAGES.duplicate_display_name mapping is reachable again.
- Thread Atlassian-specific audit metadata (atlassianDomain, atlassianCloudId)
  back into recordAudit; consolidation had dropped them.
- Use ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID constant in contract superRefine.
- Drop `error: any` in catch in favor of `error: unknown` + getPostgresErrorCode.

* chore(credentials): drop dead createWorkspaceCredentialBodySchema + updateWorkspaceCredentialBodySchema

Both shadowed the actually-used schemas (createCredentialBodySchema /
updateCredentialByIdBodySchema) and were missing the apiToken/domain
Atlassian fields. A future change could pick the wrong one and silently
drop those fields. Confirmed zero non-definition references in the repo
(grep across apps/, packages/, scripts/ minus build artifacts).

* fix(credentials): scope inner duplicate re-check to service_account

OAuth dedupes by accountId, env_* by envKey — both have DB-level partial
unique indexes that surface as 23505. The previous inner re-check fired
for all types and always threw DuplicateCredentialError, which mapped to
'duplicate_display_name' in the UI even when the real conflict was a
duplicate OAuth account or env key. Restrict the in-tx re-check to
service_account (the only type without a DB-level index) and let the
23505 handler emit a generic message for everything else.

---------

Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
This commit is contained in:
Theodore Li
2026-05-05 18:55:27 -04:00
committed by GitHub
co-authored by Waleed Siddharth Ganesan Vikhyath Mondreti
parent c09e0a04bd
commit 0337ccd7f3
24 changed files with 1218 additions and 383 deletions
@@ -0,0 +1,166 @@
---
title: Atlassian Service Accounts
description: Set up an Atlassian service account with a scoped API token to use Jira and Confluence in Sim workflows
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { Image } from '@/components/ui/image'
import { FAQ } from '@/components/ui/faq'
Atlassian service accounts let your workflows authenticate to Jira and Confluence as a non-human bot user — independent of any individual employee's account. Each service account has its own email, its own permissions, and its own API tokens, all managed centrally in admin.atlassian.com.
This is the recommended way to use Jira and Confluence in production workflows: no one person's OAuth consent expires, the bot's permissions are auditable, and access can be revoked without touching anyone's personal account.
## Prerequisites
You need an Atlassian organization admin to create the service account. Service accounts are an Atlassian organization-level feature — they cannot be created from a regular user account.
## Setting Up the Service Account
### 1. Create the Service Account
<Steps>
<Step>
Open [admin.atlassian.com](https://admin.atlassian.com/) and go to **Directory** → **Service accounts**
{/* TODO(screenshot): admin.atlassian.com directory page with the "Service accounts" tab highlighted */}
</Step>
<Step>
Click **Create service account**, give it a name (e.g. `sim-jira-bot`), and finish creation
</Step>
<Step>
Grant the service account access to the Atlassian sites and products it needs. Open the service account, go to **Product access**, and add Jira and/or Confluence on the relevant site
{/* TODO(screenshot): service account "Product access" tab showing Jira granted on a site */}
</Step>
</Steps>
<Callout type="info">
The service account inherits permissions from the project/space roles you grant it — exactly like a human user. If a workflow needs to write to a specific Jira project, give the service account write access to that project in Jira's project settings.
</Callout>
### 2. Create a Scoped API Token
<Steps>
<Step>
From the service account's page in admin.atlassian.com, open the **API tokens** tab and click **Create API token**
{/* TODO(screenshot): service account API tokens tab with "Create API token" button */}
</Step>
<Step>
Choose **API token** as the authentication type (not OAuth 2.0 — Sim uses the API token flow)
<div className="flex justify-center">
<Image
src="/static/credentials/atlassian/admin-auth-type-picker.png"
alt="Atlassian admin — Choose authentication type with API token selected"
width={700}
height={500}
className="my-4"
/>
</div>
</Step>
<Step>
Select the scopes the token needs. The minimum set Sim's Jira and Confluence blocks expect is:
**Jira (granular):**
```
read:jira-user
read:jira-work
write:jira-work
```
**Confluence (granular):**
```
read:confluence-content.all
read:confluence-space.summary
write:confluence-content
read:page:confluence
write:page:confluence
```
Add more scopes only if you need the corresponding operations (delete, manage webhooks, etc.). The full list of scopes Sim's blocks may use is documented in [Atlassian's developer reference](https://developer.atlassian.com/cloud/jira/platform/scopes-for-oauth-2-3LO-and-forge-apps/).
<div className="flex justify-center">
<Image
src="/static/credentials/atlassian/admin-scope-picker.png"
alt="Atlassian token scope picker filtered to App: Jira and Scope type: Classic"
width={1000}
height={600}
className="my-4"
/>
</div>
<Callout type="info">
Use the **App** and **Scope type** filters to narrow the list to the scopes you need. Filter by `App: Jira` (or `Confluence`) and `Scope type: Classic` to find the three core Jira scopes; switch to **Granular** if your org doesn't expose Classic.
</Callout>
</Step>
<Step>
Copy the token when it's shown. Atlassian only displays it once — if you close the dialog, you'll have to create a new token.
</Step>
</Steps>
<Callout type="warn">
The API token is bearer credentials for the service account. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the token at rest.
</Callout>
### 3. Find Your Site Domain
Your Atlassian site domain is the URL you use to access Jira or Confluence in your browser — for example, `your-team.atlassian.net`. Open Jira or Confluence, look at the address bar, and copy the part before the first `/`.
## Adding the Service Account to Sim
<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
</Step>
<Step>
Search for "Atlassian Service Account" and click it
{/* TODO(screenshot): Integrations page with "Atlassian Service Account" in the service list */}
</Step>
<Step>
Paste the API token, enter the site domain (e.g. `your-team.atlassian.net`), and optionally set a display name and description
<div className="flex justify-center">
<Image
src="/static/credentials/atlassian/sim-add-modal.png"
alt="Add Atlassian Service Account dialog with API token and site domain filled in"
width={420}
height={560}
className="my-6"
/>
</div>
</Step>
<Step>
Click **Add Service Account**. Sim verifies the token by calling Atlassian's `/myself` endpoint through the gateway — if it fails, you'll see a specific error explaining what went wrong.
</Step>
</Steps>
The token, domain, and discovered cloudId are encrypted before being stored.
## Using the Service Account in Workflows
Add a Jira or Confluence block to your workflow. In the credential dropdown, your Atlassian service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
<div className="flex justify-center">
<Image
src="/static/credentials/atlassian/sim-jira-block-credential.png"
alt="Jira block in a workflow with the Atlassian service account selected as the credential"
width={1000}
height={500}
className="my-4"
/>
</div>
The block calls Atlassian's API gateway (`api.atlassian.com/ex/jira/{cloudId}/...`) using the service account's token. There's no impersonation step — the service account acts as itself, with whatever permissions you granted it in admin.atlassian.com.
<FAQ items={[
{ question: "Why an API token instead of OAuth?", answer: "API tokens for service accounts don't have a 1-hour expiry and don't require any user to consent. They're issued by an org admin and are stable until you revoke them — which is what you want for an automated workflow." },
{ question: "Can a regular user create a service account?", answer: "No. Service accounts are an Atlassian organization-level feature and only an organization admin can create them." },
{ question: "Can the same service account work with both Jira and Confluence?", answer: "Yes — give the service account access to both products on your site, and include scopes for both when you create the API token. Then connect it once in Sim and use it from either Jira or Confluence blocks." },
{ question: "What if my workflow needs different permissions than the token has?", answer: "Either widen the token's scopes (revoke it and create a new one with more scopes), or grant the service account higher project/space roles in Jira or Confluence. Scope failures look like 401/403 errors with descriptive messages." },
{ question: "How do I rotate the API token?", answer: "Create a new token from the same service account in admin.atlassian.com, update the credential in Sim with the new token, and once it's working, revoke the old one." },
{ question: "Does this work with Atlassian Data Center / on-prem?", answer: "No — this integration uses Atlassian Cloud's API gateway (`api.atlassian.com`). For Data Center, use the OAuth flow or set up a self-hosted bot user." },
]} />
@@ -1,5 +1,5 @@
{
"title": "Integrations",
"pages": ["index", "google-service-account"],
"pages": ["index", "google-service-account", "atlassian-service-account"],
"defaultOpen": false
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

@@ -9,7 +9,9 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
import {
getAtlassianServiceAccountSecret,
getCredential,
getOAuthToken,
getServiceAccountToken,
@@ -118,6 +120,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
try {
if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
const secret = await getAtlassianServiceAccountSecret(resolved.credentialId)
return NextResponse.json(
{
accessToken: secret.apiToken,
cloudId: secret.cloudId,
domain: secret.domain,
},
{ status: 200 }
)
}
const accessToken = await getServiceAccountToken(
resolved.credentialId,
scopes ?? [],
+58
View File
@@ -11,6 +11,10 @@ import {
isMicrosoftProvider,
PROACTIVE_REFRESH_THRESHOLD_DAYS,
} from '@/lib/oauth/microsoft'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
} from '@/lib/oauth/types'
const logger = createLogger('OAuthUtilsAPI')
@@ -44,6 +48,7 @@ export interface ResolvedCredential {
usedCredentialTable: boolean
credentialType?: string
credentialId?: string
providerId?: string
}
/**
@@ -61,6 +66,7 @@ export async function resolveOAuthAccountId(
type: credential.type,
accountId: credential.accountId,
workspaceId: credential.workspaceId,
providerId: credential.providerId,
})
.from(credential)
.where(eq(credential.id, credentialId))
@@ -73,6 +79,7 @@ export async function resolveOAuthAccountId(
credentialId: credentialRow.id,
credentialType: 'service_account',
workspaceId: credentialRow.workspaceId,
providerId: credentialRow.providerId ?? undefined,
usedCredentialTable: true,
}
}
@@ -208,6 +215,53 @@ export async function getServiceAccountToken(
return tokenData.access_token
}
interface AtlassianServiceAccountSecret {
type: typeof ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE
apiToken: string
domain: string
cloudId: string
atlassianAccountId?: string
}
/**
* Loads the decrypted Atlassian service account secret blob for a credential.
* Throws if the credential is missing or not an Atlassian service account.
*/
export async function getAtlassianServiceAccountSecret(
credentialId: string
): Promise<AtlassianServiceAccountSecret> {
const [credentialRow] = await db
.select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!credentialRow?.encryptedServiceAccountKey) {
throw new Error('Atlassian service account secret not found')
}
const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
const parsed = JSON.parse(decrypted) as AtlassianServiceAccountSecret
if (
parsed.type !== ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE ||
!parsed.apiToken ||
!parsed.cloudId
) {
throw new Error('Stored Atlassian service account secret is malformed')
}
return parsed
}
/**
* For Atlassian service accounts, the API token IS the access token —
* blocks call api.atlassian.com/ex/jira/{cloudId}/... with `Authorization: Bearer {apiToken}`.
* No exchange or refresh is needed; we just decrypt and return the raw token.
*/
export async function getAtlassianServiceAccountToken(credentialId: string): Promise<string> {
const secret = await getAtlassianServiceAccountSecret(credentialId)
return secret.apiToken
}
/**
* Safely inserts an account record, handling duplicate constraint violations gracefully.
* If a duplicate is detected (unique constraint violation), logs a warning and returns success.
@@ -374,6 +428,10 @@ export async function refreshAccessTokenIfNeeded(
}
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
logger.info(`[${requestId}] Using Atlassian service account token for credential`)
return getAtlassianServiceAccountToken(resolved.credentialId)
}
if (!scopes?.length) {
throw new Error('Scopes are required for service account credentials')
}
+152 -41
View File
@@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { account, credential, credentialMember, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
@@ -16,14 +17,36 @@ import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
AtlassianValidationError,
normalizeAtlassianDomain,
validateAtlassianServiceAccount,
} from '@/lib/credentials/atlassian-service-account'
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
} from '@/lib/oauth/types'
import { captureServerEvent } from '@/lib/posthog/server'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CredentialsAPI')
/**
* Thrown by the inner duplicate guard inside the create transaction when a
* concurrent request slipped a row in between the outer existence check and
* our INSERT. The catch maps this to a 409 with a typed `code` so the UI can
* map to a friendly message.
*/
class DuplicateCredentialError extends Error {
constructor() {
super('duplicate_display_name')
this.name = 'DuplicateCredentialError'
}
}
interface ExistingCredentialSourceParams {
workspaceId: string
type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account'
@@ -34,11 +57,16 @@ interface ExistingCredentialSourceParams {
providerId?: string | null
}
async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) {
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0]
async function findExistingCredentialBySourceWith(
exec: DbOrTx,
params: ExistingCredentialSourceParams
) {
const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params
if (type === 'oauth' && accountId) {
const [row] = await db
const [row] = await exec
.select()
.from(credential)
.where(
@@ -53,7 +81,7 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa
}
if (type === 'env_workspace' && envKey) {
const [row] = await db
const [row] = await exec
.select()
.from(credential)
.where(
@@ -68,7 +96,7 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa
}
if (type === 'env_personal' && envKey && envOwnerUserId) {
const [row] = await db
const [row] = await exec
.select()
.from(credential)
.where(
@@ -84,7 +112,7 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa
}
if (type === 'service_account' && displayName && providerId) {
const [row] = await db
const [row] = await exec
.select()
.from(credential)
.where(
@@ -102,6 +130,17 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa
return null
}
async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) {
return findExistingCredentialBySourceWith(db, params)
}
async function findExistingCredentialBySourceTx(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
params: ExistingCredentialSourceParams
) {
return findExistingCredentialBySourceWith(tx, params)
}
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
const session = await getSession()
@@ -253,6 +292,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
envKey,
envOwnerUserId,
serviceAccountJson,
apiToken,
domain,
} = parsed.data.body
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
@@ -267,6 +308,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const resolvedEnvKey: string | null = envKey ? normalizeCredentialEnvKey(envKey) : null
let resolvedEnvOwnerUserId: string | null = null
let resolvedEncryptedServiceAccountKey: string | null = null
const extraAuditMetadata: Record<string, unknown> = {}
if (type === 'oauth') {
const [accountRow] = await db
@@ -302,34 +344,69 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId
}
} else if (type === 'service_account') {
if (!serviceAccountJson) {
return NextResponse.json(
{ error: 'serviceAccountJson is required for service account credentials' },
{ status: 400 }
)
if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
if (!apiToken || !domain) {
return NextResponse.json(
{ error: 'apiToken and domain are required for Atlassian service account credentials' },
{ status: 400 }
)
}
const normalizedDomain = normalizeAtlassianDomain(domain)
const validation = await validateAtlassianServiceAccount(apiToken, normalizedDomain)
resolvedProviderId = ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
resolvedAccountId = null
resolvedEnvOwnerUserId = null
if (!resolvedDisplayName) {
resolvedDisplayName = validation.displayName
}
const blob = JSON.stringify({
type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
apiToken,
domain: normalizedDomain,
cloudId: validation.cloudId,
atlassianAccountId: validation.accountId,
})
const { encrypted } = await encryptSecret(blob)
resolvedEncryptedServiceAccountKey = encrypted
extraAuditMetadata.atlassianDomain = normalizedDomain
extraAuditMetadata.atlassianCloudId = validation.cloudId
} else {
if (!serviceAccountJson) {
return NextResponse.json(
{ error: 'serviceAccountJson is required for service account credentials' },
{ status: 400 }
)
}
const jsonParseResult = serviceAccountJsonSchema.safeParse(serviceAccountJson)
if (!jsonParseResult.success) {
return NextResponse.json(
{
error: getValidationErrorMessage(
jsonParseResult.error,
'Invalid service account JSON'
),
},
{ status: 400 }
)
}
const parsedKey = jsonParseResult.data
resolvedProviderId = 'google-service-account'
resolvedAccountId = null
resolvedEnvOwnerUserId = null
if (!resolvedDisplayName) {
resolvedDisplayName = parsedKey.client_email
}
const { encrypted } = await encryptSecret(serviceAccountJson)
resolvedEncryptedServiceAccountKey = encrypted
}
const jsonParseResult = serviceAccountJsonSchema.safeParse(serviceAccountJson)
if (!jsonParseResult.success) {
return NextResponse.json(
{
error: getValidationErrorMessage(jsonParseResult.error, 'Invalid service account JSON'),
},
{ status: 400 }
)
}
const parsed = jsonParseResult.data
resolvedProviderId = 'google-service-account'
resolvedAccountId = null
resolvedEnvOwnerUserId = null
if (!resolvedDisplayName) {
resolvedDisplayName = parsed.client_email
}
const { encrypted } = await encryptSecret(serviceAccountJson)
resolvedEncryptedServiceAccountKey = encrypted
} else if (type === 'env_personal') {
resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id
if (resolvedEnvOwnerUserId !== session.user.id) {
@@ -428,6 +505,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
.limit(1)
await db.transaction(async (tx) => {
// service_account has no DB-level unique index on (workspaceId, providerId,
// displayName), so we re-check inside the tx. OAuth/env_* are guarded by
// partial unique indexes and fall through to the 23505 handler below.
if (type === 'service_account') {
const innerExisting = await findExistingCredentialBySourceTx(tx, {
workspaceId,
type,
displayName: resolvedDisplayName,
providerId: resolvedProviderId,
})
if (innerExisting) throw new DuplicateCredentialError()
}
await tx.insert(credential).values({
id: credentialId,
workspaceId,
@@ -508,36 +598,57 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
metadata: {
credentialType: type,
providerId: resolvedProviderId,
...extraAuditMetadata,
},
request,
})
return NextResponse.json({ credential: created }, { status: 201 })
} catch (error: any) {
if (error?.code === '23505') {
} catch (error: unknown) {
if (error instanceof AtlassianValidationError) {
logger.warn(`[${requestId}] Atlassian credential rejected: ${error.code}`, {
code: error.code,
upstreamStatus: error.status,
...error.logDetail,
})
return NextResponse.json({ code: error.code, error: error.code }, { status: 400 })
}
if (error instanceof DuplicateCredentialError) {
return NextResponse.json(
{
code: 'duplicate_display_name',
error: 'A credential with that name already exists in this workspace.',
},
{ status: 409 }
)
}
const pgCode = getPostgresErrorCode(error)
if (pgCode === '23505') {
return NextResponse.json(
{ error: 'A credential with this source already exists' },
{ status: 409 }
)
}
if (error?.code === '23503') {
if (pgCode === '23503') {
return NextResponse.json(
{ error: 'Invalid credential reference or membership target' },
{ status: 400 }
)
}
if (error?.code === '23514') {
if (pgCode === '23514') {
return NextResponse.json(
{ error: 'Credential source data failed validation checks' },
{ status: 400 }
)
}
const errAsRecord =
typeof error === 'object' && error !== null ? (error as Record<string, unknown>) : {}
logger.error(`[${requestId}] Credential create failure details`, {
code: error?.code,
detail: error?.detail,
constraint: error?.constraint,
table: error?.table,
message: error?.message,
code: pgCode,
detail: errAsRecord.detail,
constraint: errAsRecord.constraint,
table: errAsRecord.table,
message: errAsRecord.message,
})
logger.error(`[${requestId}] Failed to create credential`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
@@ -6,7 +6,12 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
import {
getAtlassianServiceAccountSecret,
refreshAccessTokenIfNeeded,
resolveOAuthAccountId,
} from '@/app/api/auth/oauth/utils'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
@@ -39,23 +44,39 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
// Resolve once so we know whether this is an Atlassian SA credential before
// doing any token / cloudId work. Atlassian SAs short-circuit the entire path:
// the API token IS the access token, and cloudId lives in the encrypted secret —
// so we skip refreshAccessTokenIfNeeded (avoids a redundant resolve+decrypt) and
// skip getConfluenceCloudId (which 401s for scoped SA tokens).
const resolved = await resolveOAuthAccountId(credential)
const isAtlassianServiceAccount =
resolved?.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID && !!resolved.credentialId
const cloudId = await getConfluenceCloudId(domain, accessToken)
let accessToken: string | null
let cloudId: string
if (isAtlassianServiceAccount) {
const secret = await getAtlassianServiceAccountSecret(resolved.credentialId!)
accessToken = secret.apiToken
cloudId = secret.cloudId
} else {
accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
cloudId = await getConfluenceCloudId(domain, accessToken)
}
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
@@ -0,0 +1,246 @@
'use client'
import { createElement, useState } from 'react'
import {
Badge,
Button,
Input,
Label,
ModalBody,
ModalFooter,
ModalHeader,
Textarea,
toast,
} from '@/components/emcn'
import { isApiClientError } from '@/lib/api/client/errors'
import type { OAuthServiceConfig } from '@/lib/oauth'
import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
interface AtlassianServiceAccountFormProps {
service: OAuthServiceConfig | null
serviceLabel: string
workspaceId: string
onBack: () => void
onCreate: (input: {
workspaceId: string
type: 'service_account'
providerId: typeof ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
apiToken: string
domain: string
displayName?: string
description?: string
}) => Promise<unknown>
onCreated: () => void
}
const DOMAIN_HINT_REGEX = /^[a-z0-9-]+\.atlassian\.net$/i
const ERROR_MESSAGES: Record<string, string> = {
invalid_credentials:
"We couldn't authenticate with that API token. Double-check the token and that the service account has access to this site.",
site_not_found:
"We couldn't find an Atlassian site at that domain. Check the spelling — it should look like your-team.atlassian.net.",
duplicate_display_name: 'A credential with that name already exists in this workspace.',
atlassian_unavailable:
"We couldn't reach Atlassian to verify these credentials. Try again in a moment.",
}
const FALLBACK_ERROR_MESSAGE = "We couldn't add this service account. Try again in a moment."
function normalizeDomain(raw: string): string {
return raw
.trim()
.replace(/^https?:\/\//i, '')
.replace(/\/+$/, '')
}
function messageForError(err: unknown): string {
if (isApiClientError(err) && err.code && ERROR_MESSAGES[err.code]) {
return ERROR_MESSAGES[err.code]
}
return FALLBACK_ERROR_MESSAGE
}
export function AtlassianServiceAccountForm({
service,
serviceLabel,
workspaceId,
onBack,
onCreate,
onCreated,
}: AtlassianServiceAccountFormProps) {
const [apiToken, setApiToken] = useState('')
const [domain, setDomain] = useState('')
const [displayName, setDisplayName] = useState('')
const [description, setDescription] = useState('')
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const trimmedToken = apiToken.trim()
const normalizedDomain = normalizeDomain(domain)
const canSubmit = trimmedToken.length > 0 && normalizedDomain.length > 0 && !isSubmitting
const showDomainHint = normalizedDomain.length > 0 && !DOMAIN_HINT_REGEX.test(normalizedDomain)
const handleSubmit = async () => {
setError(null)
if (!trimmedToken || !normalizedDomain) return
setIsSubmitting(true)
try {
await onCreate({
workspaceId,
type: 'service_account',
providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
apiToken: trimmedToken,
domain: normalizedDomain,
displayName: displayName.trim() || undefined,
description: description.trim() || undefined,
})
toast.success('Service account connected')
onCreated()
} catch (err) {
setError(messageForError(err))
} finally {
setIsSubmitting(false)
}
}
return (
<>
<ModalHeader>
<div className='flex items-center gap-2.5'>
<Button
type='button'
variant='ghost'
onClick={() => {
setError(null)
onBack()
}}
className='h-6 w-6 rounded-[4px] p-0 text-[var(--text-muted)] hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-primary)]'
aria-label='Back'
>
</Button>
<span>Add {serviceLabel}</span>
</div>
</ModalHeader>
<ModalBody>
{error && (
<div className='mb-3'>
<Badge variant='red' size='lg' dot className='max-w-full'>
{error}
</Badge>
</div>
)}
<div className='flex flex-col gap-4'>
<div className='flex items-center gap-3'>
<div className='flex h-[40px] w-[40px] flex-shrink-0 items-center justify-center rounded-[8px] bg-[var(--surface-5)]'>
{service && createElement(service.icon, { className: 'h-[18px] w-[18px]' })}
</div>
<div>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
Add {service?.name || 'Atlassian service account'}
</p>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{service?.description ||
'Use a scoped API token from a service account in admin.atlassian.com.'}
</p>
<a
href='https://docs.sim.ai/integrations/atlassian-service-account'
target='_blank'
rel='noopener noreferrer'
className='text-[12px] text-[var(--accent)] hover:underline'
>
View setup guide
</a>
</div>
</div>
<div>
<Label>
API token<span className='ml-1'>*</span>
</Label>
<Input
type='password'
value={apiToken}
onChange={(event) => {
setApiToken(event.target.value)
setError(null)
}}
placeholder='Paste API token'
autoComplete='off'
data-lpignore='true'
className='mt-1.5'
/>
<p className='mt-1 text-[11px] text-[var(--text-muted)]'>
Issued from the service account's profile in admin.atlassian.com. Stored encrypted.
</p>
</div>
<div>
<Label>
Site domain<span className='ml-1'>*</span>
</Label>
<Input
value={domain}
onChange={(event) => {
setDomain(event.target.value)
setError(null)
}}
placeholder='your-team.atlassian.net'
autoComplete='off'
data-lpignore='true'
className='mt-1.5'
/>
{showDomainHint && (
<p className='mt-1 text-[11px] text-[var(--text-tertiary)]'>
Atlassian sites usually look like <code>your-team.atlassian.net</code>. We'll strip
any leading <code>https://</code>.
</p>
)}
</div>
<div>
<Label>Display name</Label>
<Input
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder="Defaults to the account's Atlassian display name"
autoComplete='off'
data-lpignore='true'
className='mt-1.5'
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder='Optional description'
maxLength={500}
autoComplete='off'
data-lpignore='true'
className='mt-1.5 min-h-[80px] resize-none'
/>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => {
setError(null)
onBack()
}}
>
Back
</Button>
<Button variant='primary' onClick={handleSubmit} disabled={!canSubmit}>
{isSubmitting ? 'Adding...' : 'Add Service Account'}
</Button>
</ModalFooter>
</>
)
}
@@ -33,9 +33,12 @@ import {
writeOAuthReturnContext,
} from '@/lib/credentials/client-state'
import { getCanonicalScopesForProvider, getServiceConfigByProviderId } from '@/lib/oauth'
import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
import { getScopeDescription } from '@/lib/oauth/utils'
import { getUserColor } from '@/lib/workspaces/colors'
import { AtlassianServiceAccountForm } from '@/app/workspace/[workspaceId]/settings/components/integrations/atlassian-service-account-form'
import { CredentialSkeleton } from '@/app/workspace/[workspaceId]/settings/components/integrations/credential-skeleton'
import { ServiceAccountForm } from '@/app/workspace/[workspaceId]/settings/components/integrations/service-account-form'
import {
useCreateCredentialDraft,
useCreateWorkspaceCredential,
@@ -108,12 +111,6 @@ export function IntegrationsManager() {
| { type: 'kb-connectors'; knowledgeBaseId: string }
| undefined
>(undefined)
const [saJsonInput, setSaJsonInput] = useState('')
const [saDisplayName, setSaDisplayName] = useState('')
const [saDescription, setSaDescription] = useState('')
const [saError, setSaError] = useState<string | null>(null)
const [saIsSubmitting, setSaIsSubmitting] = useState(false)
const [saDragActive, setSaDragActive] = useState(false)
const { data: session } = useSession()
const currentUserId = session?.user?.id || ''
@@ -384,10 +381,6 @@ export function IntegrationsManager() {
setCreateError(null)
setCreateStep(1)
setServiceSearch('')
setSaJsonInput('')
setSaDisplayName('')
setSaDescription('')
setSaError(null)
pendingReturnOriginRef.current = undefined
}
@@ -651,117 +644,6 @@ export function IntegrationsManager() {
setShowCreateModal(true)
}, [])
const validateServiceAccountJson = (raw: string): { valid: boolean; error?: string } => {
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(raw)
} catch {
return { valid: false, error: 'Invalid JSON. Paste the full service account key file.' }
}
if (parsed.type !== 'service_account') {
return { valid: false, error: 'JSON key must have "type": "service_account".' }
}
if (!parsed.client_email || typeof parsed.client_email !== 'string') {
return { valid: false, error: 'Missing "client_email" field.' }
}
if (!parsed.private_key || typeof parsed.private_key !== 'string') {
return { valid: false, error: 'Missing "private_key" field.' }
}
if (!parsed.project_id || typeof parsed.project_id !== 'string') {
return { valid: false, error: 'Missing "project_id" field.' }
}
return { valid: true }
}
const handleCreateServiceAccount = async () => {
setSaError(null)
const trimmed = saJsonInput.trim()
if (!trimmed) {
setSaError('Paste the service account JSON key.')
return
}
const validation = validateServiceAccountJson(trimmed)
if (!validation.valid) {
setSaError(validation.error ?? 'Invalid JSON')
return
}
setSaIsSubmitting(true)
try {
await createCredential.mutateAsync({
workspaceId,
type: 'service_account',
displayName: saDisplayName.trim() || undefined,
description: saDescription.trim() || undefined,
serviceAccountJson: trimmed,
})
setShowCreateModal(false)
resetCreateForm()
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to add service account'
setSaError(message)
logger.error('Failed to create service account credential', error)
} finally {
setSaIsSubmitting(false)
}
}
const readSaJsonFile = useCallback(
(file: File) => {
if (!file.name.endsWith('.json')) {
setSaError('Only .json files are supported')
return
}
const reader = new FileReader()
reader.onload = (e) => {
const text = e.target?.result
if (typeof text === 'string') {
setSaJsonInput(text)
setSaError(null)
try {
const parsed = JSON.parse(text)
if (parsed.client_email && !saDisplayName.trim()) {
setSaDisplayName(parsed.client_email)
}
} catch {
// validation will catch this on submit
}
}
}
reader.readAsText(file)
},
[saDisplayName]
)
const handleSaFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
readSaJsonFile(file)
event.target.value = ''
}
const handleSaDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault()
event.stopPropagation()
setSaDragActive(true)
}, [])
const handleSaDragLeave = useCallback((event: React.DragEvent) => {
event.preventDefault()
event.stopPropagation()
setSaDragActive(false)
}, [])
const handleSaDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault()
event.stopPropagation()
setSaDragActive(false)
const file = event.dataTransfer.files[0]
if (file) readSaJsonFile(file)
},
[readSaJsonFile]
)
const filteredServices = useMemo(() => {
if (!serviceSearch.trim()) return oauthServiceOptions
const q = serviceSearch.toLowerCase()
@@ -965,160 +847,31 @@ export function IntegrationsManager() {
</Button>
</ModalFooter>
</>
) : selectedOAuthService?.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID ? (
<AtlassianServiceAccountForm
service={selectedOAuthService}
serviceLabel={selectedOAuthService.name || resolveProviderLabel(createOAuthProviderId)}
workspaceId={workspaceId}
onBack={() => setCreateStep(1)}
onCreate={(input) => createCredential.mutateAsync(input)}
onCreated={() => {
setShowCreateModal(false)
resetCreateForm()
}}
/>
) : (
<>
<ModalHeader>
<div className='flex items-center gap-2.5'>
<Button
type='button'
variant='ghost'
onClick={() => {
setCreateStep(1)
setSaError(null)
}}
className='h-6 w-6 rounded-[4px] p-0 text-[var(--text-muted)] hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-primary)]'
aria-label='Back'
>
</Button>
<span>
Add {selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)}
</span>
</div>
</ModalHeader>
<ModalBody>
{saError && (
<div className='mb-3'>
<Badge variant='red' size='lg' dot className='max-w-full'>
{saError}
</Badge>
</div>
)}
<div className='flex flex-col gap-4'>
<div className='flex items-center gap-3'>
<div className='flex h-[40px] w-[40px] flex-shrink-0 items-center justify-center rounded-[8px] bg-[var(--surface-5)]'>
{selectedOAuthService &&
createElement(selectedOAuthService.icon, { className: 'h-[18px] w-[18px]' })}
</div>
<div>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
Add {selectedOAuthService?.name || 'service account'}
</p>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{selectedOAuthService?.description || 'Paste or upload the JSON key file'}
</p>
<a
href='https://docs.sim.ai/credentials/google-service-account'
target='_blank'
rel='noopener noreferrer'
className='text-[12px] text-[var(--accent)] hover:underline'
>
View setup guide
</a>
</div>
</div>
<div>
<Label>
JSON Key<span className='ml-1'>*</span>
</Label>
<div
onDragOver={handleSaDragOver}
onDragLeave={handleSaDragLeave}
onDrop={handleSaDrop}
className={cn(
'relative mt-1.5 rounded-md border-2 border-dashed transition-colors',
saDragActive
? 'border-[var(--accent)] bg-[var(--accent)]/5'
: 'border-transparent'
)}
>
{saDragActive && (
<div className='pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md bg-[var(--accent)]/5'>
<p className='font-medium text-[13px] text-[var(--accent)]'>
Drop JSON key file here
</p>
</div>
)}
<Textarea
value={saJsonInput}
onChange={(event) => {
setSaJsonInput(event.target.value)
setSaError(null)
if (!saDisplayName.trim()) {
try {
const parsed = JSON.parse(event.target.value)
if (parsed.client_email) setSaDisplayName(parsed.client_email)
} catch {
// not valid yet
}
}
}}
placeholder='Paste your service account JSON key here or drag & drop a .json file...'
autoComplete='off'
data-lpignore='true'
className={cn(
'min-h-[120px] resize-none border-0 font-mono text-[12px]',
saDragActive && 'opacity-30'
)}
/>
</div>
<div className='mt-1.5'>
<label className='inline-flex cursor-pointer items-center gap-1.5 text-[12px] text-[var(--text-muted)] hover:text-[var(--text-secondary)]'>
<input
type='file'
accept='.json'
onChange={handleSaFileUpload}
className='hidden'
/>
Or upload a .json file
</label>
</div>
</div>
<div>
<Label>Display name</Label>
<Input
value={saDisplayName}
onChange={(event) => setSaDisplayName(event.target.value)}
placeholder='Auto-populated from client_email'
autoComplete='off'
data-lpignore='true'
className='mt-1.5'
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={saDescription}
onChange={(event) => setSaDescription(event.target.value)}
placeholder='Optional description'
maxLength={500}
autoComplete='off'
data-lpignore='true'
className='mt-1.5 min-h-[80px] resize-none'
/>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => {
setCreateStep(1)
setSaError(null)
}}
>
Back
</Button>
<Button
variant='primary'
onClick={handleCreateServiceAccount}
disabled={!saJsonInput.trim() || saIsSubmitting}
>
{saIsSubmitting ? 'Adding...' : 'Add Service Account'}
</Button>
</ModalFooter>
</>
<ServiceAccountForm
service={selectedOAuthService}
serviceLabel={selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)}
workspaceId={workspaceId}
setupGuideHref='https://docs.sim.ai/integrations/google-service-account'
onBack={() => setCreateStep(1)}
onCreate={(input) => createCredential.mutateAsync(input)}
onCreated={() => {
setShowCreateModal(false)
resetCreateForm()
}}
/>
)}
</ModalContent>
</Modal>
@@ -0,0 +1,277 @@
'use client'
import { createElement, useState } from 'react'
import {
Badge,
Button,
Input,
Label,
ModalBody,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import { serviceAccountJsonSchema } from '@/lib/api/contracts/credentials'
import { cn } from '@/lib/core/utils/cn'
import type { OAuthServiceConfig } from '@/lib/oauth'
interface ServiceAccountFormProps {
service: OAuthServiceConfig | null
serviceLabel: string
workspaceId: string
setupGuideHref?: string
onBack: () => void
onCreate: (input: {
workspaceId: string
type: 'service_account'
serviceAccountJson: string
displayName?: string
description?: string
}) => Promise<unknown>
onCreated: () => void
}
export function ServiceAccountForm({
service,
serviceLabel,
workspaceId,
setupGuideHref,
onBack,
onCreate,
onCreated,
}: ServiceAccountFormProps) {
const [jsonInput, setJsonInput] = useState('')
const [displayName, setDisplayName] = useState('')
const [description, setDescription] = useState('')
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [dragActive, setDragActive] = useState(false)
const readJsonFile = (file: File) => {
if (!file.name.endsWith('.json')) {
setError('Only .json files are supported')
return
}
const reader = new FileReader()
reader.onload = (e) => {
const text = e.target?.result
if (typeof text !== 'string') return
setJsonInput(text)
setError(null)
if (!displayName.trim()) {
try {
const parsed = JSON.parse(text)
if (parsed.client_email) setDisplayName(parsed.client_email)
} catch {
// surface validation on submit instead
}
}
}
reader.readAsText(file)
}
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
readJsonFile(file)
event.target.value = ''
}
const handleDragOver = (event: React.DragEvent) => {
event.preventDefault()
event.stopPropagation()
setDragActive(true)
}
const handleDragLeave = (event: React.DragEvent) => {
event.preventDefault()
event.stopPropagation()
setDragActive(false)
}
const handleDrop = (event: React.DragEvent) => {
event.preventDefault()
event.stopPropagation()
setDragActive(false)
const file = event.dataTransfer.files[0]
if (file) readJsonFile(file)
}
const handleSubmit = async () => {
setError(null)
const trimmed = jsonInput.trim()
if (!trimmed) {
setError('Paste the service account JSON key.')
return
}
const validation = serviceAccountJsonSchema.safeParse(trimmed)
if (!validation.success) {
setError(validation.error.issues[0]?.message ?? 'Invalid JSON')
return
}
setIsSubmitting(true)
try {
await onCreate({
workspaceId,
type: 'service_account',
displayName: displayName.trim() || undefined,
description: description.trim() || undefined,
serviceAccountJson: trimmed,
})
onCreated()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to add service account'
setError(message)
} finally {
setIsSubmitting(false)
}
}
return (
<>
<ModalHeader>
<div className='flex items-center gap-2.5'>
<Button
type='button'
variant='ghost'
onClick={() => {
setError(null)
onBack()
}}
className='h-6 w-6 rounded-[4px] p-0 text-[var(--text-muted)] hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-primary)]'
aria-label='Back'
>
</Button>
<span>Add {serviceLabel}</span>
</div>
</ModalHeader>
<ModalBody>
{error && (
<div className='mb-3'>
<Badge variant='red' size='lg' dot className='max-w-full'>
{error}
</Badge>
</div>
)}
<div className='flex flex-col gap-4'>
<div className='flex items-center gap-3'>
<div className='flex h-[40px] w-[40px] flex-shrink-0 items-center justify-center rounded-[8px] bg-[var(--surface-5)]'>
{service && createElement(service.icon, { className: 'h-[18px] w-[18px]' })}
</div>
<div>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
Add {service?.name || 'service account'}
</p>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{service?.description || 'Paste or upload the JSON key file'}
</p>
{setupGuideHref && (
<a
href={setupGuideHref}
target='_blank'
rel='noopener noreferrer'
className='text-[12px] text-[var(--accent)] hover:underline'
>
View setup guide
</a>
)}
</div>
</div>
<div>
<Label>
JSON Key<span className='ml-1'>*</span>
</Label>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'relative mt-1.5 rounded-md border-2 border-dashed transition-colors',
dragActive ? 'border-[var(--accent)] bg-[var(--accent)]/5' : 'border-transparent'
)}
>
{dragActive && (
<div className='pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md bg-[var(--accent)]/5'>
<p className='font-medium text-[13px] text-[var(--accent)]'>
Drop JSON key file here
</p>
</div>
)}
<Textarea
value={jsonInput}
onChange={(event) => {
setJsonInput(event.target.value)
setError(null)
if (!displayName.trim()) {
try {
const parsed = JSON.parse(event.target.value)
if (parsed.client_email) setDisplayName(parsed.client_email)
} catch {
// surface validation on submit instead
}
}
}}
placeholder='Paste your service account JSON key here or drag & drop a .json file...'
autoComplete='off'
data-lpignore='true'
className={cn(
'min-h-[120px] resize-none border-0 font-mono text-[12px]',
dragActive && 'opacity-30'
)}
/>
</div>
<div className='mt-1.5'>
<label className='inline-flex cursor-pointer items-center gap-1.5 text-[12px] text-[var(--text-muted)] hover:text-[var(--text-secondary)]'>
<input type='file' accept='.json' onChange={handleFileUpload} className='hidden' />
Or upload a .json file
</label>
</div>
</div>
<div>
<Label>Display name</Label>
<Input
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder='Auto-populated from client_email'
autoComplete='off'
data-lpignore='true'
className='mt-1.5'
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder='Optional description'
maxLength={500}
autoComplete='off'
data-lpignore='true'
className='mt-1.5 min-h-[80px] resize-none'
/>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => {
setError(null)
onBack()
}}
>
Back
</Button>
<Button
variant='primary'
onClick={handleSubmit}
disabled={!jsonInput.trim() || isSubmitting}
>
{isSubmitting ? 'Adding...' : 'Add Service Account'}
</Button>
</ModalFooter>
</>
)
}
+17 -2
View File
@@ -1,13 +1,28 @@
import { requestJson } from '@/lib/api/client/request'
import { oauthTokenContract } from '@/lib/api/contracts/selectors'
export interface OAuthTokenBundle {
accessToken: string
cloudId?: string
domain?: string
}
/**
* Returns the access token plus any provider-specific extras (e.g. `cloudId` for
* Atlassian service accounts whose tokens cannot call api.atlassian.com/oauth/token/accessible-resources).
*/
export async function fetchOAuthToken(
credentialId: string,
workflowId?: string
): Promise<string | null> {
): Promise<OAuthTokenBundle | null> {
if (!credentialId) return null
const token = await requestJson(oauthTokenContract, {
body: { credentialId, workflowId },
})
return token.accessToken ?? null
if (!token.accessToken) return null
return {
accessToken: token.accessToken,
cloudId: token.cloudId,
domain: token.domain,
}
}
@@ -67,14 +67,15 @@ export const confluenceSelectors = {
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'confluence.pages')
const domain = ensureDomain(context, 'confluence.pages')
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
if (!accessToken) {
const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Confluence access token')
}
const data = await requestJson(selectorContracts.confluencePagesSelectorContract, {
body: {
domain,
accessToken,
accessToken: bundle.accessToken,
cloudId: bundle.cloudId,
title: search,
},
signal,
@@ -88,14 +89,15 @@ export const confluenceSelectors = {
if (!detailId) return null
const credentialId = ensureCredential(context, 'confluence.pages')
const domain = ensureDomain(context, 'confluence.pages')
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
if (!accessToken) {
const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Confluence access token')
}
const data = await requestJson(selectorContracts.confluencePageSelectorContract, {
body: {
domain,
accessToken,
accessToken: bundle.accessToken,
cloudId: bundle.cloudId,
pageId: detailId,
},
signal,
@@ -23,14 +23,15 @@ export const jiraSelectors = {
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jira.projects')
const domain = ensureDomain(context, 'jira.projects')
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
if (!accessToken) {
const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Jira access token')
}
const data = await requestJson(selectorContracts.jiraProjectsSelectorContract, {
query: {
domain,
accessToken,
accessToken: bundle.accessToken,
cloudId: bundle.cloudId,
query: search,
},
signal,
@@ -44,14 +45,15 @@ export const jiraSelectors = {
if (!detailId) return null
const credentialId = ensureCredential(context, 'jira.projects')
const domain = ensureDomain(context, 'jira.projects')
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
if (!accessToken) {
const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Jira access token')
}
const data = await requestJson(selectorContracts.jiraProjectSelectorContract, {
body: {
domain,
accessToken,
accessToken: bundle.accessToken,
cloudId: bundle.cloudId,
projectId: detailId,
},
signal,
@@ -82,14 +84,15 @@ export const jiraSelectors = {
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jira.issues')
const domain = ensureDomain(context, 'jira.issues')
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
if (!accessToken) {
const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Jira access token')
}
const data = await requestJson(selectorContracts.jiraIssuesSelectorContract, {
query: {
domain,
accessToken,
accessToken: bundle.accessToken,
cloudId: bundle.cloudId,
projectId: context.projectId,
query: search,
},
@@ -110,14 +113,15 @@ export const jiraSelectors = {
if (!detailId) return null
const credentialId = ensureCredential(context, 'jira.issues')
const domain = ensureDomain(context, 'jira.issues')
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
if (!accessToken) {
const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Jira access token')
}
const data = await requestJson(selectorContracts.jiraIssueSelectorContract, {
body: {
domain,
accessToken,
accessToken: bundle.accessToken,
cloudId: bundle.cloudId,
issueKeys: [detailId],
},
signal,
+9
View File
@@ -134,6 +134,14 @@ function messageFromErrorBody(body: unknown, fallback: string): string {
return fallback
}
function codeFromErrorBody(body: unknown): string | undefined {
if (body && typeof body === 'object') {
const record = body as Record<string, unknown>
if (typeof record.code === 'string' && record.code.length > 0) return record.code
}
return undefined
}
function isSchemaValidationError(error: unknown): boolean {
return Boolean(
error &&
@@ -173,6 +181,7 @@ export async function requestJson<C extends AnyApiRouteContract>(
message: messageFromErrorBody(parsed, `Request failed with ${response.status}`),
body: parsed,
rawBody: raw,
code: codeFromErrorBody(parsed),
})
}
+20 -30
View File
@@ -1,6 +1,6 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
import type { OAuthProvider } from '@/lib/oauth/types'
import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, type OAuthProvider } from '@/lib/oauth/types'
const ENV_VAR_NAME_REGEX = /^[A-Za-z0-9_]+$/
@@ -119,6 +119,8 @@ export const createCredentialBodySchema = z
envKey: z.string().trim().min(1).optional(),
envOwnerUserId: z.string().trim().min(1).optional(),
serviceAccountJson: z.string().optional(),
apiToken: z.string().trim().min(1).optional(),
domain: z.string().trim().min(1).optional(),
})
.superRefine((data, ctx) => {
if (data.type === 'oauth') {
@@ -147,6 +149,23 @@ export const createCredentialBodySchema = z
}
if (data.type === 'service_account') {
if (data.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
if (!data.apiToken) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'apiToken is required for Atlassian service account credentials',
path: ['apiToken'],
})
}
if (!data.domain) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'domain is required for Atlassian service account credentials',
path: ['domain'],
})
}
return
}
if (!data.serviceAccountJson) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -222,35 +241,6 @@ export const createCredentialDraftBodySchema = z.object({
credentialId: z.string().min(1).optional(),
})
export const createWorkspaceCredentialBodySchema = z.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
type: workspaceCredentialTypeSchema,
displayName: z.string().optional(),
description: z.string().optional(),
providerId: z.string().optional(),
accountId: z.string().optional(),
envKey: z.string().optional(),
envOwnerUserId: z.string().optional(),
serviceAccountJson: z.string().optional(),
})
export const updateWorkspaceCredentialBodySchema = z
.object({
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).nullable().optional(),
serviceAccountJson: z.string().min(1).optional(),
})
.refine(
(data) =>
data.displayName !== undefined ||
data.description !== undefined ||
data.serviceAccountJson !== undefined,
{
message: 'At least one field must be provided',
path: ['displayName'],
}
)
export const upsertWorkspaceCredentialMemberBodySchema = z.object({
userId: z.string().min(1),
role: workspaceCredentialRoleSchema.default('member'),
@@ -77,6 +77,8 @@ const oauthTokenResponseSchema = z.object({
accessToken: z.string(),
idToken: z.string().optional(),
instanceUrl: z.string().optional(),
cloudId: z.string().optional(),
domain: z.string().optional(),
})
export const oauthTokenGetContract = defineRouteContract({
@@ -8,6 +8,8 @@ const oauthTokenResponseSchema = z
accessToken: z.string().optional(),
idToken: z.string().optional(),
instanceUrl: z.string().optional(),
cloudId: z.string().optional(),
domain: z.string().optional(),
})
.passthrough()
@@ -0,0 +1,127 @@
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
/**
* Discrete validation failure codes returned to the client. The UI maps each
* code to a human message; raw Atlassian response bodies stay in server logs.
*/
export type AtlassianValidationCode =
| 'invalid_credentials'
| 'site_not_found'
| 'atlassian_unavailable'
export class AtlassianValidationError extends Error {
constructor(
public readonly code: AtlassianValidationCode,
public readonly status: number,
public readonly logDetail?: Record<string, unknown>
) {
super(code)
this.name = 'AtlassianValidationError'
}
}
/**
* Atlassian Cloud sites are always served from `*.atlassian.net` (production)
* or `*.jira-dev.com` (Atlassian's developer sandbox). Anything else is either
* a typo (`atlassian.com`, `jira.com`), a Data Center hostname (which our
* gateway URL doesn't support), or — worse — an attempt to point this
* server-side fetch at internal infrastructure (`localhost`, `169.254.169.254`,
* `*.corp`). Restricting to the public Atlassian Cloud suffixes blocks SSRF
* at the boundary before any outbound request.
*/
const ATLASSIAN_CLOUD_HOST_REGEX =
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.(?:atlassian\.net|jira-dev\.com)$/i
export function normalizeAtlassianDomain(rawDomain: string): string {
return rawDomain.replace(/^https?:\/\//i, '').replace(/\/+$/, '')
}
function assertAtlassianCloudHost(domain: string): void {
if (!ATLASSIAN_CLOUD_HOST_REGEX.test(domain)) {
throw new AtlassianValidationError('site_not_found', 400, {
step: 'host_validation',
domain,
reason: 'host is not an Atlassian Cloud site (expected *.atlassian.net)',
})
}
}
/**
* Throws an `AtlassianValidationError` with `unauthorizedCode` for 401/403 responses
* (which mean the token itself was rejected) and `atlassian_unavailable` for any
* other non-2xx.
*/
async function assertAtlassianResponseOk(
res: Response,
step: string,
unauthorizedCode: AtlassianValidationCode,
context: Record<string, unknown> = {}
): Promise<void> {
if (res.ok) return
const body = parseAtlassianErrorMessage(res.status, res.statusText, await res.text())
if (res.status === 401 || res.status === 403) {
throw new AtlassianValidationError(unauthorizedCode, res.status, { step, body, ...context })
}
throw new AtlassianValidationError('atlassian_unavailable', res.status, {
step,
body,
...context,
})
}
/**
* Validates an Atlassian service account scoped API token.
*
* Scoped service-account tokens cannot call `api.atlassian.com/oauth/token/accessible-resources`
* (that endpoint is for OAuth-3LO tokens). Instead we use the public, unauthenticated
* `tenant_info` discovery endpoint to resolve cloudId from the site domain, then verify
* the token works by hitting `/myself` through the gateway.
*/
export async function validateAtlassianServiceAccount(
apiToken: string,
domain: string
): Promise<{ accountId: string; displayName: string; cloudId: string }> {
assertAtlassianCloudHost(domain)
const tenantInfoRes = await fetch(`https://${domain}/_edge/tenant_info`, {
headers: { Accept: 'application/json' },
})
if (tenantInfoRes.status === 404) {
throw new AtlassianValidationError('site_not_found', 404, { step: 'tenant_info', domain })
}
// tenant_info is unauthenticated, so there is no "invalid credentials" branch here —
// any non-OK that isn't a 404 means Atlassian is unavailable, not the token's fault.
await assertAtlassianResponseOk(tenantInfoRes, 'tenant_info', 'atlassian_unavailable', { domain })
const tenantInfo = (await tenantInfoRes.json()) as { cloudId?: string }
if (!tenantInfo.cloudId) {
throw new AtlassianValidationError('atlassian_unavailable', 502, {
step: 'tenant_info',
reason: 'missing cloudId in response',
domain,
})
}
const cloudId = tenantInfo.cloudId
const myselfRes = await fetch(`https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/myself`, {
headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' },
})
await assertAtlassianResponseOk(myselfRes, 'myself', 'invalid_credentials', { cloudId })
const myself = (await myselfRes.json()) as {
accountId?: string
displayName?: string
emailAddress?: string
}
if (!myself.accountId) {
throw new AtlassianValidationError('atlassian_unavailable', 502, {
step: 'myself',
reason: 'missing accountId in response',
})
}
return {
accountId: myself.accountId,
displayName: myself.displayName || myself.emailAddress || domain,
cloudId,
}
}
+19
View File
@@ -427,6 +427,23 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
},
defaultService: 'x',
},
atlassian: {
name: 'Atlassian',
icon: JiraIcon,
services: {
'atlassian-service-account': {
name: 'Atlassian Service Account',
description:
'Authenticate as an Atlassian service account using a scoped API token from admin.atlassian.com.',
providerId: 'atlassian-service-account',
icon: JiraIcon,
baseProviderIcon: JiraIcon,
scopes: [],
authType: 'service_account',
},
},
defaultService: 'atlassian-service-account',
},
confluence: {
name: 'Confluence',
icon: ConfluenceIcon,
@@ -437,6 +454,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'confluence',
icon: ConfluenceIcon,
baseProviderIcon: ConfluenceIcon,
serviceAccountProviderId: 'atlassian-service-account',
scopes: [
'read:confluence-content.all',
'read:confluence-space.summary',
@@ -489,6 +507,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'jira',
icon: JiraIcon,
baseProviderIcon: JiraIcon,
serviceAccountProviderId: 'atlassian-service-account',
scopes: [
'read:jira-user',
'read:jira-work',
+14
View File
@@ -1,5 +1,17 @@
import type { ReactNode } from 'react'
/**
* Stable identifier for the Atlassian service account provider. Used as the
* `providerId` on credential rows and as the `serviceAccountProviderId` on
* Jira/Confluence service configs.
*/
export const ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID = 'atlassian-service-account' as const
/**
* Discriminator stored inside the encrypted Atlassian service account secret blob.
*/
export const ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE = 'atlassian_service_account' as const
export type OAuthProvider =
| 'google'
| 'google-email'
@@ -21,6 +33,7 @@ export type OAuthProvider =
| 'airtable'
| 'notion'
| 'jira'
| 'atlassian-service-account'
| 'box'
| 'dropbox'
| 'microsoft'
@@ -72,6 +85,7 @@ export type OAuthService =
| 'airtable'
| 'notion'
| 'jira'
| 'atlassian-service-account'
| 'box'
| 'dropbox'
| 'microsoft-ad'
+6
View File
@@ -904,6 +904,12 @@ export async function executeTool(
if (data.instanceUrl) {
contextParams.instanceUrl = data.instanceUrl
}
if (data.cloudId && !contextParams.cloudId) {
contextParams.cloudId = data.cloudId
}
if (data.domain && !contextParams.domain) {
contextParams.domain = data.domain
}
logger.info(`[${requestId}] Successfully got access token for ${toolId}`)