From cf119167a594a087878e37a06baeb5ecb8e666c1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:11:32 -0700 Subject: [PATCH] fix(jira): support scoped Cloud API tokens (#28604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/site/content/docs/review/jira.mdx | 10 +- src/main/jira/__mocks__/jira-client-module.ts | 62 +++ src/main/jira/auth-http-fixture.ts | 55 +++ src/main/jira/authenticated-request.ts | 100 ++--- src/main/jira/client-scoped-token.test.ts | 368 ++++++++++++++++++ src/main/jira/client.test.ts | 53 +-- src/main/jira/client.ts | 28 +- src/main/jira/cloud-gateway.ts | 20 + src/main/jira/credential-verification.ts | 93 +++++ src/main/jira/gateway-base-url.ts | 13 + src/main/jira/jira-api-error.ts | 56 +++ src/main/jira/site-credential-store.ts | 12 +- src/main/jira/site-identity.ts | 3 + .../jira-connect-credential-guide.tsx | 58 +++ .../src/components/jira-connect-dialog.tsx | 88 ++--- .../jira-connect-scoped-token.test.tsx | 102 +++++ src/renderer/src/i18n/locales/en.json | 8 + .../runtime-jira-connection-status.test.ts | 26 ++ .../runtime/runtime-jira-connection-status.ts | 7 +- .../store/slices/jira-connection-actions.ts | 5 +- .../slices/jira-connection-identity.test.ts | 21 +- src/shared/jira-types.ts | 7 +- 22 files changed, 1025 insertions(+), 170 deletions(-) create mode 100644 src/main/jira/__mocks__/jira-client-module.ts create mode 100644 src/main/jira/auth-http-fixture.ts create mode 100644 src/main/jira/client-scoped-token.test.ts create mode 100644 src/main/jira/cloud-gateway.ts create mode 100644 src/main/jira/credential-verification.ts create mode 100644 src/main/jira/gateway-base-url.ts create mode 100644 src/main/jira/jira-api-error.ts create mode 100644 src/renderer/src/components/jira-connect-credential-guide.tsx create mode 100644 src/renderer/src/components/jira-connect-scoped-token.test.tsx diff --git a/docs/site/content/docs/review/jira.mdx b/docs/site/content/docs/review/jira.mdx index aeca47e1ef8..76e227f5223 100644 --- a/docs/site/content/docs/review/jira.mdx +++ b/docs/site/content/docs/review/jira.mdx @@ -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/`. 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** diff --git a/src/main/jira/__mocks__/jira-client-module.ts b/src/main/jira/__mocks__/jira-client-module.ts new file mode 100644 index 00000000000..8b883d83cbd --- /dev/null +++ b/src/main/jira/__mocks__/jira-client-module.ts @@ -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('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 } +} diff --git a/src/main/jira/auth-http-fixture.ts b/src/main/jira/auth-http-fixture.ts new file mode 100644 index 00000000000..3e13a89c5ba --- /dev/null +++ b/src/main/jira/auth-http-fixture.ts @@ -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((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((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)) +} diff --git a/src/main/jira/authenticated-request.ts b/src/main/jira/authenticated-request.ts index feb54167c9c..91790aa0d07 100644 --- a/src/main/jira/authenticated-request.ts +++ b/src/main/jira/authenticated-request.ts @@ -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 { +export async function jiraFetch(url: string, init: RequestInit): Promise { 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 { - try { - const data = (await response.json()) as { - errorMessages?: string[] - errors?: Record - 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( client: JiraClientForSite, path: string, @@ -151,12 +136,14 @@ export async function jiraRequest( 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( 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 { diff --git a/src/main/jira/client-scoped-token.test.ts b/src/main/jira/client-scoped-token.test.ts new file mode 100644 index 00000000000..36d1c1ec61d --- /dev/null +++ b/src/main/jira/client-scoped-token.test.ts @@ -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> | 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('Sign in') + }) + 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') + }) +}) diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts index 7f57ad80b01..648b541fac7 100644 --- a/src/main/jira/client.test.ts +++ b/src/main/jira/client.test.ts @@ -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 @@ -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('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(() => { diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts index 8505c7314c7..19cc3d1d4ed 100644 --- a/src/main/jira/client.ts +++ b/src/main/jira/client.ts @@ -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, - 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 } diff --git a/src/main/jira/cloud-gateway.ts b/src/main/jira/cloud-gateway.ts new file mode 100644 index 00000000000..64ff2f24f1c --- /dev/null +++ b/src/main/jira/cloud-gateway.ts @@ -0,0 +1,20 @@ +import { JiraApiError, jiraFetch } from './authenticated-request' +import { jiraGatewayBaseUrl } from './gateway-base-url' + +export async function resolveJiraGatewayBaseUrl(siteUrl: string): Promise { + 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 + ) +} diff --git a/src/main/jira/credential-verification.ts b/src/main/jira/credential-verification.ts new file mode 100644 index 00000000000..973a2e07dea --- /dev/null +++ b/src/main/jira/credential-verification.ts @@ -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 { + 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) + } +} diff --git a/src/main/jira/gateway-base-url.ts b/src/main/jira/gateway-base-url.ts new file mode 100644 index 00000000000..f49696289b5 --- /dev/null +++ b/src/main/jira/gateway-base-url.ts @@ -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) +} diff --git a/src/main/jira/jira-api-error.ts b/src/main/jira/jira-api-error.ts new file mode 100644 index 00000000000..aea894cb3bb --- /dev/null +++ b/src/main/jira/jira-api-error.ts @@ -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 { + 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 { + 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) +} diff --git a/src/main/jira/site-credential-store.ts b/src/main/jira/site-credential-store.ts index 8be2bdb112e..8957f2a318c 100644 --- a/src/main/jira/site-credential-store.ts +++ b/src/main/jira/site-credential-store.ts @@ -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 } + : {}) } } diff --git a/src/main/jira/site-identity.ts b/src/main/jira/site-identity.ts index 434e3039ac1..5d9b00887ff 100644 --- a/src/main/jira/site-identity.ts +++ b/src/main/jira/site-identity.ts @@ -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 = '' diff --git a/src/renderer/src/components/jira-connect-credential-guide.tsx b/src/renderer/src/components/jira-connect-credential-guide.tsx new file mode 100644 index 00000000000..a22e42b831c --- /dev/null +++ b/src/renderer/src/components/jira-connect-credential-guide.tsx @@ -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 ( +

+ {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.' + )} +

+ ) + } + return ( +
+

+ {translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '} + + . +

+

+ {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.' + )} +

+
+ ) +} diff --git a/src/renderer/src/components/jira-connect-dialog.tsx b/src/renderer/src/components/jira-connect-dialog.tsx index 3db503ebac8..a2ba344b179 100644 --- a/src/renderer/src/components/jira-connect-dialog.tsx +++ b/src/renderer/src/components/jira-connect-dialog.tsx @@ -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({ {!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({ {connectState === 'error' && connectError ? ( -

+

) : null} - {isServerBasic ? ( -

- {translate( - 'auto.components.jira.connect.dialog.d8737db691', - 'Use your Jira Server or Data Center account username and password.' - )} -

- ) : isServer ? ( -

- {translate( - 'auto.components.jira.connect.dialog.ccfb086d3e', - 'Create a personal access token in your Jira profile under Personal Access Tokens.' - )} -

- ) : ( -

- {translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '} - - . -

- )} +

{credentialStorageCopy} diff --git a/src/renderer/src/components/jira-connect-scoped-token.test.tsx b/src/renderer/src/components/jira-connect-scoped-token.test.tsx new file mode 100644 index 00000000000..e5d5234bc2e --- /dev/null +++ b/src/renderer/src/components/jira-connect-scoped-token.test.tsx @@ -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() + fillSiteAndToken() + expect(screen.getByLabelText('Atlassian email')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Connect' })).toHaveProperty('disabled', true) + mocks.state.jiraStatusContextKey = getEnvironmentRuntimeContextKey('new-runtime') + view.rerender() + 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() + 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() + 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() + 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() + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e8f9292e3dc..11de559a293 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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)" + } } } diff --git a/src/renderer/src/runtime/runtime-jira-connection-status.test.ts b/src/renderer/src/runtime/runtime-jira-connection-status.test.ts index 05669334e7f..ba620769295 100644 --- a/src/renderer/src/runtime/runtime-jira-connection-status.test.ts +++ b/src/renderer/src/runtime/runtime-jira-connection-status.test.ts @@ -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], diff --git a/src/renderer/src/runtime/runtime-jira-connection-status.ts b/src/renderer/src/runtime/runtime-jira-connection-status.ts index ad75cb4cfc8..b34447d16b3 100644 --- a/src/renderer/src/runtime/runtime-jira-connection-status.ts +++ b/src/renderer/src/runtime/runtime-jira-connection-status.ts @@ -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 } diff --git a/src/renderer/src/store/slices/jira-connection-actions.ts b/src/renderer/src/store/slices/jira-connection-actions.ts index 1d01e79ec77..6baed8f7c38 100644 --- a/src/renderer/src/store/slices/jira-connection-actions.ts +++ b/src/renderer/src/store/slices/jira-connection-actions.ts @@ -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 }) } } }, diff --git a/src/renderer/src/store/slices/jira-connection-identity.test.ts b/src/renderer/src/store/slices/jira-connection-identity.test.ts index 8540953ec44..01f9cae57eb 100644 --- a/src/renderer/src/store/slices/jira-connection-identity.test.ts +++ b/src/renderer/src/store/slices/jira-connection-identity.test.ts @@ -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') + } +) diff --git a/src/shared/jira-types.ts b/src/shared/jira-types.ts index 0cc03868914..de9c25f95d6 100644 --- a/src/shared/jira-types.ts +++ b/src/shared/jira-types.ts @@ -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