@shipfox/api-integration-jira 4.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +52 -0
  3. package/dist/api/client.d.ts +33 -0
  4. package/dist/api/client.d.ts.map +1 -0
  5. package/dist/api/client.js +143 -0
  6. package/dist/api/client.js.map +1 -0
  7. package/dist/core/disconnect.d.ts +18 -0
  8. package/dist/core/disconnect.d.ts.map +1 -0
  9. package/dist/core/disconnect.js +21 -0
  10. package/dist/core/disconnect.js.map +1 -0
  11. package/dist/core/errors.d.ts +37 -0
  12. package/dist/core/errors.d.ts.map +1 -1
  13. package/dist/core/errors.js +63 -0
  14. package/dist/core/errors.js.map +1 -1
  15. package/dist/core/install.d.ts +55 -0
  16. package/dist/core/install.d.ts.map +1 -0
  17. package/dist/core/install.js +121 -0
  18. package/dist/core/install.js.map +1 -0
  19. package/dist/core/pending.d.ts +43 -0
  20. package/dist/core/pending.d.ts.map +1 -0
  21. package/dist/core/pending.js +107 -0
  22. package/dist/core/pending.js.map +1 -0
  23. package/dist/core/scopes.d.ts +4 -0
  24. package/dist/core/scopes.d.ts.map +1 -0
  25. package/dist/core/scopes.js +23 -0
  26. package/dist/core/scopes.js.map +1 -0
  27. package/dist/core/state.d.ts +12 -0
  28. package/dist/core/state.d.ts.map +1 -0
  29. package/dist/core/state.js +51 -0
  30. package/dist/core/state.js.map +1 -0
  31. package/dist/core/tokens.d.ts +6 -0
  32. package/dist/core/tokens.d.ts.map +1 -1
  33. package/dist/core/tokens.js +82 -6
  34. package/dist/core/tokens.js.map +1 -1
  35. package/dist/db/db.d.ts +188 -0
  36. package/dist/db/db.d.ts.map +1 -1
  37. package/dist/db/db.js +3 -1
  38. package/dist/db/db.js.map +1 -1
  39. package/dist/db/installations.d.ts +21 -0
  40. package/dist/db/installations.d.ts.map +1 -1
  41. package/dist/db/installations.js +82 -19
  42. package/dist/db/installations.js.map +1 -1
  43. package/dist/db/pending-selections.d.ts +17 -0
  44. package/dist/db/pending-selections.d.ts.map +1 -0
  45. package/dist/db/pending-selections.js +27 -0
  46. package/dist/db/pending-selections.js.map +1 -0
  47. package/dist/db/schema/installations.js +2 -2
  48. package/dist/db/schema/installations.js.map +1 -1
  49. package/dist/db/schema/pending-selections.d.ts +105 -0
  50. package/dist/db/schema/pending-selections.d.ts.map +1 -0
  51. package/dist/db/schema/pending-selections.js +24 -0
  52. package/dist/db/schema/pending-selections.js.map +1 -0
  53. package/dist/index.d.ts +26 -3
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +27 -4
  56. package/dist/index.js.map +1 -1
  57. package/dist/presentation/dto/integrations.d.ts +2 -0
  58. package/dist/presentation/dto/integrations.d.ts.map +1 -0
  59. package/dist/presentation/dto/integrations.js +3 -0
  60. package/dist/presentation/dto/integrations.js.map +1 -0
  61. package/dist/presentation/routes/errors.d.ts +2 -0
  62. package/dist/presentation/routes/errors.d.ts.map +1 -0
  63. package/dist/presentation/routes/errors.js +58 -0
  64. package/dist/presentation/routes/errors.js.map +1 -0
  65. package/dist/presentation/routes/install.d.ts +26 -0
  66. package/dist/presentation/routes/install.d.ts.map +1 -0
  67. package/dist/presentation/routes/install.js +133 -0
  68. package/dist/presentation/routes/install.js.map +1 -0
  69. package/dist/tsconfig.test.tsbuildinfo +1 -1
  70. package/drizzle/0000_whole_master_chief.sql +8 -1
  71. package/drizzle/meta/0000_snapshot.json +47 -3
  72. package/package.json +22 -26
  73. package/src/api/client.test.ts +29 -0
  74. package/src/api/client.ts +213 -0
  75. package/src/core/disconnect.test.ts +24 -0
  76. package/src/core/disconnect.ts +25 -0
  77. package/src/core/errors.ts +79 -0
  78. package/src/core/install.test.ts +129 -0
  79. package/src/core/install.ts +191 -0
  80. package/src/core/pending.ts +146 -0
  81. package/src/core/scopes.test.ts +19 -0
  82. package/src/core/scopes.ts +20 -0
  83. package/src/core/state.test.ts +38 -0
  84. package/src/core/state.ts +79 -0
  85. package/src/core/tokens-refresh.test.ts +106 -0
  86. package/src/core/tokens.test.ts +16 -2
  87. package/src/core/tokens.ts +145 -8
  88. package/src/db/db.ts +2 -1
  89. package/src/db/installations.test.ts +37 -1
  90. package/src/db/installations.ts +120 -23
  91. package/src/db/pending-selections.ts +55 -0
  92. package/src/db/schema/installations.ts +2 -2
  93. package/src/db/schema/pending-selections.ts +31 -0
  94. package/src/index.test.ts +7 -2
  95. package/src/index.ts +66 -2
  96. package/src/presentation/dto/integrations.ts +1 -0
  97. package/src/presentation/routes/errors.ts +61 -0
  98. package/src/presentation/routes/install.ts +161 -0
  99. package/test/globalSetup.ts +0 -9
  100. package/tsconfig.build.tsbuildinfo +1 -1
  101. package/turbo.json +9 -0
  102. package/test/api-secrets.d.ts +0 -26
