fix(jira): support scoped Cloud API tokens (#28604)

Support scoped Jira Cloud API tokens through the configured gateway while keeping self-hosted routing, redirects, diagnostics, and selected-host capabilities bounded.

Co-authored-by: Jerome Würf <oss@scharom.de>
This commit is contained in:
Neil
2026-10-14 03:11:32 -07:00
committed by GitHub
co-authored by Jerome Würf
parent 64538a680c
commit cf119167a5
22 changed files with 1025 additions and 170 deletions
+8 -2
View File
@@ -16,8 +16,14 @@ Jira sits next to GitHub and Linear in the task drawer. Browse Jira issues, upda
**Cloud**
- **Jira Cloud site URL** — e.g. `https://example.atlassian.net`.
- **Atlassian email** — the address on your Atlassian account.
- **Atlassian API token** — create one at [id.atlassian.com → Security → API tokens](https://id.atlassian.com/manage-profile/security/api-tokens).
- **Atlassian email** — the address on your Atlassian account. Optional for scoped tokens: leave it empty to use Bearer authentication.
- **Atlassian API token** — create one at [id.atlassian.com → Security → API tokens](https://id.atlassian.com/manage-profile/security/api-tokens), with or without scopes.
Scoped tokens need `read:jira-work` to browse issues, `write:jira-work` to create or edit issues and comments, and `read:jira-user` to verify the account and choose assignees. Board column ordering additionally needs `read:board-scope:jira-software`, `read:board-scope.admin:jira-software`, and `read:project:jira`; without these, statuses sort alphabetically.
Orca looks up the site's cloud ID without sending the token, then routes scoped-token API requests through `https://api.atlassian.com/ex/jira/<cloudId>`. With an email, it tries the site first and retries on the gateway only after a 401. Browse links keep using the site URL. A missing scope reports the endpoint and required access without removing the saved credential.
For SSH connections, the remote runtime owns the token and API requests. Older remote runtimes still require an email and an unscoped token; update the remote runtime to enable scoped tokens.
**Self-hosted**
@@ -0,0 +1,62 @@
import type * as Os from 'node:os'
import { vi, type Mock } from 'vitest'
export type JiraClientMocks = {
closeAllConnectionsMock: Mock
netFetchMock: Mock
resolveProxyMock: Mock
setProxyMock: Mock
}
export type SafeStorageMockOptions = {
encryptionAvailable?: boolean
decryptString?: (value: Buffer) => string
}
export async function loadJiraClientModule(
mocks: JiraClientMocks,
tempHome: string,
options: SafeStorageMockOptions = {}
) {
const { closeAllConnectionsMock, netFetchMock, resolveProxyMock, setProxyMock } = mocks
vi.resetModules()
vi.doMock('electron', () => ({
net: { fetch: netFetchMock },
session: {
defaultSession: {
closeAllConnections: closeAllConnectionsMock,
resolveProxy: resolveProxyMock,
setProxy: setProxyMock
}
}
}))
// Why here and not in beforeEach: vi.resetModules() above gives the http-client module
// a fresh singleton, so the port must be installed on that instance. The electron net
// mock alone is inert now that Jira fetches through the port.
const { setMainHttpClient } = await import('../../network/http-client')
setMainHttpClient({
fetch: (url, init) => netFetchMock(url, init),
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These are the only session methods the proxy fixture exercises.
proxySession: () => ({ resolveProxy: resolveProxyMock, setProxy: setProxyMock }) as never
})
const { setSecretStore } = await import('../../../shared/secret-store')
setSecretStore({
isEncryptionAvailable: () => options.encryptionAvailable ?? false,
encryptString: (value) => Buffer.from(value),
decryptString: options.decryptString ?? ((value) => value.toString('utf-8')),
describeProtectionGap: () => null
})
vi.doMock('os', async () => {
const actual = await vi.importActual<typeof Os>('os')
return { ...actual, homedir: () => tempHome }
})
// One import call per reset so the split modules share a single graph (and
// thus one copy of the request queue / credential caches) per test.
const [client, queue, api] = await Promise.all([
import('../client'),
import('../request-queue'),
import('../authenticated-request')
])
return { ...client, ...queue, ...api }
}
+55
View File
@@ -0,0 +1,55 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
export type JiraFixtureRequest = {
url: string
authorization: string | undefined
method: string | undefined
}
export async function startJiraAuthFixture(
respond: (request: JiraFixtureRequest, response: ServerResponse, raw: IncomingMessage) => void
) {
const requests: JiraFixtureRequest[] = []
const server = createServer((raw, response) => {
const header = raw.headers['x-jira-fixture-url']
const request = {
url: typeof header === 'string' ? header : '',
authorization: raw.headers.authorization,
method: raw.method
}
requests.push(request)
respond(request, response, raw)
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('Expected a TCP Jira fixture')
}
return {
requests,
fetch: (url: string, init?: RequestInit) => {
const headers = new Headers(init?.headers)
headers.set('x-jira-fixture-url', url)
return fetch(`http://127.0.0.1:${address.port}${new URL(url).pathname}`, {
...init,
headers
})
},
close: () =>
new Promise<void>((resolve, reject) => {
if (!server.listening) {
resolve()
return
}
server.closeAllConnections()
server.close((error) =>
error && error.message !== 'Server is not running.' ? reject(error) : resolve()
)
})
}
}
export function jiraFixtureJson(response: ServerResponse, status: number, body: unknown): void {
response.writeHead(status, { 'Content-Type': 'application/json' })
response.end(JSON.stringify(body))
}
+53 -47
View File
@@ -2,6 +2,10 @@ import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings'
import { getMainHttpClient } from '../network/http-client'
import { withSpan } from '../observability/tracer'
import type { JiraAuthType, JiraSite } from '../../shared/jira-types'
import { isJiraGatewayBaseUrl } from './gateway-base-url'
import { JiraApiError, toJiraApiError } from './jira-api-error'
export { JiraApiError } from './jira-api-error'
// Why: Atlassian's XSRF filter rejects POST/PUT REST calls that carry a browser
// User-Agent, failing them with "XSRF check failed" even under API-token auth.
@@ -22,21 +26,22 @@ export function apiBasePath(site: JiraSite): string {
return site.authType === 'server' ? '/rest/api/2' : '/rest/api/3'
}
export class JiraApiError extends Error {
status: number | null
constructor(message: string, status: number | null = null) {
super(message)
this.status = status
export function apiBaseUrl(site: JiraSite): string {
if (site.authType !== 'server' && site.apiBaseUrl !== undefined) {
if (!isJiraGatewayBaseUrl(site.apiBaseUrl)) {
throw new JiraApiError('Invalid Jira gateway URL. Reconnect this Jira site.')
}
return site.apiBaseUrl
}
return site.siteUrl
}
export function authHeader(email: string, apiToken: string, authType?: JiraAuthType): string {
export function authHeader(email: string, apiToken: string, _authType?: JiraAuthType): string {
// Self-hosted with no username = a personal access token (Bearer); Basic auth
// with a PAT in the password slot is what produces the 401s users report.
// Self-hosted WITH a username is classic username+password Basic auth, which
// older Server/DC instances (predating PATs) require. Cloud is always Basic.
if (authType === 'server' && !email) {
// older Server/DC instances require. Email-free Cloud tokens use the gateway.
if (!email) {
return `Bearer ${apiToken}`
}
return `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}`
@@ -53,7 +58,7 @@ function describeErrorCause(error: unknown): string | undefined {
return cause === undefined ? undefined : String(cause)
}
async function jiraFetch(url: string, init: RequestInit): Promise<Response> {
export async function jiraFetch(url: string, init: RequestInit): Promise<Response> {
return withSpan(
'jira.request',
async (span) => {
@@ -109,10 +114,11 @@ export async function requestWithCredentials(
headers.set('Authorization', authHeader(email, apiToken, authType))
const response = await jiraFetch(`${siteUrl}${path}`, {
...init,
headers
headers,
...(isJiraGatewayBaseUrl(siteUrl) ? { redirect: 'error' as const } : {})
})
if (!response.ok) {
throw new JiraApiError(await readJiraError(response), response.status)
throw await toJiraApiError(response, `${siteUrl}${path}`)
}
if (response.status === 204) {
return null
@@ -120,27 +126,6 @@ export async function requestWithCredentials(
return response.json()
}
async function readJiraError(response: Response): Promise<string> {
try {
const data = (await response.json()) as {
errorMessages?: string[]
errors?: Record<string, string>
message?: string
}
const messages = [
...(Array.isArray(data.errorMessages) ? data.errorMessages : []),
...Object.values(data.errors ?? {}),
...(data.message ? [data.message] : [])
].filter(Boolean)
if (messages.length > 0) {
return messages.join('; ')
}
} catch {
// Fall through to status text.
}
return response.statusText || `Jira request failed (${response.status})`
}
export async function jiraRequest<T>(
client: JiraClientForSite,
path: string,
@@ -151,12 +136,14 @@ export async function jiraRequest<T>(
headers.set('Content-Type', 'application/json')
headers.set('User-Agent', JIRA_API_USER_AGENT)
headers.set('Authorization', client.authorization)
const response = await jiraFetch(`${client.site.siteUrl}${path}`, {
const url = `${apiBaseUrl(client.site)}${path}`
const response = await jiraFetch(url, {
...init,
headers
headers,
...(isJiraGatewayBaseUrl(apiBaseUrl(client.site)) ? { redirect: 'error' as const } : {})
})
if (!response.ok) {
throw new JiraApiError(await readJiraError(response), response.status)
throw await toJiraApiError(response, url)
}
if (response.status === 204) {
return null as T
@@ -164,28 +151,47 @@ export async function jiraRequest<T>(
return (await response.json()) as T
}
function resolveBinaryRequestUrl(site: JiraSite, pathOrUrl: string): URL {
const siteUrl = new URL(site.siteUrl)
const baseUrl = apiBaseUrl(site)
const requestUrl = /^https?:\/\//i.test(pathOrUrl)
? new URL(pathOrUrl)
: new URL(`${site.siteUrl}${pathOrUrl}`)
if (!requestUrl.username && !requestUrl.password) {
if (baseUrl !== site.siteUrl && requestUrl.href.startsWith(`${baseUrl}/`)) {
return requestUrl
}
if (requestUrl.origin === siteUrl.origin) {
if (baseUrl === site.siteUrl) {
return requestUrl
}
if (requestUrl.pathname.startsWith('/rest/')) {
return new URL(`${baseUrl}${requestUrl.pathname}${requestUrl.search}`)
}
throw new JiraApiError('Scoped Jira attachments require a REST content URL.', null)
}
}
// The gateway origin is shared by every tenant, so its full cloud-ID prefix must match.
throw new JiraApiError('Jira attachment URL must use the configured site origin.', null)
}
export async function jiraRequestBinary(
client: JiraClientForSite,
pathOrUrl: string
): Promise<{ data: ArrayBuffer; contentType: string }> {
const siteUrl = new URL(client.site.siteUrl)
const requestUrl = /^https?:\/\//i.test(pathOrUrl)
? new URL(pathOrUrl)
: new URL(`${client.site.siteUrl}${pathOrUrl}`)
if (requestUrl.origin !== siteUrl.origin) {
// Why: attachment metadata is provider-controlled; never forward Jira
// credentials if a malformed response points at another origin.
throw new JiraApiError('Jira attachment URL must use the configured site origin.', null)
}
const requestUrl = resolveBinaryRequestUrl(client.site, pathOrUrl)
const headers = new Headers()
// Why: attachment content is binary; forcing JSON Accept/Content-Type can
// break downloads and confuses some Atlassian edge responses.
headers.set('Accept', '*/*')
headers.set('User-Agent', JIRA_API_USER_AGENT)
headers.set('Authorization', client.authorization)
const response = await jiraFetch(requestUrl.toString(), { headers })
const response = await jiraFetch(requestUrl.toString(), {
headers,
...(isJiraGatewayBaseUrl(apiBaseUrl(client.site)) ? { redirect: 'error' as const } : {})
})
if (!response.ok) {
throw new JiraApiError(await readJiraError(response), response.status)
throw await toJiraApiError(response, requestUrl.toString())
}
const contentType = response.headers.get('content-type') || 'application/octet-stream'
return {
+368
View File
@@ -0,0 +1,368 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadJiraClientModule } from './__mocks__/jira-client-module'
import { jiraFixtureJson, startJiraAuthFixture } from './auth-http-fixture'
const SITE = 'https://example.atlassian.net'
const GATEWAY = 'https://api.atlassian.com/ex/jira/cloud-abc'
const VIEWER = { accountId: 'account-alpha', displayName: 'Ada' }
const CREDENTIAL = { siteUrl: SITE, email: 'ada@example.com', apiToken: 'private-token' }
const mocks = vi.hoisted(() => ({
closeAllConnectionsMock: vi.fn(),
netFetchMock: vi.fn(),
resolveProxyMock: vi.fn(),
setProxyMock: vi.fn()
}))
let tempHome = ''
let fixture: Awaited<ReturnType<typeof startJiraAuthFixture>> | undefined
async function loadClient() {
const jira = await loadJiraClientModule(mocks, tempHome, { encryptionAvailable: true })
const { setMainHttpClient } = await import('../network/http-client')
setMainHttpClient({
fetch: (url, init) => {
if (!fixture) {
throw new Error('Start the Jira HTTP fixture first')
}
return fixture.fetch(url, init)
},
proxySession: () => null
})
return jira
}
beforeEach(() => {
tempHome = mkdtempSync(join(tmpdir(), 'orca-jira-scoped-'))
})
afterEach(async () => {
await fixture?.close()
fixture = undefined
rmSync(tempHome, { recursive: true, force: true })
})
describe('scoped Jira Cloud tokens over HTTP', () => {
it('refuses redirects from gateway credential, API and attachment requests', async () => {
fixture = await startJiraAuthFixture((request, response) => {
if (request.url.endsWith('/_edge/tenant_info')) {
jiraFixtureJson(response, 200, { cloudId: 'cloud-abc' })
} else {
response.writeHead(302, { Location: `${GATEWAY}/other-tenant` })
response.end()
}
})
const jira = await loadClient()
await expect(jira.connect({ ...CREDENTIAL, email: '' })).resolves.toMatchObject({ ok: false })
expect(fixture.requests).toHaveLength(2)
const client = {
site: {
id: 'scoped',
siteUrl: SITE,
email: '',
displayName: 'Ada',
accountId: 'account-alpha',
authType: 'cloud' as const,
apiBaseUrl: GATEWAY
},
authorization: 'Bearer private-token'
}
await expect(jira.jiraRequest(client, '/rest/api/3/issue/ALP-1')).rejects.toThrow()
await expect(
jira.jiraRequestBinary(client, `${SITE}/rest/api/3/attachment/content/1`)
).rejects.toThrow()
expect(fixture.requests).toHaveLength(4)
expect(fixture.requests.every((request) => !request.url.endsWith('/other-tenant'))).toBe(true)
})
it('revokes cached attachment bytes only after replacing a verified credential', async () => {
let authorized = true
fixture = await startJiraAuthFixture((request, response) => {
if (request.url.endsWith('/_edge/tenant_info')) {
jiraFixtureJson(response, 200, { cloudId: 'cloud-abc' })
} else {
jiraFixtureJson(response, authorized ? 200 : 401, authorized ? VIEWER : {})
}
})
const jira = await loadClient()
await jira.connect(CREDENTIAL)
const client = jira.getClients()[0]
if (!client) {
throw new Error('Expected connected Jira client')
}
const cache = await import('./attachment-image-cache')
cache.setCachedAttachmentDataUrl({
siteId: client.site.id,
attachmentId: 'image',
dataUrl: 'data:image/png;base64,AQ==',
byteSize: 1
})
authorized = false
await expect(
jira.connect({ ...CREDENTIAL, apiToken: 'rejected-token' })
).resolves.toMatchObject({ ok: false })
expect(cache.getCachedAttachmentDataUrl(client.site.id, 'image')).not.toBeNull()
authorized = true
await expect(
jira.connect({ ...CREDENTIAL, apiToken: 'replacement-token' })
).resolves.toMatchObject({ ok: true })
expect(cache.getCachedAttachmentDataUrl(client.site.id, 'image')).toBeNull()
expect(jira.getClients()[0]?.authorization).toBe(
`Basic ${Buffer.from('ada@example.com:replacement-token').toString('base64')}`
)
cache._resetAttachmentImageCache()
})
it('preserves the installation path in self-hosted attachment URLs', async () => {
fixture = await startJiraAuthFixture((_request, response) => {
jiraFixtureJson(response, 200, { image: true })
})
const jira = await loadClient()
const attachmentUrl = `${SITE}/jira/secure/attachment/1/shot.png?download=true`
await jira.jiraRequestBinary(
{
site: {
id: 'server',
siteUrl: `${SITE}/jira`,
email: '',
displayName: 'Ada',
accountId: 'ada',
authType: 'server'
},
authorization: 'Bearer private-token'
},
attachmentUrl
)
expect(fixture.requests.map((request) => request.url)).toEqual([attachmentUrl])
})
it('resolves the tenant without a credential and connects an email-free Bearer token', async () => {
fixture = await startJiraAuthFixture((request, response) => {
if (request.url === `${SITE}/_edge/tenant_info`) {
jiraFixtureJson(response, 200, { cloudId: 'cloud-abc' })
} else if (request.url === `${GATEWAY}/rest/api/3/myself`) {
jiraFixtureJson(response, 200, VIEWER)
} else {
jiraFixtureJson(response, 401, {})
}
})
const jira = await loadClient()
await expect(jira.connect({ ...CREDENTIAL, email: '' })).resolves.toMatchObject({
ok: true,
viewer: VIEWER
})
expect(fixture.requests).toEqual([
{ url: `${SITE}/_edge/tenant_info`, authorization: undefined, method: 'GET' },
{
url: `${GATEWAY}/rest/api/3/myself`,
authorization: 'Bearer private-token',
method: 'GET'
}
])
})
it('retries site-rejected Basic tokens on the gateway and keeps that route after reload', async () => {
fixture = await startJiraAuthFixture((request, response) => {
if (request.url === `${SITE}/_edge/tenant_info`) {
jiraFixtureJson(response, 200, { cloudId: 'cloud-abc' })
} else if (request.url.startsWith(`${GATEWAY}/`)) {
jiraFixtureJson(response, 200, VIEWER)
} else {
jiraFixtureJson(response, 401, { errorMessages: ['Client must be authenticated.'] })
}
})
const jira = await loadClient()
await expect(jira.connect(CREDENTIAL)).resolves.toMatchObject({ ok: true })
const stored = JSON.parse(readFileSync(join(tempHome, '.orca', 'jira-sites.json'), 'utf8'))
expect(stored.sites[0]).toMatchObject({
siteUrl: SITE,
authType: 'cloud',
apiBaseUrl: GATEWAY
})
const reloaded = await loadClient()
await expect(reloaded.testConnection()).resolves.toMatchObject({ ok: true })
expect(fixture.requests.at(-1)).toMatchObject({
url: `${GATEWAY}/rest/api/3/myself`,
authorization: `Basic ${Buffer.from('ada@example.com:private-token').toString('base64')}`
})
expect(reloaded.getStatus()).toMatchObject({ supportsScopedTokens: true })
})
it('routes reads, edits and site attachment links through the selected gateway tenant', async () => {
fixture = await startJiraAuthFixture((request, response) => {
if (request.url.endsWith('/_edge/tenant_info')) {
jiraFixtureJson(response, 200, { cloudId: 'cloud-abc' })
} else if (request.url.endsWith('/myself')) {
jiraFixtureJson(response, 200, VIEWER)
} else {
jiraFixtureJson(response, 200, { issues: [] })
}
})
const jira = await loadClient()
await jira.connect({ ...CREDENTIAL, email: '' })
const client = jira.getClients()[0]
if (!client) {
throw new Error('Expected connected scoped Jira client')
}
await jira.jiraRequest(client, '/rest/api/3/issue/ALP-1', { method: 'PUT', body: '{}' })
await jira.jiraRequestBinary(client, `${SITE}/rest/api/3/attachment/content/1?redirect=false`)
expect(fixture.requests.slice(-2).map((request) => request.url)).toEqual([
`${GATEWAY}/rest/api/3/issue/ALP-1`,
`${GATEWAY}/rest/api/3/attachment/content/1?redirect=false`
])
for (const url of [
'https://evil.example/attachment.png',
`${SITE}/secure/attachment/1/shot.png`,
`${SITE}/rest-other/attachment/1/shot.png`,
'https://api.atlassian.com/ex/jira/other-cloud/rest/api/3/attachment/content/1',
`${GATEWAY}-other/rest/api/3/attachment/content/1`,
`${GATEWAY}/../other-cloud/rest/api/3/attachment/content/1`
]) {
await expect(jira.jiraRequestBinary(client, url)).rejects.toThrow(
/configured site|REST content URL/
)
}
expect(fixture.requests).toHaveLength(4)
})
it('retains saved credentials when a later search lacks scopes', async () => {
fixture = await startJiraAuthFixture((request, response) => {
if (request.url.endsWith('/_edge/tenant_info')) {
jiraFixtureJson(response, 200, { cloudId: 'cloud-abc' })
} else if (request.url.endsWith('/myself')) {
jiraFixtureJson(response, 200, VIEWER)
} else {
jiraFixtureJson(response, 401, { message: 'Unauthorized; scope does not match' })
}
})
const jira = await loadClient()
await jira.connect({ ...CREDENTIAL, email: '' })
const { listIssues } = await import('./jira-issue-search')
await expect(listIssues()).rejects.toThrow('scope')
expect(jira.getStatus().connected).toBe(true)
expect(jira.getClients()).toHaveLength(1)
})
it('recognizes scope failures in authentication headers without clearing the token', async () => {
fixture = await startJiraAuthFixture((_request, response) => {
response.setHeader('WWW-Authenticate', 'Bearer error="insufficient_scope"')
jiraFixtureJson(response, 401, {})
})
const jira = await loadClient()
const error = await jira
.requestWithCredentials(GATEWAY, '', 'private-token', '/rest/api/3/myself')
.catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(jira.JiraApiError)
expect(jira.isAuthError(error)).toBe(false)
})
it('identifies both rejected endpoints safely, including a reflected secret', async () => {
fixture = await startJiraAuthFixture((request, response) => {
if (request.url.endsWith('/_edge/tenant_info')) {
jiraFixtureJson(response, 200, { cloudId: 'cloud-abc' })
} else {
jiraFixtureJson(response, 401, { message: 'Rejected private-token' })
}
})
const jira = await loadClient()
const result = await jira.connect(CREDENTIAL)
expect(result).toMatchObject({ ok: false })
if (result.ok) {
throw new Error('Expected rejected credentials')
}
expect(result.error).toContain('example.atlassian.net/rest/api/3/myself (HTTP 401)')
expect(result.error).toContain('api.atlassian.com')
expect(result.error).toContain('email')
expect(result.error).not.toContain('private-token')
expect(jira.getStatus().connected).toBe(false)
})
it('reports unauthenticated tenant lookup failures without calling a gateway', async () => {
fixture = await startJiraAuthFixture((_request, response) => {
jiraFixtureJson(response, 404, {})
})
const jira = await loadClient()
const result = await jira.connect({ ...CREDENTIAL, email: '' })
expect(result).toMatchObject({ ok: false, error: expect.stringContaining('cloud ID') })
expect(result).toMatchObject({ error: expect.stringContaining('HTTP 404') })
expect(fixture.requests).toHaveLength(1)
expect(fixture.requests[0]?.authorization).toBeUndefined()
})
it('identifies a wrong base path returning HTML as a response problem', async () => {
fixture = await startJiraAuthFixture((_request, response) => {
response.writeHead(200, { 'Content-Type': 'text/html' })
response.end('<html>Sign in</html>')
})
const jira = await loadClient()
const result = await jira.connect(CREDENTIAL)
expect(result).toMatchObject({
ok: false,
error: expect.stringContaining('unexpected account response')
})
expect(result).toMatchObject({ error: expect.stringContaining('base path') })
expect(fixture.requests).toHaveLength(1)
})
it.each(['..', 'cloud/other', 'cloud%2fother', '', 'cloud?token=private-token'])(
'refuses malformed cloud ID %s before sending credentials to the gateway',
async (cloudId) => {
fixture = await startJiraAuthFixture((_request, response) => {
jiraFixtureJson(response, 200, { cloudId })
})
const jira = await loadClient()
await expect(jira.connect({ ...CREDENTIAL, email: '' })).resolves.toMatchObject({
ok: false,
error: expect.stringContaining('cloud ID')
})
expect(fixture.requests).toHaveLength(1)
}
)
it('never probes the Cloud gateway for self-hosted credentials or a server outage', async () => {
fixture = await startJiraAuthFixture((_request, response) => {
jiraFixtureJson(response, 401, {})
})
const jira = await loadClient()
await jira.connect({ ...CREDENTIAL, authType: 'server' })
expect(fixture.requests).toHaveLength(1)
expect(fixture.requests[0]?.url).toBe(`${SITE}/rest/api/2/myself`)
await fixture.close()
fixture = await startJiraAuthFixture((_request, response) => {
jiraFixtureJson(response, 503, {})
})
await jira.connect(CREDENTIAL)
expect(fixture.requests).toHaveLength(1)
})
it('keeps legacy stored sites while refusing edited gateway destinations', async () => {
const dir = join(tempHome, '.orca')
mkdirSync(join(dir, 'jira-tokens'), { recursive: true })
const sites = [
{ id: 'legacy' },
{ id: 'scoped', apiBaseUrl: GATEWAY },
{ id: 'foreign', apiBaseUrl: 'https://evil.example/ex/jira/cloud-abc' },
{ id: 'wrong-path', apiBaseUrl: `${GATEWAY}/../other-cloud` },
{ id: 'query', apiBaseUrl: `${GATEWAY}?token=private-token` }
].map((site) => ({
siteUrl: SITE,
email: 'ada@example.com',
displayName: 'Ada',
accountId: 'account-alpha',
...site
}))
writeFileSync(join(dir, 'jira-sites.json'), JSON.stringify({ version: 1, sites }))
for (const site of sites) {
writeFileSync(
join(dir, 'jira-tokens', `${Buffer.from(site.id).toString('base64url')}.enc`),
'private-token'
)
}
const jira = await loadClient()
expect(jira.getStatus().sites?.map((site) => site.id)).toEqual(['legacy', 'scoped'])
expect(jira.getClients()[0]?.site.authType).toBe('cloud')
})
})
+7 -46
View File
@@ -1,8 +1,8 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import type * as Os from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadJiraClientModule, type SafeStorageMockOptions } from './__mocks__/jira-client-module'
const OLD_FETCH = globalThis.fetch
const { closeAllConnectionsMock, netFetchMock, resolveProxyMock, setProxyMock } = vi.hoisted(
@@ -14,11 +14,6 @@ const { closeAllConnectionsMock, netFetchMock, resolveProxyMock, setProxyMock }
})
)
type SafeStorageMockOptions = {
encryptionAvailable?: boolean
decryptString?: (value: Buffer) => string
}
let tempHome = ''
let fetchMock: ReturnType<typeof vi.fn>
@@ -89,46 +84,12 @@ function writeMultiSiteFiles(
}
}
async function loadClientModule(options: SafeStorageMockOptions = {}) {
vi.resetModules()
vi.doMock('electron', () => ({
net: { fetch: netFetchMock },
session: {
defaultSession: {
closeAllConnections: closeAllConnectionsMock,
resolveProxy: resolveProxyMock,
setProxy: setProxyMock
}
}
}))
// Why here and not in beforeEach: vi.resetModules() above gives the http-client module
// a fresh singleton, so the port must be installed on that instance. The electron net
// mock alone is inert now that Jira fetches through the port.
const { setMainHttpClient } = await import('../network/http-client')
setMainHttpClient({
fetch: (url, init) => netFetchMock(url, init),
proxySession: () => ({ resolveProxy: resolveProxyMock, setProxy: setProxyMock }) as never
})
const { setSecretStore } = await import('../../shared/secret-store')
setSecretStore({
isEncryptionAvailable: () => options.encryptionAvailable ?? false,
encryptString: (value) => Buffer.from(value),
decryptString: options.decryptString ?? ((value) => value.toString('utf-8')),
describeProtectionGap: () => null
})
vi.doMock('os', async () => {
const actual = await vi.importActual<typeof Os>('os')
return { ...actual, homedir: () => tempHome }
})
// One import call per reset so the split modules share a single graph (and
// thus one copy of the request queue / credential caches) per test.
const [client, queue, api] = await Promise.all([
import('./client'),
import('./request-queue'),
import('./authenticated-request')
])
return { ...client, ...queue, ...api }
function loadClientModule(options: SafeStorageMockOptions = {}) {
return loadJiraClientModule(
{ closeAllConnectionsMock, netFetchMock, resolveProxyMock, setProxyMock },
tempHome,
options
)
}
beforeEach(() => {
+10 -18
View File
@@ -24,10 +24,10 @@ import {
authHeader,
JiraApiError,
jiraRequest,
requestWithCredentials,
type JiraClientForSite
} from './authenticated-request'
import { getSiteId, normalizeJiraSiteUrl, siteToViewer, toViewer } from './site-identity'
import { verifyJiraCredentials } from './credential-verification'
export function getClients(selection?: JiraSiteSelection | null): JiraClientForSite[] {
const file = getSiteFile()
@@ -75,7 +75,8 @@ export function getStatus(): JiraConnectionStatus {
activeSiteId: activeSite?.id ?? null,
selectedSiteId: file.selectedSiteId ?? activeSite?.id ?? null,
...(credentialError ? { credentialError } : {}),
credentialProtection
credentialProtection,
supportsScopedTokens: true
}
}
@@ -101,24 +102,13 @@ export async function connect(
error: email ? 'Password is required.' : 'Personal access token is required.'
}
}
} else if (!email || !apiToken) {
return { ok: false, error: 'Email and API token are required.' }
} else if (!apiToken) {
return { ok: false, error: 'API token is required.' }
}
await acquire()
try {
const myselfPath = authType === 'server' ? '/rest/api/2/myself' : '/rest/api/3/myself'
const viewer = toViewer(
(await requestWithCredentials(
siteUrl,
email,
apiToken,
myselfPath,
undefined,
authType
)) as Record<string, unknown>,
email || siteUrl
)
const { viewer, apiBaseUrl } = await verifyJiraCredentials(siteUrl, email, apiToken, authType)
// PAT sites have no email, so keying on it alone would collide every PAT
// connection to the same host into one id (silently overwriting a prior
// account + token). Fall back to the verified viewer identity so distinct
@@ -130,8 +120,10 @@ export async function connect(
email,
displayName: viewer.displayName,
accountId: viewer.accountId,
authType
authType,
...(apiBaseUrl ? { apiBaseUrl } : {})
}
clearAttachmentImagesForSite(id)
saveToken(id, apiToken)
const file = getSiteFile()
writeSiteFile({
@@ -215,5 +207,5 @@ export function clearToken(siteId: string): void {
export function isAuthError(error: unknown): boolean {
// Why: Jira returns 403 for project/API permission gaps even when /myself
// succeeds, so only 401 means the saved credential itself is invalid.
return error instanceof JiraApiError && error.status === 401
return error instanceof JiraApiError && error.status === 401 && !error.scopeMismatch
}
+20
View File
@@ -0,0 +1,20 @@
import { JiraApiError, jiraFetch } from './authenticated-request'
import { jiraGatewayBaseUrl } from './gateway-base-url'
export async function resolveJiraGatewayBaseUrl(siteUrl: string): Promise<string> {
const tenantInfoUrl = new URL('/_edge/tenant_info', siteUrl).toString()
const response = await jiraFetch(tenantInfoUrl, {
headers: { Accept: 'application/json', 'User-Agent': 'Orca' }
})
if (!response.ok) {
await response.body?.cancel()
throw new JiraApiError(
`Could not resolve the Atlassian cloud ID at ${new URL(tenantInfoUrl).host}/_edge/tenant_info (HTTP ${response.status}). Check the Jira Cloud site URL.`,
response.status
)
}
const data: unknown = await response.json()
return jiraGatewayBaseUrl(
data && typeof data === 'object' && 'cloudId' in data ? data.cloudId : undefined
)
}
+93
View File
@@ -0,0 +1,93 @@
import type { JiraAuthType, JiraViewer } from '../../shared/jira-types'
import { JiraApiError, requestWithCredentials } from './authenticated-request'
import { resolveJiraGatewayBaseUrl } from './cloud-gateway'
import { toViewer } from './site-identity'
function endpointLabel(baseUrl: string, path: string): string {
const url = new URL(`${baseUrl}${path}`)
return `${url.host}${url.pathname}`
}
function describeFailure(error: unknown, endpoint: string): string {
if (error instanceof JiraApiError) {
if (error.scopeMismatch) {
return error.message
}
const status = error.status === null ? '' : ` (HTTP ${error.status})`
if (error.status === 401) {
return `Jira rejected the credential at ${endpoint}${status}. Check the account email, token expiry, and access to this site.`
}
if (error.status === 403) {
return `Jira denied access at ${endpoint}${status}. Check account permissions and API token scopes.`
}
if (error.status === 200) {
return `Jira returned an unexpected account response at ${endpoint}${status}. Check that the site URL includes the Jira installation's base path.`
}
return `Jira could not verify the credential at ${endpoint}${status}. Check the site URL and Jira availability.`
}
return `Could not reach ${endpoint}. Check the connection, proxy, and Jira site URL.`
}
async function verifyViewer(
baseUrl: string,
email: string,
apiToken: string,
authType: JiraAuthType
): Promise<JiraViewer> {
const path = authType === 'server' ? '/rest/api/2/myself' : '/rest/api/3/myself'
let data: unknown
try {
data = await requestWithCredentials(baseUrl, email, apiToken, path, undefined, authType)
} catch (error) {
if (error instanceof SyntaxError) {
throw new JiraApiError('Jira returned a non-JSON account response.', 200)
}
throw error
}
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new JiraApiError('Jira returned an invalid account response.', 200)
}
const viewer = toViewer(Object.fromEntries(Object.entries(data)), email || baseUrl)
if (!viewer.accountId) {
throw new JiraApiError('Jira did not return an account identity.', 200)
}
return viewer
}
export async function verifyJiraCredentials(
siteUrl: string,
email: string,
apiToken: string,
authType: JiraAuthType
): Promise<{ viewer: JiraViewer; apiBaseUrl?: string }> {
const path = authType === 'server' ? '/rest/api/2/myself' : '/rest/api/3/myself'
let siteFailure: string | undefined
if (email || authType === 'server') {
try {
return { viewer: await verifyViewer(siteUrl, email, apiToken, authType) }
} catch (error) {
siteFailure = describeFailure(error, endpointLabel(siteUrl, path))
if (authType === 'server' || !(error instanceof JiraApiError) || error.status !== 401) {
throw new Error(siteFailure)
}
}
}
// Scoped tokens are rejected by the site host; keep browse links there and send REST to the gateway.
let apiBaseUrl: string
try {
apiBaseUrl = await resolveJiraGatewayBaseUrl(siteUrl)
} catch (error) {
const detail =
error instanceof JiraApiError
? error.message
: 'Could not resolve a valid Atlassian cloud ID. Check the Jira Cloud site URL and connection.'
throw new Error(siteFailure ? `${siteFailure} ${detail}` : detail)
}
try {
return { viewer: await verifyViewer(apiBaseUrl, email, apiToken, authType), apiBaseUrl }
} catch (error) {
const detail = describeFailure(error, endpointLabel(apiBaseUrl, path))
throw new Error(siteFailure ? `${siteFailure} Gateway check: ${detail}` : detail)
}
}
+13
View File
@@ -0,0 +1,13 @@
const GATEWAY_BASE_URL_RE = /^https:\/\/api\.atlassian\.com\/ex\/jira\/[A-Za-z0-9_-]+$/
const CLOUD_ID_RE = /^[A-Za-z0-9_-]+$/
export function jiraGatewayBaseUrl(cloudId: unknown): string {
if (typeof cloudId !== 'string' || !CLOUD_ID_RE.test(cloudId)) {
throw new Error('The site did not report a valid Atlassian cloud ID.')
}
return `https://api.atlassian.com/ex/jira/${cloudId}`
}
export function isJiraGatewayBaseUrl(value: unknown): value is string {
return typeof value === 'string' && GATEWAY_BASE_URL_RE.test(value)
}
+56
View File
@@ -0,0 +1,56 @@
export class JiraApiError extends Error {
constructor(
message: string,
public status: number | null = null,
public scopeMismatch = false
) {
super(message)
}
}
async function readJiraError(response: Response): Promise<string> {
try {
const data: unknown = await response.json()
if (data && typeof data === 'object') {
const messages: unknown[] = []
if ('errorMessages' in data && Array.isArray(data.errorMessages)) {
messages.push(...data.errorMessages)
}
if ('errors' in data && data.errors && typeof data.errors === 'object') {
messages.push(...Object.values(data.errors))
}
if ('message' in data) {
messages.push(data.message)
}
const strings = messages.filter((message): message is string => typeof message === 'string')
if (strings.length > 0) {
return strings.join('; ')
}
}
} catch {
// Fall through for non-JSON responses.
}
return response.statusText || `Jira request failed (${response.status})`
}
export async function toJiraApiError(response: Response, url: string): Promise<JiraApiError> {
const message = await readJiraError(response)
const target = new URL(url)
const scopeMismatch =
target.origin === 'https://api.atlassian.com' &&
(response.status === 401 || response.status === 403) &&
/scope does not match|insufficient_scope|scope mismatch/i.test(
`${message} ${response.headers.get('www-authenticate') ?? ''}`
)
if (scopeMismatch) {
const scope = /\/(myself|user|users)(\/|$)/.test(target.pathname)
? 'read:jira-user'
: 'read:jira-work for reads or write:jira-work for edits'
return new JiraApiError(
`Jira rejected this request's API token scopes at ${target.host}${target.pathname} (HTTP ${response.status}). Add ${scope}; board ordering also needs the Jira Software board scopes listed in the connection guide.`,
response.status,
true
)
}
return new JiraApiError(message, response.status)
}
+11 -1
View File
@@ -10,6 +10,7 @@ import {
readStoredCredentialToken
} from '../integration-credential-file'
import type { JiraSite, JiraSiteSelection } from '../../shared/jira-types'
import { isJiraGatewayBaseUrl } from './gateway-base-url'
export type JiraSiteFile = {
version: 1
@@ -82,6 +83,12 @@ function normalizeSite(input: unknown): JiraSite | null {
) {
return null
}
if (record.apiBaseUrl !== undefined && !isJiraGatewayBaseUrl(record.apiBaseUrl)) {
return null
}
if (record.authType !== 'server' && !record.email && !isJiraGatewayBaseUrl(record.apiBaseUrl)) {
return null
}
return {
id: record.id,
siteUrl: record.siteUrl,
@@ -89,7 +96,10 @@ function normalizeSite(input: unknown): JiraSite | null {
displayName: record.displayName,
accountId: record.accountId,
// Sites saved before self-hosted support have no authType; they are Cloud.
authType: record.authType === 'server' ? 'server' : 'cloud'
authType: record.authType === 'server' ? 'server' : 'cloud',
...(record.authType !== 'server' && isJiraGatewayBaseUrl(record.apiBaseUrl)
? { apiBaseUrl: record.apiBaseUrl }
: {})
}
}
+3
View File
@@ -5,6 +5,9 @@ export function normalizeJiraSiteUrl(siteUrl: string): string {
const trimmed = siteUrl.trim()
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
const url = new URL(withProtocol)
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) {
throw new Error('Enter an HTTP or HTTPS Jira site URL without credentials.')
}
url.pathname = url.pathname.replace(/\/+$/, '')
url.search = ''
url.hash = ''
@@ -0,0 +1,58 @@
import { translate } from '@/i18n/i18n'
export function JiraConnectCredentialGuide({
isServer,
isServerBasic,
supportsScopedTokens
}: {
isServer: boolean
isServerBasic: boolean
supportsScopedTokens: boolean
}): React.JSX.Element {
if (isServer) {
return (
<p className="text-xs text-muted-foreground">
{isServerBasic
? translate(
'auto.components.jira.connect.dialog.d8737db691',
'Use your Jira Server or Data Center account username and password.'
)
: translate(
'auto.components.jira.connect.dialog.ccfb086d3e',
'Create a personal access token in your Jira profile under Personal Access Tokens.'
)}
</p>
)
}
return (
<div className="space-y-2 text-xs text-muted-foreground">
<p>
{translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '}
<button
type="button"
className="text-primary underline-offset-2 hover:underline"
onClick={() =>
window.api.shell.openUrl('https://id.atlassian.com/manage-profile/security/api-tokens')
}
>
{translate(
'auto.components.jira.connect.dialog.fdd26d81cc',
'Atlassian account settings'
)}
</button>
.
</p>
<p>
{supportsScopedTokens
? translate(
'jira.connect.scopedTokenGuide',
'Scoped tokens need read:jira-work, write:jira-work, and read:jira-user. Leave email empty to use Bearer authentication.'
)
: translate(
'jira.connect.oldHostGuide',
'This remote runtime requires an email and an unscoped API token. Update the remote runtime to use scoped tokens.'
)}
</p>
</div>
)
}
@@ -15,9 +15,13 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { cn } from '@/lib/utils'
import { hasRemoteProviderRuntime } from '@/lib/provider-runtime-context'
import {
getProviderRuntimeContextKey,
isEnvironmentRuntimeContextKey
} from '@/lib/provider-runtime-context'
import { preventOutsideDismissWhenDirty } from '@/lib/outside-dismiss-guard'
import { translate } from '@/i18n/i18n'
import { JiraConnectCredentialGuide } from './jira-connect-credential-guide'
type JiraConnectDialogProps = {
open: boolean
@@ -45,6 +49,13 @@ export function JiraConnectDialog({
}: JiraConnectDialogProps): React.JSX.Element {
const connectJira = useAppStore((s) => s.connectJira)
const settings = useAppStore((s) => s.settings)
const hostSupportsScopedTokens = useAppStore((s) => s.jiraStatus?.supportsScopedTokens === true)
const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey)
const providerRuntimeContextKey = getProviderRuntimeContextKey(settings)
const remoteRuntime = isEnvironmentRuntimeContextKey(providerRuntimeContextKey)
const supportsScopedTokens =
!remoteRuntime ||
(jiraStatusContextKey === providerRuntimeContextKey && hostSupportsScopedTokens)
const mountedRef = useMountedRef()
const siteUrlId = useId()
const emailId = useId()
@@ -81,12 +92,13 @@ export function JiraConnectDialog({
// uses no identity, so the email field is hidden and left empty.
const isServerBasic = isServer && serverAuthMethod === 'basic'
const needsIdentity = !isServer || isServerBasic
const requiresIdentity = isServerBasic || (!isServer && !supportsScopedTokens)
const canSubmit =
Boolean(siteUrl.trim()) &&
(!needsIdentity || Boolean(email.trim())) &&
(!requiresIdentity || Boolean(email.trim())) &&
Boolean(apiToken.trim()) &&
connectState !== 'connecting'
const credentialStorageCopy = hasRemoteProviderRuntime(settings)
const credentialStorageCopy = remoteRuntime
? 'Your token is sent to the selected remote runtime and stored there with runtime-supported encryption.'
: 'Your token is stored locally and encrypted when local runtime storage supports it.'
@@ -124,7 +136,7 @@ export function JiraConnectDialog({
const trimmedToken = apiToken.trim()
if (
!trimmedSite ||
(needsIdentity && !trimmedEmail) ||
(requiresIdentity && !trimmedEmail) ||
!trimmedToken ||
connectState === 'connecting'
) {
@@ -179,10 +191,15 @@ export function JiraConnectDialog({
</DialogTitle>
<DialogDescription>
{!isServer
? translate(
'auto.components.jira.connect.dialog.d785c42b8b',
'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.'
)
? supportsScopedTokens
? translate(
'jira.connect.scopedDescription',
'Use a Jira Cloud site URL and API token to browse issues. Scoped tokens can omit the email.'
)
: translate(
'auto.components.jira.connect.dialog.d785c42b8b',
'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.'
)
: isServerBasic
? translate(
'auto.components.jira.connect.dialog.1d947a07ab',
@@ -295,10 +312,15 @@ export function JiraConnectDialog({
<Label htmlFor={emailId} className="text-xs">
{isServerBasic
? translate('auto.components.jira.connect.dialog.8d1223fa5c', 'Username')
: translate(
'auto.components.jira.connect.dialog.2849ddb295',
'Atlassian email'
)}
: supportsScopedTokens
? translate(
'jira.connect.optionalEmail',
'Atlassian email (optional for scoped tokens)'
)
: translate(
'auto.components.jira.connect.dialog.2849ddb295',
'Atlassian email'
)}
</Label>
<Input
id={emailId}
@@ -356,49 +378,19 @@ export function JiraConnectDialog({
clearErrorOnEdit()
}}
disabled={connectState === 'connecting'}
aria-invalid={connectState === 'error'}
aria-describedby={connectState === 'error' ? errorId : undefined}
/>
</div>
{connectState === 'error' && connectError ? (
<p id={errorId} className="text-xs text-destructive">
<p id={errorId} role="alert" className="text-xs text-destructive">
{connectError}
</p>
) : null}
{isServerBasic ? (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.jira.connect.dialog.d8737db691',
'Use your Jira Server or Data Center account username and password.'
)}
</p>
) : isServer ? (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.jira.connect.dialog.ccfb086d3e',
'Create a personal access token in your Jira profile under Personal Access Tokens.'
)}
</p>
) : (
<p className="text-xs text-muted-foreground">
{translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '}
<button
type="button"
className="text-primary underline-offset-2 hover:underline"
onClick={() =>
window.api.shell.openUrl(
'https://id.atlassian.com/manage-profile/security/api-tokens'
)
}
>
{translate(
'auto.components.jira.connect.dialog.fdd26d81cc',
'Atlassian account settings'
)}
</button>
.
</p>
)}
<JiraConnectCredentialGuide
isServer={isServer}
isServerBasic={isServerBasic}
supportsScopedTokens={supportsScopedTokens}
/>
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground/70">
<Lock className="size-3 shrink-0" />
{credentialStorageCopy}
@@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen, cleanup, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { JiraConnectionStatus } from '../../../shared/jira-types'
import { JiraConnectDialog } from './jira-connect-dialog'
import { getEnvironmentRuntimeContextKey } from '@/lib/provider-runtime-context'
type FixtureState = {
settings: { activeRuntimeEnvironmentId: string | null }
jiraStatus: JiraConnectionStatus
jiraStatusContextKey: string | null
}
const mocks = vi.hoisted(() => {
const state: FixtureState = {
settings: { activeRuntimeEnvironmentId: null },
jiraStatus: { connected: false, viewer: null },
jiraStatusContextKey: null
}
return {
connectJira: vi.fn(),
state
}
})
vi.mock('@/store', () => ({
useAppStore: (
selector: (state: typeof mocks.state & { connectJira: typeof mocks.connectJira }) => unknown
) => selector({ ...mocks.state, connectJira: mocks.connectJira })
}))
beforeEach(() => {
mocks.connectJira.mockReset().mockResolvedValue({ ok: true })
mocks.state.settings.activeRuntimeEnvironmentId = null
mocks.state.jiraStatus = { connected: false, viewer: null }
mocks.state.jiraStatusContextKey = null
})
afterEach(cleanup)
function fillSiteAndToken(): void {
fireEvent.change(screen.getByLabelText('Jira Cloud site URL'), {
target: { value: 'https://example.atlassian.net' }
})
fireEvent.change(screen.getByLabelText('API token'), { target: { value: 'scoped-token' } })
}
describe('Jira scoped-token connection UI', () => {
it('requires email until the selected runtime confirms its own scoped-token capability', () => {
mocks.state.settings.activeRuntimeEnvironmentId = 'new-runtime'
mocks.state.jiraStatus.supportsScopedTokens = true
mocks.state.jiraStatusContextKey = getEnvironmentRuntimeContextKey('old-runtime')
const view = render(<JiraConnectDialog open onOpenChange={vi.fn()} />)
fillSiteAndToken()
expect(screen.getByLabelText('Atlassian email')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Connect' })).toHaveProperty('disabled', true)
mocks.state.jiraStatusContextKey = getEnvironmentRuntimeContextKey('new-runtime')
view.rerender(<JiraConnectDialog open onOpenChange={vi.fn()} />)
expect(screen.getByLabelText('Atlassian email (optional for scoped tokens)')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Connect' })).toHaveProperty('disabled', false)
})
it('submits an email-free Cloud token to an upgraded host', async () => {
mocks.state.settings.activeRuntimeEnvironmentId = 'remote-1'
mocks.state.jiraStatus.supportsScopedTokens = true
mocks.state.jiraStatusContextKey = getEnvironmentRuntimeContextKey('remote-1')
render(<JiraConnectDialog open onOpenChange={vi.fn()} />)
fillSiteAndToken()
expect(screen.getByLabelText('Atlassian email (optional for scoped tokens)')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
await waitFor(() =>
expect(mocks.connectJira).toHaveBeenCalledWith({
siteUrl: 'https://example.atlassian.net',
email: '',
apiToken: 'scoped-token',
authType: 'cloud'
})
)
})
it('keeps the original email requirement when the remote host omits the capability', () => {
mocks.state.settings.activeRuntimeEnvironmentId = 'old-remote'
render(<JiraConnectDialog open onOpenChange={vi.fn()} />)
fillSiteAndToken()
expect(screen.getByLabelText('Atlassian email')).toBeTruthy()
const submit = screen.getByRole('button', { name: 'Connect' })
expect(submit).toHaveProperty('disabled', true)
expect(screen.getByText(/Update the remote runtime/)).toBeTruthy()
fireEvent.click(submit)
expect(mocks.connectJira).not.toHaveBeenCalled()
})
it('shows endpoint diagnostics without marking the token as the failing field', async () => {
mocks.connectJira.mockResolvedValue({
ok: false,
error: 'Could not resolve the Atlassian cloud ID (HTTP 404). Check the Jira Cloud site URL.'
})
render(<JiraConnectDialog open onOpenChange={vi.fn()} />)
fillSiteAndToken()
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('cloud ID'))
expect(screen.getByLabelText('API token').getAttribute('aria-invalid')).toBeNull()
})
})
+8
View File
@@ -19966,5 +19966,13 @@
"preparingLabel": "Preparing update",
"preparingAriaLabel": "Preparing update. Click to expand."
}
},
"jira": {
"connect": {
"scopedTokenGuide": "Scoped tokens need read:jira-work, write:jira-work, and read:jira-user. Leave email empty to use Bearer authentication.",
"oldHostGuide": "This remote runtime requires an email and an unscoped API token. Update the remote runtime to use scoped tokens.",
"scopedDescription": "Use a Jira Cloud site URL and API token to browse issues. Scoped tokens can omit the email.",
"optionalEmail": "Atlassian email (optional for scoped tokens)"
}
}
}
@@ -2,6 +2,32 @@ import { describe, expect, it } from 'vitest'
import { parseJiraConnectionStatus } from './runtime-jira-connection-status'
describe('parseJiraConnectionStatus', () => {
it('accepts an upgraded host without changing the Cloud/Server values older readers know', () => {
const site = {
id: 'site-scoped',
siteUrl: 'https://example.atlassian.net',
email: '',
displayName: 'Ada',
accountId: 'account-alpha',
authType: 'cloud',
apiBaseUrl: 'https://api.atlassian.com/ex/jira/cloud-abc'
}
const status = parseJiraConnectionStatus({
connected: true,
viewer: null,
sites: [site],
supportsScopedTokens: true
})
expect(status.sites).toEqual([site])
expect(status.supportsScopedTokens).toBe(true)
expect(
parseJiraConnectionStatus({ connected: true, viewer: null }).supportsScopedTokens
).toBeUndefined()
expect(
parseJiraConnectionStatus({ connected: true, supportsScopedTokens: 'yes' })
.supportsScopedTokens
).toBeUndefined()
})
it.each([
['undefined', undefined],
['null', null],
@@ -62,7 +62,8 @@ function parseJiraSite(value: unknown): JiraSite | null {
email: typeof value.email === 'string' ? value.email : '',
displayName: value.displayName,
accountId: value.accountId,
authType: isJiraAuthType(value.authType) ? value.authType : undefined
authType: isJiraAuthType(value.authType) ? value.authType : undefined,
apiBaseUrl: typeof value.apiBaseUrl === 'string' ? value.apiBaseUrl : undefined
}
return site
}
@@ -95,7 +96,9 @@ export function parseJiraConnectionStatus(value: unknown): JiraConnectionStatus
credentialProtection:
value.credentialProtection === null || isSecretAtRestProtection(value.credentialProtection)
? value.credentialProtection
: undefined
: undefined,
supportsScopedTokens:
typeof value.supportsScopedTokens === 'boolean' ? value.supportsScopedTokens : undefined
}
return status
}
@@ -42,6 +42,7 @@ function hasJiraStatusChanged(
previous.connected !== next.connected ||
previous.credentialError !== next.credentialError ||
previous.credentialProtection !== next.credentialProtection ||
previous.supportsScopedTokens !== next.supportsScopedTokens ||
previous.activeSiteId !== next.activeSiteId ||
!shallow(previous.viewer, next.viewer) ||
getSelectedJiraSiteId(previous) !== getSelectedJiraSiteId(next) ||
@@ -85,12 +86,10 @@ export function createJiraConnectionActions(
) {
return
}
if (get().jiraStatus.connected) {
if (get().jiraStatusContextKey !== contextKey || get().jiraStatus.connected) {
set((state) => jiraStatusUpdate(state, contextKey, { connected: false, viewer: null }))
} else if (!get().jiraStatusChecked) {
set({ jiraStatusChecked: true, jiraStatusContextKey: contextKey })
} else if (get().jiraStatusContextKey !== contextKey) {
set({ jiraStatusContextKey: contextKey })
}
}
},
@@ -32,7 +32,9 @@ function status(accountId: string): JiraConnectionStatus {
}
}
beforeEach(() => jiraStatus.mockReset())
beforeEach(() => {
jiraStatus.mockReset()
})
it('replaces status when the provider context changes even if its visible values are identical', async () => {
const store = createTestStore()
@@ -79,3 +81,20 @@ it('preserves the status reference for an unchanged identity on the same host',
await store.getState().checkJiraConnection()
expect(store.getState().jiraStatus).toBe(initial)
})
it.each([false, true])(
'clears the old disconnected host capability when the new status read fails (checked: %s)',
async (jiraStatusChecked) => {
const store = createTestStore()
store.setState({
jiraStatus: { connected: false, viewer: null, supportsScopedTokens: true },
jiraStatusChecked,
jiraStatusContextKey: 'runtime:old-host#0'
})
jiraStatus.mockRejectedValueOnce(new Error('Host unavailable'))
await store.getState().checkJiraConnection()
expect(store.getState().jiraStatus).toEqual({ connected: false, viewer: null })
expect(store.getState().jiraStatusChecked).toBe(true)
expect(store.getState().jiraStatusContextKey).toBe('local#0')
}
)
+5 -2
View File
@@ -11,6 +11,8 @@ export type JiraSite = {
displayName: string
accountId: string
authType?: JiraAuthType
// REST gateway for scoped Cloud tokens; siteUrl remains the browse-link origin.
apiBaseUrl?: string
}
export type JiraViewer = {
@@ -34,6 +36,8 @@ export type JiraConnectionStatus = {
// 'plaintext' when any stored token is unsealed, so Settings can warn. Optional:
// an older remote host omits it, and absent must read as "unknown", not "sealed".
credentialProtection?: SecretAtRestProtection | null
// Absent on older remote hosts, which require a Cloud email and unscoped token.
supportsScopedTokens?: boolean
}
export type JiraProject = {
@@ -141,8 +145,7 @@ export type JiraIssueFilter = 'assigned' | 'reported' | 'all' | 'done'
export type JiraConnectArgs = {
siteUrl: string
// Ignored for 'server' auth: self-hosted PATs authenticate via Bearer
// header alone, so the email field may be empty.
// Empty for Bearer authentication: self-hosted PAT or scoped Cloud token.
email: string
apiToken: string
authType?: JiraAuthType