@@ -0,0 +1,191 @@
1
+ import type {UserContextMembership} from '@shipfox/api-auth-context';
2
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
3
+ import {logger} from '@shipfox/node-opentelemetry';
4
+ import type {JiraAccessibleResource, JiraApiClient, JiraAuthorization} from '#api/client.js';
5
+ import type {JiraPendingSelectionStore} from '#core/pending.js';
6
+ import type {JiraTokenStore} from '#core/tokens.js';
7
+ import {
8
+ JiraInstallationAlreadyLinkedError,
9
+ JiraInstallStateActorMismatchError,
10
+ JiraIntegrationProviderError,
11
+ JiraOAuthCallbackError,
12
+ JiraOfflineAccessNotGrantedError,
13
+ JiraPendingSelectionNotFoundError,
14
+ JiraSiteSelectionMismatchError,
15
+ } from './errors.js';
16
+ import {assertJiraAuthorizationScopes} from './scopes.js';
17
+ import {verifyJiraInstallState} from './state.js';
18
+
19
+ export interface ConnectJiraInstallationInput {
20
+ workspaceId: string;
21
+ cloudId: string;
22
+ siteUrl: string;
23
+ siteName: string;
24
+ authorizingAccountId: string;
25
+ scopes: string[];
26
+ tokenExpiresAt: Date | null;
27
+ displayName: string;
28
+ }
29
+
30
+ export interface HandleJiraCallbackParams {
31
+ jira: JiraApiClient;
32
+ tokenStore: Pick<JiraTokenStore, 'storeTokens'>;
33
+ pendingStore: Pick<JiraPendingSelectionStore, 'save' | 'load' | 'clear'>;
34
+ code: string;
35
+ state: string;
36
+ sessionUserId: string;
37
+ sessionMemberships: ReadonlyArray<UserContextMembership>;
38
+ requireWorkspaceMembership(input: {
39
+ workspaceId: string;
40
+ userId: string;
41
+ memberships: ReadonlyArray<UserContextMembership>;
42
+ }): Promise<unknown>;
43
+ getExistingJiraConnection(input: {
44
+ cloudId: string;
45
+ }): Promise<IntegrationConnection<'jira'> | undefined>;
46
+ connectJiraInstallation(
47
+ input: ConnectJiraInstallationInput,
48
+ ): Promise<IntegrationConnection<'jira'>>;
49
+ disconnectJiraInstallation(input: {connectionId: string}): Promise<void>;
50
+ }
51
+
52
+ export async function handleJiraCallback(
53
+ params: HandleJiraCallbackParams,
54
+ ): Promise<IntegrationConnection<'jira'> | {sites: JiraAccessibleResource[]}> {
55
+ const claims = await verifyClaims(params, params.state);
56
+ const authorization = await params.jira.exchangeAuthorizationCode({code: params.code});
57
+ if (!authorization.refreshToken) throw new JiraOfflineAccessNotGrantedError();
58
+ const sites = await params.jira.getAccessibleResources({accessToken: authorization.accessToken});
59
+ if (sites.length === 0)
60
+ throw new JiraIntegrationProviderError(
61
+ 'access-denied',
62
+ 'Jira authorization did not grant access to a site',
63
+ );
64
+ if (sites.length > 1) {
65
+ await params.pendingStore.save({
66
+ workspaceId: claims.workspaceId,
67
+ state: params.state,
68
+ authorization,
69
+ sites,
70
+ });
71
+ return {sites};
72
+ }
73
+ const site = sites[0];
74
+ if (!site) {
75
+ throw new JiraIntegrationProviderError(
76
+ 'malformed-provider-response',
77
+ 'Jira accessible-resources response did not contain a selected site',
78
+ );
79
+ }
80
+ assertJiraAuthorizationScopes(site.scopes);
81
+ return resolveJiraSite({...params, authorization, site, claims});
82
+ }
83
+
84
+ export async function handleJiraSiteSelection(
85
+ params: Omit<HandleJiraCallbackParams, 'code'> & {cloudId: string},
86
+ ): Promise<IntegrationConnection<'jira'>> {
87
+ const claims = await verifyClaims(params, params.state);
88
+ const pending = await params.pendingStore.load({
89
+ workspaceId: claims.workspaceId,
90
+ state: params.state,
91
+ });
92
+ if (!pending) throw new JiraPendingSelectionNotFoundError();
93
+ const site = pending.sites.find((candidate) => candidate.cloudId === params.cloudId);
94
+ if (!site) throw new JiraSiteSelectionMismatchError(params.cloudId);
95
+ assertJiraAuthorizationScopes(site.scopes);
96
+ const connection = await resolveJiraSite({
97
+ ...params,
98
+ authorization: pending.authorization,
99
+ site,
100
+ claims,
101
+ });
102
+ await params.pendingStore.clear({workspaceId: claims.workspaceId, state: params.state});
103
+ return connection;
104
+ }
105
+
106
+ export async function handleJiraOAuthCallbackError(params: {
107
+ state: string;
108
+ error: string;
109
+ errorDescription?: string | undefined;
110
+ sessionUserId: string;
111
+ sessionMemberships: ReadonlyArray<UserContextMembership>;
112
+ requireWorkspaceMembership(input: {
113
+ workspaceId: string;
114
+ userId: string;
115
+ memberships: ReadonlyArray<UserContextMembership>;
116
+ }): Promise<unknown>;
117
+ }): Promise<never> {
118
+ await verifyClaims(params, params.state);
119
+ throw new JiraOAuthCallbackError(params.error, params.errorDescription);
120
+ }
121
+
122
+ async function verifyClaims(
123
+ params: Pick<
124
+ HandleJiraCallbackParams,
125
+ 'sessionUserId' | 'sessionMemberships' | 'requireWorkspaceMembership'
126
+ >,
127
+ state: string,
128
+ ) {
129
+ const claims = verifyJiraInstallState(state);
130
+ if (claims.userId !== params.sessionUserId) throw new JiraInstallStateActorMismatchError();
131
+ await params.requireWorkspaceMembership({
132
+ workspaceId: claims.workspaceId,
133
+ userId: claims.userId,
134
+ memberships: params.sessionMemberships,
135
+ });
136
+ return claims;
137
+ }
138
+
139
+ async function resolveJiraSite(
140
+ params: Omit<HandleJiraCallbackParams, 'code'> & {
141
+ authorization: JiraAuthorization;
142
+ site: JiraAccessibleResource;
143
+ claims: {workspaceId: string; userId: string};
144
+ },
145
+ ): Promise<IntegrationConnection<'jira'>> {
146
+ const existing = await params.getExistingJiraConnection({cloudId: params.site.cloudId});
147
+ if (existing && existing.workspaceId !== params.claims.workspaceId)
148
+ throw new JiraInstallationAlreadyLinkedError(params.site.cloudId);
149
+ let connectionId: string | undefined;
150
+ try {
151
+ const identity = await params.jira.getMyself({
152
+ accessToken: params.authorization.accessToken,
153
+ cloudId: params.site.cloudId,
154
+ });
155
+ const connection = await params.connectJiraInstallation({
156
+ workspaceId: params.claims.workspaceId,
157
+ cloudId: params.site.cloudId,
158
+ siteUrl: params.site.url,
159
+ siteName: params.site.name,
160
+ authorizingAccountId: identity.accountId,
161
+ scopes: params.site.scopes,
162
+ tokenExpiresAt: params.authorization.expiresAt ?? null,
163
+ displayName: `Jira ${params.site.name}`,
164
+ });
165
+ connectionId = connection.id;
166
+ await params.tokenStore.storeTokens({
167
+ connectionId: connection.id,
168
+ accessToken: params.authorization.accessToken,
169
+ refreshToken: params.authorization.refreshToken,
170
+ editedBy: params.claims.userId,
171
+ });
172
+ return connection;
173
+ } catch (error) {
174
+ if (connectionId && !existing) await bestEffortDisconnect(params, connectionId);
175
+ throw error;
176
+ }
177
+ }
178
+
179
+ async function bestEffortDisconnect(
180
+ params: Pick<HandleJiraCallbackParams, 'disconnectJiraInstallation'>,
181
+ connectionId: string,
182
+ ): Promise<void> {
183
+ try {
184
+ await params.disconnectJiraInstallation({connectionId});
185
+ } catch (error) {
186
+ logger().warn(
187
+ {err: error, connectionId},
188
+ 'Jira connect compensation failed after token storage rejection',
189
+ );
190
+ }
191
+ }
@@ -0,0 +1,146 @@
1
+ import {createHash} from 'node:crypto';
2
+ import type {JiraAccessibleResource, JiraAuthorization} from '#api/client.js';
3
+ import {
4
+ deleteJiraPendingSelection,
5
+ getJiraPendingSelection,
6
+ listExpiredJiraPendingSelections,
7
+ saveJiraPendingSelection,
8
+ } from '#db/pending-selections.js';
9
+
10
+ const PENDING_SELECTION_TTL_MS = 30 * 60 * 1000;
11
+ const ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';
12
+ const REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';
13
+ const TOKEN_EXPIRES_AT_KEY = 'TOKEN_EXPIRES_AT';
14
+
15
+ export interface JiraPendingSelectionSecretsStore {
16
+ getSecret(params: {workspaceId: string; namespace: string; key: string}): Promise<string | null>;
17
+ setSecrets(params: {
18
+ workspaceId: string;
19
+ namespace: string;
20
+ values: Record<string, string>;
21
+ editedBy?: string | null | undefined;
22
+ }): Promise<void>;
23
+ deleteSecrets(params: {workspaceId: string; namespace: string}): Promise<number>;
24
+ }
25
+
26
+ export interface JiraPendingSelectionStore {
27
+ save(params: {
28
+ workspaceId: string;
29
+ state: string;
30
+ authorization: JiraAuthorization;
31
+ sites: JiraAccessibleResource[];
32
+ }): Promise<void>;
33
+ load(params: {
34
+ workspaceId: string;
35
+ state: string;
36
+ }): Promise<{authorization: JiraAuthorization; sites: JiraAccessibleResource[]} | undefined>;
37
+ clear(params: {workspaceId: string; state: string}): Promise<void>;
38
+ pruneExpiredPendingSelections(now: Date): Promise<void>;
39
+ }
40
+
41
+ export function jiraPendingSecretsNamespace(state: string): string {
42
+ return `system/integrations/jira/pending/${stateHash(state)}`;
43
+ }
44
+
45
+ export function createJiraPendingSelectionStore(params: {
46
+ secrets: JiraPendingSelectionSecretsStore;
47
+ }): JiraPendingSelectionStore {
48
+ async function clear(input: {workspaceId: string; state: string}): Promise<void> {
49
+ const hash = stateHash(input.state);
50
+ await Promise.all([
51
+ deleteJiraPendingSelection({stateHash: hash, workspaceId: input.workspaceId}),
52
+ params.secrets.deleteSecrets({
53
+ workspaceId: input.workspaceId,
54
+ namespace: jiraPendingSecretsNamespace(input.state),
55
+ }),
56
+ ]);
57
+ }
58
+
59
+ return {
60
+ async save(input) {
61
+ const expiresAt = new Date(Date.now() + PENDING_SELECTION_TTL_MS);
62
+ const values: Record<string, string> = {[ACCESS_TOKEN_KEY]: input.authorization.accessToken};
63
+ if (input.authorization.refreshToken)
64
+ values[REFRESH_TOKEN_KEY] = input.authorization.refreshToken;
65
+ if (input.authorization.expiresAt)
66
+ values[TOKEN_EXPIRES_AT_KEY] = input.authorization.expiresAt.toISOString();
67
+ await params.secrets.setSecrets({
68
+ workspaceId: input.workspaceId,
69
+ namespace: jiraPendingSecretsNamespace(input.state),
70
+ values,
71
+ });
72
+ await saveJiraPendingSelection({
73
+ stateHash: stateHash(input.state),
74
+ workspaceId: input.workspaceId,
75
+ expiresAt,
76
+ sites: input.sites,
77
+ });
78
+ },
79
+
80
+ async load(input) {
81
+ const pending = await getJiraPendingSelection({
82
+ stateHash: stateHash(input.state),
83
+ workspaceId: input.workspaceId,
84
+ });
85
+ if (!pending) return undefined;
86
+ if (pending.expiresAt < new Date()) {
87
+ await clear(input);
88
+ return undefined;
89
+ }
90
+ const namespace = jiraPendingSecretsNamespace(input.state);
91
+ const [accessToken, refreshToken, tokenExpiresAt] = await Promise.all([
92
+ params.secrets.getSecret({
93
+ workspaceId: input.workspaceId,
94
+ namespace,
95
+ key: ACCESS_TOKEN_KEY,
96
+ }),
97
+ params.secrets.getSecret({
98
+ workspaceId: input.workspaceId,
99
+ namespace,
100
+ key: REFRESH_TOKEN_KEY,
101
+ }),
102
+ params.secrets.getSecret({
103
+ workspaceId: input.workspaceId,
104
+ namespace,
105
+ key: TOKEN_EXPIRES_AT_KEY,
106
+ }),
107
+ ]);
108
+ if (!accessToken || !refreshToken) {
109
+ await clear(input);
110
+ return undefined;
111
+ }
112
+ const expiresAt = tokenExpiresAt ? new Date(tokenExpiresAt) : undefined;
113
+ return {
114
+ authorization: {
115
+ accessToken,
116
+ refreshToken,
117
+ expiresAt: expiresAt && !Number.isNaN(expiresAt.getTime()) ? expiresAt : undefined,
118
+ scopes: [],
119
+ },
120
+ sites: pending.sites,
121
+ };
122
+ },
123
+
124
+ clear,
125
+
126
+ async pruneExpiredPendingSelections(now) {
127
+ const expired = await listExpiredJiraPendingSelections(now);
128
+ await Promise.all(
129
+ expired.map(async (pending) => {
130
+ await params.secrets.deleteSecrets({
131
+ workspaceId: pending.workspaceId,
132
+ namespace: `system/integrations/jira/pending/${pending.stateHash}`,
133
+ });
134
+ await deleteJiraPendingSelection({
135
+ stateHash: pending.stateHash,
136
+ workspaceId: pending.workspaceId,
137
+ });
138
+ }),
139
+ );
140
+ },
141
+ };
142
+ }
143
+
144
+ function stateHash(state: string): string {
145
+ return createHash('sha256').update(state).digest('hex');
146
+ }
@@ -0,0 +1,19 @@
1
+ import {JiraAuthorizationScopeMismatchError} from './errors.js';
2
+ import {assertJiraAuthorizationScopes, formatJiraOAuthScopes, JIRA_OAUTH_SCOPES} from './scopes.js';
3
+
4
+ describe('Jira OAuth scopes', () => {
5
+ it('formats the requested classic scopes and accepts both classic and granular site scopes', () => {
6
+ const classic = () => assertJiraAuthorizationScopes(['read:jira-work']);
7
+ const granular = () => assertJiraAuthorizationScopes(['read:issue:jira']);
8
+
9
+ expect(formatJiraOAuthScopes()).toBe(JIRA_OAUTH_SCOPES.join(' '));
10
+ expect(classic).not.toThrow();
11
+ expect(granular).not.toThrow();
12
+ });
13
+
14
+ it('rejects an unusable accessible-resource scope list', () => {
15
+ const result = () => assertJiraAuthorizationScopes([]);
16
+
17
+ expect(result).toThrow(JiraAuthorizationScopeMismatchError);
18
+ });
19
+ });
@@ -0,0 +1,20 @@
1
+ import {logger} from '@shipfox/node-opentelemetry';
2
+ import {JiraAuthorizationScopeMismatchError} from './errors.js';
3
+
4
+ export const JIRA_OAUTH_SCOPES = [
5
+ 'read:jira-work',
6
+ 'write:jira-work',
7
+ 'read:jira-user',
8
+ 'manage:jira-webhook',
9
+ 'offline_access',
10
+ ] as const;
11
+
12
+ export function formatJiraOAuthScopes(): string {
13
+ return JIRA_OAUTH_SCOPES.join(' ');
14
+ }
15
+
16
+ export function assertJiraAuthorizationScopes(scopes: readonly string[]): void {
17
+ const usableScopes = scopes.filter((scope) => scope.trim().length > 0);
18
+ if (usableScopes.length === 0) throw new JiraAuthorizationScopeMismatchError(['site scopes']);
19
+ logger().info({scopes: usableScopes}, 'Jira site authorization scopes granted');
20
+ }
@@ -0,0 +1,38 @@
1
+ import {JiraInstallStateError} from './errors.js';
2
+ import {signJiraInstallState, verifyJiraInstallState} from './state.js';
3
+
4
+ describe('Jira install state', () => {
5
+ it('round-trips signed workspace and user claims', () => {
6
+ const state = signJiraInstallState({
7
+ workspaceId: 'workspace-1',
8
+ userId: 'user-1',
9
+ nonce: 'nonce-1',
10
+ now: new Date('2026-07-07T12:00:00.000Z'),
11
+ });
12
+
13
+ const result = verifyJiraInstallState(state, new Date('2026-07-07T12:05:00.000Z'));
14
+
15
+ expect(result).toEqual({workspaceId: 'workspace-1', userId: 'user-1'});
16
+ });
17
+
18
+ it('rejects a tampered or expired state', () => {
19
+ const state = signJiraInstallState({
20
+ workspaceId: 'workspace-1',
21
+ userId: 'user-1',
22
+ nonce: 'nonce-1',
23
+ now: new Date('2026-07-07T12:00:00.000Z'),
24
+ });
25
+ const [payload, signature] = state.split('.');
26
+ const decoded = JSON.parse(Buffer.from(payload ?? '', 'base64url').toString('utf8'));
27
+ decoded.workspaceId = 'workspace-2';
28
+
29
+ const tampered = () =>
30
+ verifyJiraInstallState(
31
+ `${Buffer.from(JSON.stringify(decoded)).toString('base64url')}.${signature}`,
32
+ );
33
+ const expired = () => verifyJiraInstallState(state, new Date('2026-07-07T12:31:00.000Z'));
34
+
35
+ expect(tampered).toThrow(JiraInstallStateError);
36
+ expect(expired).toThrow(JiraInstallStateError);
37
+ });
38
+ });
@@ -0,0 +1,79 @@
1
+ import {createHmac, randomUUID, timingSafeEqual} from 'node:crypto';
2
+ import {config} from '#config.js';
3
+ import {JiraInstallStateError} from './errors.js';
4
+
5
+ const STATE_TTL_SECONDS = 30 * 60;
6
+
7
+ interface JiraInstallStatePayload {
8
+ workspaceId: string;
9
+ userId: string;
10
+ nonce: string;
11
+ expiresAt: number;
12
+ }
13
+
14
+ export interface JiraInstallStateClaims {
15
+ workspaceId: string;
16
+ userId: string;
17
+ }
18
+
19
+ export function signJiraInstallState(params: {
20
+ workspaceId: string;
21
+ userId: string;
22
+ nonce?: string | undefined;
23
+ now?: Date | undefined;
24
+ }): string {
25
+ const now = params.now ?? new Date();
26
+ const payload: JiraInstallStatePayload = {
27
+ workspaceId: params.workspaceId,
28
+ userId: params.userId,
29
+ nonce: params.nonce ?? randomUUID(),
30
+ expiresAt: Math.floor(now.getTime() / 1000) + STATE_TTL_SECONDS,
31
+ };
32
+ const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
33
+ return `${encodedPayload}.${sign(encodedPayload)}`;
34
+ }
35
+
36
+ export function verifyJiraInstallState(
37
+ state: string,
38
+ now: Date = new Date(),
39
+ ): JiraInstallStateClaims {
40
+ const [encodedPayload, signature, extra] = state.split('.');
41
+ if (!encodedPayload || !signature || extra !== undefined) throw new JiraInstallStateError();
42
+ if (!constantTimeEqual(signature, sign(encodedPayload))) {
43
+ throw new JiraInstallStateError('Invalid Jira install state signature');
44
+ }
45
+ const payload = parsePayload(encodedPayload);
46
+ if (payload.expiresAt < Math.floor(now.getTime() / 1000)) {
47
+ throw new JiraInstallStateError('Expired Jira install state');
48
+ }
49
+ return {workspaceId: payload.workspaceId, userId: payload.userId};
50
+ }
51
+
52
+ function sign(encodedPayload: string): string {
53
+ return createHmac('sha256', config.JIRA_OAUTH_CLIENT_SECRET)
54
+ .update(encodedPayload)
55
+ .digest('base64url');
56
+ }
57
+
58
+ function constantTimeEqual(a: string, b: string): boolean {
59
+ const left = Buffer.from(a);
60
+ const right = Buffer.from(b);
61
+ return left.length === right.length && timingSafeEqual(left, right);
62
+ }
63
+
64
+ function parsePayload(encodedPayload: string): JiraInstallStatePayload {
65
+ try {
66
+ const parsed = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
67
+ if (
68
+ typeof parsed.workspaceId !== 'string' ||
69
+ typeof parsed.userId !== 'string' ||
70
+ typeof parsed.nonce !== 'string' ||
71
+ typeof parsed.expiresAt !== 'number'
72
+ ) {
73
+ throw new Error('Invalid payload shape');
74
+ }
75
+ return parsed;
76
+ } catch {
77
+ throw new JiraInstallStateError('Invalid Jira install state payload');
78
+ }
79
+ }
@@ -0,0 +1,106 @@
1
+ const {getInstallation, updateExpiry, withRefreshLock} = vi.hoisted(() => ({
2
+ getInstallation: vi.fn(),
3
+ updateExpiry: vi.fn(),
4
+ withRefreshLock: vi.fn(async (_connectionId: string, fn: () => Promise<string>) => ({
5
+ acquired: true as const,
6
+ value: await fn(),
7
+ })),
8
+ }));
9
+
10
+ vi.mock('#db/installations.js', () => ({
11
+ getJiraInstallationByConnectionId: getInstallation,
12
+ updateJiraInstallationTokenExpiry: updateExpiry,
13
+ withJiraRefreshLock: withRefreshLock,
14
+ }));
15
+
16
+ import {JiraIntegrationProviderError, JiraTokenUnrefreshableError} from './errors.js';
17
+ import {createJiraTokenStore} from './tokens.js';
18
+
19
+ function createStore() {
20
+ const values = new Map<string, string>();
21
+ const secrets = {
22
+ getSecret: vi.fn(async ({key}: {key: string}) => values.get(key) ?? null),
23
+ setSecrets: vi.fn(({values: next}: {values: Record<string, string>}) => {
24
+ for (const [key, value] of Object.entries(next)) values.set(key, value);
25
+ return Promise.resolve();
26
+ }),
27
+ };
28
+ const client = {
29
+ exchangeAuthorizationCode: vi.fn(),
30
+ refreshAccessToken: vi.fn(),
31
+ getAccessibleResources: vi.fn(),
32
+ getMyself: vi.fn(),
33
+ };
34
+ const markConnectionError = vi.fn().mockResolvedValue(undefined);
35
+ const connectionId = crypto.randomUUID();
36
+ const store = createJiraTokenStore({
37
+ resolveConnection: vi.fn().mockResolvedValue({workspaceId: crypto.randomUUID()}),
38
+ secrets,
39
+ client,
40
+ markConnectionError,
41
+ });
42
+ return {client, connectionId, markConnectionError, secrets, store, values};
43
+ }
44
+
45
+ describe('Jira token refresh', () => {
46
+ beforeEach(() => {
47
+ getInstallation.mockResolvedValue({tokenExpiresAt: new Date(0)});
48
+ updateExpiry.mockResolvedValue(undefined);
49
+ withRefreshLock.mockImplementation(async (_connectionId, fn) => ({
50
+ acquired: true,
51
+ value: await fn(),
52
+ }));
53
+ });
54
+
55
+ it('rotates and persists both tokens when the access token expires', async () => {
56
+ const {client, connectionId, secrets, store, values} = createStore();
57
+ await store.storeTokens({connectionId, accessToken: 'access-0', refreshToken: 'refresh-0'});
58
+ client.refreshAccessToken.mockResolvedValue({
59
+ accessToken: 'access-1',
60
+ refreshToken: 'refresh-1',
61
+ expiresAt: new Date('2030-01-01'),
62
+ scopes: ['read:jira-work'],
63
+ });
64
+
65
+ const result = await store.getAccessToken({connectionId});
66
+
67
+ expect(result).toBe('access-1');
68
+ expect(client.refreshAccessToken).toHaveBeenCalledWith({refreshToken: 'refresh-0'});
69
+ expect(values.get('ACCESS_TOKEN')).toBe('access-1');
70
+ expect(values.get('REFRESH_TOKEN')).toBe('refresh-1');
71
+ expect(secrets.setSecrets).toHaveBeenCalledTimes(2);
72
+ expect(updateExpiry).toHaveBeenCalledWith(expect.objectContaining({connectionId}));
73
+ });
74
+
75
+ it('marks only credential failures as connection errors', async () => {
76
+ const {client, connectionId, markConnectionError, store} = createStore();
77
+ await store.storeTokens({connectionId, accessToken: 'access-0', refreshToken: 'refresh-0'});
78
+ client.refreshAccessToken.mockRejectedValue(
79
+ new JiraIntegrationProviderError('access-denied', 'invalid grant'),
80
+ );
81
+
82
+ const result = store.getAccessToken({connectionId});
83
+
84
+ await expect(result).rejects.toBeInstanceOf(JiraIntegrationProviderError);
85
+ expect(markConnectionError).toHaveBeenCalledWith({connectionId});
86
+ });
87
+
88
+ it('requires a refresh token once the access token expires', async () => {
89
+ const {connectionId, store} = createStore();
90
+ await store.storeTokens({connectionId, accessToken: 'access-0'});
91
+
92
+ const result = store.getAccessToken({connectionId});
93
+
94
+ await expect(result).rejects.toBeInstanceOf(JiraTokenUnrefreshableError);
95
+ });
96
+
97
+ it('does not return an expired token when another process owns the refresh lock', async () => {
98
+ const {connectionId, store} = createStore();
99
+ await store.storeTokens({connectionId, accessToken: 'access-0', refreshToken: 'refresh-0'});
100
+ withRefreshLock.mockResolvedValue({acquired: false} as never);
101
+
102
+ const result = store.getAccessToken({connectionId});
103
+
104
+ await expect(result).rejects.toMatchObject({reason: 'provider-unavailable'});
105
+ });
106
+ });
@@ -8,8 +8,8 @@ import {
8
8
 
9
9
  let secrets: JiraSecretsStore;
10
10
 
11
- beforeAll(async () => {
12
- secrets = await import('@shipfox/api-secrets');
11
+ beforeEach(() => {
12
+ secrets = createInMemorySecretsStore();
13
13
  });
14
14
 
15
15
  function createConnectionContext() {
@@ -35,6 +35,20 @@ function storedToken(input: {
35
35
  });
36
36
  }
37
37
 
38
+ function createInMemorySecretsStore(): JiraSecretsStore {
39
+ const values = new Map<string, string>();
40
+ const id = (params: {workspaceId: string; namespace: string; key: string}) =>
41
+ `${params.workspaceId}\0${params.namespace}\0${params.key}`;
42
+ return {
43
+ getSecret: async (params) => values.get(id(params)) ?? null,
44
+ setSecrets: async (params) => {
45
+ await Promise.resolve();
46
+ for (const [key, value] of Object.entries(params.values))
47
+ values.set(id({...params, key}), value);
48
+ },
49
+ };
50
+ }
51
+
38
52
  describe('createJiraTokenStore.storeTokens', () => {
39
53
  it('stores access and refresh tokens in the Jira system namespace', async () => {
40
54
  const {workspaceId, connectionId, store} = createConnectionContext();