@shipfox/api-integration-jira 4.0.0 → 5.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +21 -0
- package/dist/api/client.d.ts +33 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +143 -0
- package/dist/api/client.js.map +1 -0
- package/dist/core/disconnect.d.ts +18 -0
- package/dist/core/disconnect.d.ts.map +1 -0
- package/dist/core/disconnect.js +21 -0
- package/dist/core/disconnect.js.map +1 -0
- package/dist/core/errors.d.ts +37 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +63 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/install.d.ts +55 -0
- package/dist/core/install.d.ts.map +1 -0
- package/dist/core/install.js +121 -0
- package/dist/core/install.js.map +1 -0
- package/dist/core/pending.d.ts +43 -0
- package/dist/core/pending.d.ts.map +1 -0
- package/dist/core/pending.js +107 -0
- package/dist/core/pending.js.map +1 -0
- package/dist/core/scopes.d.ts +4 -0
- package/dist/core/scopes.d.ts.map +1 -0
- package/dist/core/scopes.js +23 -0
- package/dist/core/scopes.js.map +1 -0
- package/dist/core/state.d.ts +12 -0
- package/dist/core/state.d.ts.map +1 -0
- package/dist/core/state.js +51 -0
- package/dist/core/state.js.map +1 -0
- package/dist/core/tokens.d.ts +6 -0
- package/dist/core/tokens.d.ts.map +1 -1
- package/dist/core/tokens.js +82 -6
- package/dist/core/tokens.js.map +1 -1
- package/dist/db/db.d.ts +188 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +3 -1
- package/dist/db/db.js.map +1 -1
- package/dist/db/installations.d.ts +21 -0
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +82 -19
- package/dist/db/installations.js.map +1 -1
- package/dist/db/pending-selections.d.ts +17 -0
- package/dist/db/pending-selections.d.ts.map +1 -0
- package/dist/db/pending-selections.js +27 -0
- package/dist/db/pending-selections.js.map +1 -0
- package/dist/db/schema/installations.js +2 -2
- package/dist/db/schema/installations.js.map +1 -1
- package/dist/db/schema/pending-selections.d.ts +105 -0
- package/dist/db/schema/pending-selections.d.ts.map +1 -0
- package/dist/db/schema/pending-selections.js +24 -0
- package/dist/db/schema/pending-selections.js.map +1 -0
- package/dist/index.d.ts +26 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -4
- package/dist/index.js.map +1 -1
- package/dist/presentation/dto/integrations.d.ts +2 -0
- package/dist/presentation/dto/integrations.d.ts.map +1 -0
- package/dist/presentation/dto/integrations.js +3 -0
- package/dist/presentation/dto/integrations.js.map +1 -0
- package/dist/presentation/routes/errors.d.ts +2 -0
- package/dist/presentation/routes/errors.d.ts.map +1 -0
- package/dist/presentation/routes/errors.js +58 -0
- package/dist/presentation/routes/errors.js.map +1 -0
- package/dist/presentation/routes/install.d.ts +21 -0
- package/dist/presentation/routes/install.d.ts.map +1 -0
- package/dist/presentation/routes/install.js +130 -0
- package/dist/presentation/routes/install.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0000_whole_master_chief.sql +8 -1
- package/drizzle/meta/0000_snapshot.json +47 -3
- package/package.json +21 -24
- package/src/api/client.test.ts +29 -0
- package/src/api/client.ts +213 -0
- package/src/core/disconnect.test.ts +24 -0
- package/src/core/disconnect.ts +25 -0
- package/src/core/errors.ts +79 -0
- package/src/core/install.test.ts +129 -0
- package/src/core/install.ts +191 -0
- package/src/core/pending.ts +146 -0
- package/src/core/scopes.test.ts +19 -0
- package/src/core/scopes.ts +20 -0
- package/src/core/state.test.ts +38 -0
- package/src/core/state.ts +79 -0
- package/src/core/tokens-refresh.test.ts +106 -0
- package/src/core/tokens.ts +145 -8
- package/src/db/db.ts +2 -1
- package/src/db/installations.test.ts +37 -1
- package/src/db/installations.ts +120 -23
- package/src/db/pending-selections.ts +55 -0
- package/src/db/schema/installations.ts +2 -2
- package/src/db/schema/pending-selections.ts +31 -0
- package/src/index.test.ts +7 -2
- package/src/index.ts +66 -2
- package/src/presentation/dto/integrations.ts +1 -0
- package/src/presentation/routes/errors.ts +61 -0
- package/src/presentation/routes/install.ts +147 -0
- package/tsconfig.build.tsbuildinfo +1 -1
- package/turbo.json +9 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/install.ts"],"sourcesContent":["import type {UserContextMembership} from '@shipfox/api-auth-context';\nimport type {IntegrationConnection} from '@shipfox/api-integration-core-dto';\nimport {logger} from '@shipfox/node-opentelemetry';\nimport type {JiraAccessibleResource, JiraApiClient, JiraAuthorization} from '#api/client.js';\nimport type {JiraPendingSelectionStore} from '#core/pending.js';\nimport type {JiraTokenStore} from '#core/tokens.js';\nimport {\n JiraInstallationAlreadyLinkedError,\n JiraInstallStateActorMismatchError,\n JiraIntegrationProviderError,\n JiraOAuthCallbackError,\n JiraOfflineAccessNotGrantedError,\n JiraPendingSelectionNotFoundError,\n JiraSiteSelectionMismatchError,\n} from './errors.js';\nimport {assertJiraAuthorizationScopes} from './scopes.js';\nimport {verifyJiraInstallState} from './state.js';\n\nexport interface ConnectJiraInstallationInput {\n workspaceId: string;\n cloudId: string;\n siteUrl: string;\n siteName: string;\n authorizingAccountId: string;\n scopes: string[];\n tokenExpiresAt: Date | null;\n displayName: string;\n}\n\nexport interface HandleJiraCallbackParams {\n jira: JiraApiClient;\n tokenStore: Pick<JiraTokenStore, 'storeTokens'>;\n pendingStore: Pick<JiraPendingSelectionStore, 'save' | 'load' | 'clear'>;\n code: string;\n state: string;\n sessionUserId: string;\n sessionMemberships: ReadonlyArray<UserContextMembership>;\n requireWorkspaceMembership(input: {\n workspaceId: string;\n userId: string;\n memberships: ReadonlyArray<UserContextMembership>;\n }): Promise<unknown>;\n getExistingJiraConnection(input: {\n cloudId: string;\n }): Promise<IntegrationConnection<'jira'> | undefined>;\n connectJiraInstallation(\n input: ConnectJiraInstallationInput,\n ): Promise<IntegrationConnection<'jira'>>;\n disconnectJiraInstallation(input: {connectionId: string}): Promise<void>;\n}\n\nexport async function handleJiraCallback(\n params: HandleJiraCallbackParams,\n): Promise<IntegrationConnection<'jira'> | {sites: JiraAccessibleResource[]}> {\n const claims = await verifyClaims(params, params.state);\n const authorization = await params.jira.exchangeAuthorizationCode({code: params.code});\n if (!authorization.refreshToken) throw new JiraOfflineAccessNotGrantedError();\n const sites = await params.jira.getAccessibleResources({accessToken: authorization.accessToken});\n if (sites.length === 0)\n throw new JiraIntegrationProviderError(\n 'access-denied',\n 'Jira authorization did not grant access to a site',\n );\n if (sites.length > 1) {\n await params.pendingStore.save({\n workspaceId: claims.workspaceId,\n state: params.state,\n authorization,\n sites,\n });\n return {sites};\n }\n const site = sites[0];\n if (!site) {\n throw new JiraIntegrationProviderError(\n 'malformed-provider-response',\n 'Jira accessible-resources response did not contain a selected site',\n );\n }\n assertJiraAuthorizationScopes(site.scopes);\n return resolveJiraSite({...params, authorization, site, claims});\n}\n\nexport async function handleJiraSiteSelection(\n params: Omit<HandleJiraCallbackParams, 'code'> & {cloudId: string},\n): Promise<IntegrationConnection<'jira'>> {\n const claims = await verifyClaims(params, params.state);\n const pending = await params.pendingStore.load({\n workspaceId: claims.workspaceId,\n state: params.state,\n });\n if (!pending) throw new JiraPendingSelectionNotFoundError();\n const site = pending.sites.find((candidate) => candidate.cloudId === params.cloudId);\n if (!site) throw new JiraSiteSelectionMismatchError(params.cloudId);\n assertJiraAuthorizationScopes(site.scopes);\n const connection = await resolveJiraSite({\n ...params,\n authorization: pending.authorization,\n site,\n claims,\n });\n await params.pendingStore.clear({workspaceId: claims.workspaceId, state: params.state});\n return connection;\n}\n\nexport async function handleJiraOAuthCallbackError(params: {\n state: string;\n error: string;\n errorDescription?: string | undefined;\n sessionUserId: string;\n sessionMemberships: ReadonlyArray<UserContextMembership>;\n requireWorkspaceMembership(input: {\n workspaceId: string;\n userId: string;\n memberships: ReadonlyArray<UserContextMembership>;\n }): Promise<unknown>;\n}): Promise<never> {\n await verifyClaims(params, params.state);\n throw new JiraOAuthCallbackError(params.error, params.errorDescription);\n}\n\nasync function verifyClaims(\n params: Pick<\n HandleJiraCallbackParams,\n 'sessionUserId' | 'sessionMemberships' | 'requireWorkspaceMembership'\n >,\n state: string,\n) {\n const claims = verifyJiraInstallState(state);\n if (claims.userId !== params.sessionUserId) throw new JiraInstallStateActorMismatchError();\n await params.requireWorkspaceMembership({\n workspaceId: claims.workspaceId,\n userId: claims.userId,\n memberships: params.sessionMemberships,\n });\n return claims;\n}\n\nasync function resolveJiraSite(\n params: Omit<HandleJiraCallbackParams, 'code'> & {\n authorization: JiraAuthorization;\n site: JiraAccessibleResource;\n claims: {workspaceId: string; userId: string};\n },\n): Promise<IntegrationConnection<'jira'>> {\n const existing = await params.getExistingJiraConnection({cloudId: params.site.cloudId});\n if (existing && existing.workspaceId !== params.claims.workspaceId)\n throw new JiraInstallationAlreadyLinkedError(params.site.cloudId);\n let connectionId: string | undefined;\n try {\n const identity = await params.jira.getMyself({\n accessToken: params.authorization.accessToken,\n cloudId: params.site.cloudId,\n });\n const connection = await params.connectJiraInstallation({\n workspaceId: params.claims.workspaceId,\n cloudId: params.site.cloudId,\n siteUrl: params.site.url,\n siteName: params.site.name,\n authorizingAccountId: identity.accountId,\n scopes: params.site.scopes,\n tokenExpiresAt: params.authorization.expiresAt ?? null,\n displayName: `Jira ${params.site.name}`,\n });\n connectionId = connection.id;\n await params.tokenStore.storeTokens({\n connectionId: connection.id,\n accessToken: params.authorization.accessToken,\n refreshToken: params.authorization.refreshToken,\n editedBy: params.claims.userId,\n });\n return connection;\n } catch (error) {\n if (connectionId && !existing) await bestEffortDisconnect(params, connectionId);\n throw error;\n }\n}\n\nasync function bestEffortDisconnect(\n params: Pick<HandleJiraCallbackParams, 'disconnectJiraInstallation'>,\n connectionId: string,\n): Promise<void> {\n try {\n await params.disconnectJiraInstallation({connectionId});\n } catch (error) {\n logger().warn(\n {err: error, connectionId},\n 'Jira connect compensation failed after token storage rejection',\n );\n }\n}\n"],"names":["logger","JiraInstallationAlreadyLinkedError","JiraInstallStateActorMismatchError","JiraIntegrationProviderError","JiraOAuthCallbackError","JiraOfflineAccessNotGrantedError","JiraPendingSelectionNotFoundError","JiraSiteSelectionMismatchError","assertJiraAuthorizationScopes","verifyJiraInstallState","handleJiraCallback","params","claims","verifyClaims","state","authorization","jira","exchangeAuthorizationCode","code","refreshToken","sites","getAccessibleResources","accessToken","length","pendingStore","save","workspaceId","site","scopes","resolveJiraSite","handleJiraSiteSelection","pending","load","find","candidate","cloudId","connection","clear","handleJiraOAuthCallbackError","error","errorDescription","userId","sessionUserId","requireWorkspaceMembership","memberships","sessionMemberships","existing","getExistingJiraConnection","connectionId","identity","getMyself","connectJiraInstallation","siteUrl","url","siteName","name","authorizingAccountId","accountId","tokenExpiresAt","expiresAt","displayName","id","tokenStore","storeTokens","editedBy","bestEffortDisconnect","disconnectJiraInstallation","warn","err"],"mappings":"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"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { JiraAccessibleResource, JiraAuthorization } from '#api/client.js';
|
|
2
|
+
export interface JiraPendingSelectionSecretsStore {
|
|
3
|
+
getSecret(params: {
|
|
4
|
+
workspaceId: string;
|
|
5
|
+
namespace: string;
|
|
6
|
+
key: string;
|
|
7
|
+
}): Promise<string | null>;
|
|
8
|
+
setSecrets(params: {
|
|
9
|
+
workspaceId: string;
|
|
10
|
+
namespace: string;
|
|
11
|
+
values: Record<string, string>;
|
|
12
|
+
editedBy?: string | null | undefined;
|
|
13
|
+
}): Promise<void>;
|
|
14
|
+
deleteSecrets(params: {
|
|
15
|
+
workspaceId: string;
|
|
16
|
+
namespace: string;
|
|
17
|
+
}): Promise<number>;
|
|
18
|
+
}
|
|
19
|
+
export interface JiraPendingSelectionStore {
|
|
20
|
+
save(params: {
|
|
21
|
+
workspaceId: string;
|
|
22
|
+
state: string;
|
|
23
|
+
authorization: JiraAuthorization;
|
|
24
|
+
sites: JiraAccessibleResource[];
|
|
25
|
+
}): Promise<void>;
|
|
26
|
+
load(params: {
|
|
27
|
+
workspaceId: string;
|
|
28
|
+
state: string;
|
|
29
|
+
}): Promise<{
|
|
30
|
+
authorization: JiraAuthorization;
|
|
31
|
+
sites: JiraAccessibleResource[];
|
|
32
|
+
} | undefined>;
|
|
33
|
+
clear(params: {
|
|
34
|
+
workspaceId: string;
|
|
35
|
+
state: string;
|
|
36
|
+
}): Promise<void>;
|
|
37
|
+
pruneExpiredPendingSelections(now: Date): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
export declare function jiraPendingSecretsNamespace(state: string): string;
|
|
40
|
+
export declare function createJiraPendingSelectionStore(params: {
|
|
41
|
+
secrets: JiraPendingSelectionSecretsStore;
|
|
42
|
+
}): JiraPendingSelectionStore;
|
|
43
|
+
//# sourceMappingURL=pending.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pending.d.ts","sourceRoot":"","sources":["../../src/core/pending.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAC,sBAAsB,EAAE,iBAAiB,EAAC,MAAM,gBAAgB,CAAC;AAa9E,MAAM,WAAW,gCAAgC;IAC/C,SAAS,CAAC,MAAM,EAAE;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACjG,UAAU,CAAC,MAAM,EAAE;QACjB,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;KACtC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClB,aAAa,CAAC,MAAM,EAAE;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAClF;AAED,MAAM,WAAW,yBAAyB;IACxC,IAAI,CAAC,MAAM,EAAE;QACX,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,MAAM,CAAC;QACd,aAAa,EAAE,iBAAiB,CAAC;QACjC,KAAK,EAAE,sBAAsB,EAAE,CAAC;KACjC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClB,IAAI,CAAC,MAAM,EAAE;QACX,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,MAAM,CAAC;KACf,GAAG,OAAO,CAAC;QAAC,aAAa,EAAE,iBAAiB,CAAC;QAAC,KAAK,EAAE,sBAAsB,EAAE,CAAA;KAAC,GAAG,SAAS,CAAC,CAAC;IAC7F,KAAK,CAAC,MAAM,EAAE;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnE,6BAA6B,CAAC,GAAG,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AAED,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEjE;AAED,wBAAgB,+BAA+B,CAAC,MAAM,EAAE;IACtD,OAAO,EAAE,gCAAgC,CAAC;CAC3C,GAAG,yBAAyB,CA+F5B"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { deleteJiraPendingSelection, getJiraPendingSelection, listExpiredJiraPendingSelections, saveJiraPendingSelection } from '#db/pending-selections.js';
|
|
3
|
+
const PENDING_SELECTION_TTL_MS = 30 * 60 * 1000;
|
|
4
|
+
const ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';
|
|
5
|
+
const REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';
|
|
6
|
+
const TOKEN_EXPIRES_AT_KEY = 'TOKEN_EXPIRES_AT';
|
|
7
|
+
export function jiraPendingSecretsNamespace(state) {
|
|
8
|
+
return `system/integrations/jira/pending/${stateHash(state)}`;
|
|
9
|
+
}
|
|
10
|
+
export function createJiraPendingSelectionStore(params) {
|
|
11
|
+
async function clear(input) {
|
|
12
|
+
const hash = stateHash(input.state);
|
|
13
|
+
await Promise.all([
|
|
14
|
+
deleteJiraPendingSelection({
|
|
15
|
+
stateHash: hash,
|
|
16
|
+
workspaceId: input.workspaceId
|
|
17
|
+
}),
|
|
18
|
+
params.secrets.deleteSecrets({
|
|
19
|
+
workspaceId: input.workspaceId,
|
|
20
|
+
namespace: jiraPendingSecretsNamespace(input.state)
|
|
21
|
+
})
|
|
22
|
+
]);
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
async save (input) {
|
|
26
|
+
const expiresAt = new Date(Date.now() + PENDING_SELECTION_TTL_MS);
|
|
27
|
+
const values = {
|
|
28
|
+
[ACCESS_TOKEN_KEY]: input.authorization.accessToken
|
|
29
|
+
};
|
|
30
|
+
if (input.authorization.refreshToken) values[REFRESH_TOKEN_KEY] = input.authorization.refreshToken;
|
|
31
|
+
if (input.authorization.expiresAt) values[TOKEN_EXPIRES_AT_KEY] = input.authorization.expiresAt.toISOString();
|
|
32
|
+
await params.secrets.setSecrets({
|
|
33
|
+
workspaceId: input.workspaceId,
|
|
34
|
+
namespace: jiraPendingSecretsNamespace(input.state),
|
|
35
|
+
values
|
|
36
|
+
});
|
|
37
|
+
await saveJiraPendingSelection({
|
|
38
|
+
stateHash: stateHash(input.state),
|
|
39
|
+
workspaceId: input.workspaceId,
|
|
40
|
+
expiresAt,
|
|
41
|
+
sites: input.sites
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
async load (input) {
|
|
45
|
+
const pending = await getJiraPendingSelection({
|
|
46
|
+
stateHash: stateHash(input.state),
|
|
47
|
+
workspaceId: input.workspaceId
|
|
48
|
+
});
|
|
49
|
+
if (!pending) return undefined;
|
|
50
|
+
if (pending.expiresAt < new Date()) {
|
|
51
|
+
await clear(input);
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const namespace = jiraPendingSecretsNamespace(input.state);
|
|
55
|
+
const [accessToken, refreshToken, tokenExpiresAt] = await Promise.all([
|
|
56
|
+
params.secrets.getSecret({
|
|
57
|
+
workspaceId: input.workspaceId,
|
|
58
|
+
namespace,
|
|
59
|
+
key: ACCESS_TOKEN_KEY
|
|
60
|
+
}),
|
|
61
|
+
params.secrets.getSecret({
|
|
62
|
+
workspaceId: input.workspaceId,
|
|
63
|
+
namespace,
|
|
64
|
+
key: REFRESH_TOKEN_KEY
|
|
65
|
+
}),
|
|
66
|
+
params.secrets.getSecret({
|
|
67
|
+
workspaceId: input.workspaceId,
|
|
68
|
+
namespace,
|
|
69
|
+
key: TOKEN_EXPIRES_AT_KEY
|
|
70
|
+
})
|
|
71
|
+
]);
|
|
72
|
+
if (!accessToken || !refreshToken) {
|
|
73
|
+
await clear(input);
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const expiresAt = tokenExpiresAt ? new Date(tokenExpiresAt) : undefined;
|
|
77
|
+
return {
|
|
78
|
+
authorization: {
|
|
79
|
+
accessToken,
|
|
80
|
+
refreshToken,
|
|
81
|
+
expiresAt: expiresAt && !Number.isNaN(expiresAt.getTime()) ? expiresAt : undefined,
|
|
82
|
+
scopes: []
|
|
83
|
+
},
|
|
84
|
+
sites: pending.sites
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
clear,
|
|
88
|
+
async pruneExpiredPendingSelections (now) {
|
|
89
|
+
const expired = await listExpiredJiraPendingSelections(now);
|
|
90
|
+
await Promise.all(expired.map(async (pending)=>{
|
|
91
|
+
await params.secrets.deleteSecrets({
|
|
92
|
+
workspaceId: pending.workspaceId,
|
|
93
|
+
namespace: `system/integrations/jira/pending/${pending.stateHash}`
|
|
94
|
+
});
|
|
95
|
+
await deleteJiraPendingSelection({
|
|
96
|
+
stateHash: pending.stateHash,
|
|
97
|
+
workspaceId: pending.workspaceId
|
|
98
|
+
});
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function stateHash(state) {
|
|
104
|
+
return createHash('sha256').update(state).digest('hex');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
//# sourceMappingURL=pending.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/pending.ts"],"sourcesContent":["import {createHash} from 'node:crypto';\nimport type {JiraAccessibleResource, JiraAuthorization} from '#api/client.js';\nimport {\n deleteJiraPendingSelection,\n getJiraPendingSelection,\n listExpiredJiraPendingSelections,\n saveJiraPendingSelection,\n} from '#db/pending-selections.js';\n\nconst PENDING_SELECTION_TTL_MS = 30 * 60 * 1000;\nconst ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';\nconst REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';\nconst TOKEN_EXPIRES_AT_KEY = 'TOKEN_EXPIRES_AT';\n\nexport interface JiraPendingSelectionSecretsStore {\n getSecret(params: {workspaceId: string; namespace: string; key: string}): Promise<string | null>;\n setSecrets(params: {\n workspaceId: string;\n namespace: string;\n values: Record<string, string>;\n editedBy?: string | null | undefined;\n }): Promise<void>;\n deleteSecrets(params: {workspaceId: string; namespace: string}): Promise<number>;\n}\n\nexport interface JiraPendingSelectionStore {\n save(params: {\n workspaceId: string;\n state: string;\n authorization: JiraAuthorization;\n sites: JiraAccessibleResource[];\n }): Promise<void>;\n load(params: {\n workspaceId: string;\n state: string;\n }): Promise<{authorization: JiraAuthorization; sites: JiraAccessibleResource[]} | undefined>;\n clear(params: {workspaceId: string; state: string}): Promise<void>;\n pruneExpiredPendingSelections(now: Date): Promise<void>;\n}\n\nexport function jiraPendingSecretsNamespace(state: string): string {\n return `system/integrations/jira/pending/${stateHash(state)}`;\n}\n\nexport function createJiraPendingSelectionStore(params: {\n secrets: JiraPendingSelectionSecretsStore;\n}): JiraPendingSelectionStore {\n async function clear(input: {workspaceId: string; state: string}): Promise<void> {\n const hash = stateHash(input.state);\n await Promise.all([\n deleteJiraPendingSelection({stateHash: hash, workspaceId: input.workspaceId}),\n params.secrets.deleteSecrets({\n workspaceId: input.workspaceId,\n namespace: jiraPendingSecretsNamespace(input.state),\n }),\n ]);\n }\n\n return {\n async save(input) {\n const expiresAt = new Date(Date.now() + PENDING_SELECTION_TTL_MS);\n const values: Record<string, string> = {[ACCESS_TOKEN_KEY]: input.authorization.accessToken};\n if (input.authorization.refreshToken)\n values[REFRESH_TOKEN_KEY] = input.authorization.refreshToken;\n if (input.authorization.expiresAt)\n values[TOKEN_EXPIRES_AT_KEY] = input.authorization.expiresAt.toISOString();\n await params.secrets.setSecrets({\n workspaceId: input.workspaceId,\n namespace: jiraPendingSecretsNamespace(input.state),\n values,\n });\n await saveJiraPendingSelection({\n stateHash: stateHash(input.state),\n workspaceId: input.workspaceId,\n expiresAt,\n sites: input.sites,\n });\n },\n\n async load(input) {\n const pending = await getJiraPendingSelection({\n stateHash: stateHash(input.state),\n workspaceId: input.workspaceId,\n });\n if (!pending) return undefined;\n if (pending.expiresAt < new Date()) {\n await clear(input);\n return undefined;\n }\n const namespace = jiraPendingSecretsNamespace(input.state);\n const [accessToken, refreshToken, tokenExpiresAt] = await Promise.all([\n params.secrets.getSecret({\n workspaceId: input.workspaceId,\n namespace,\n key: ACCESS_TOKEN_KEY,\n }),\n params.secrets.getSecret({\n workspaceId: input.workspaceId,\n namespace,\n key: REFRESH_TOKEN_KEY,\n }),\n params.secrets.getSecret({\n workspaceId: input.workspaceId,\n namespace,\n key: TOKEN_EXPIRES_AT_KEY,\n }),\n ]);\n if (!accessToken || !refreshToken) {\n await clear(input);\n return undefined;\n }\n const expiresAt = tokenExpiresAt ? new Date(tokenExpiresAt) : undefined;\n return {\n authorization: {\n accessToken,\n refreshToken,\n expiresAt: expiresAt && !Number.isNaN(expiresAt.getTime()) ? expiresAt : undefined,\n scopes: [],\n },\n sites: pending.sites,\n };\n },\n\n clear,\n\n async pruneExpiredPendingSelections(now) {\n const expired = await listExpiredJiraPendingSelections(now);\n await Promise.all(\n expired.map(async (pending) => {\n await params.secrets.deleteSecrets({\n workspaceId: pending.workspaceId,\n namespace: `system/integrations/jira/pending/${pending.stateHash}`,\n });\n await deleteJiraPendingSelection({\n stateHash: pending.stateHash,\n workspaceId: pending.workspaceId,\n });\n }),\n );\n },\n };\n}\n\nfunction stateHash(state: string): string {\n return createHash('sha256').update(state).digest('hex');\n}\n"],"names":["createHash","deleteJiraPendingSelection","getJiraPendingSelection","listExpiredJiraPendingSelections","saveJiraPendingSelection","PENDING_SELECTION_TTL_MS","ACCESS_TOKEN_KEY","REFRESH_TOKEN_KEY","TOKEN_EXPIRES_AT_KEY","jiraPendingSecretsNamespace","state","stateHash","createJiraPendingSelectionStore","params","clear","input","hash","Promise","all","workspaceId","secrets","deleteSecrets","namespace","save","expiresAt","Date","now","values","authorization","accessToken","refreshToken","toISOString","setSecrets","sites","load","pending","undefined","tokenExpiresAt","getSecret","key","Number","isNaN","getTime","scopes","pruneExpiredPendingSelections","expired","map","update","digest"],"mappings":"AAAA,SAAQA,UAAU,QAAO,cAAc;AAEvC,SACEC,0BAA0B,EAC1BC,uBAAuB,EACvBC,gCAAgC,EAChCC,wBAAwB,QACnB,4BAA4B;AAEnC,MAAMC,2BAA2B,KAAK,KAAK;AAC3C,MAAMC,mBAAmB;AACzB,MAAMC,oBAAoB;AAC1B,MAAMC,uBAAuB;AA4B7B,OAAO,SAASC,4BAA4BC,KAAa;IACvD,OAAO,CAAC,iCAAiC,EAAEC,UAAUD,QAAQ;AAC/D;AAEA,OAAO,SAASE,gCAAgCC,MAE/C;IACC,eAAeC,MAAMC,KAA2C;QAC9D,MAAMC,OAAOL,UAAUI,MAAML,KAAK;QAClC,MAAMO,QAAQC,GAAG,CAAC;YAChBjB,2BAA2B;gBAACU,WAAWK;gBAAMG,aAAaJ,MAAMI,WAAW;YAAA;YAC3EN,OAAOO,OAAO,CAACC,aAAa,CAAC;gBAC3BF,aAAaJ,MAAMI,WAAW;gBAC9BG,WAAWb,4BAA4BM,MAAML,KAAK;YACpD;SACD;IACH;IAEA,OAAO;QACL,MAAMa,MAAKR,KAAK;YACd,MAAMS,YAAY,IAAIC,KAAKA,KAAKC,GAAG,KAAKrB;YACxC,MAAMsB,SAAiC;gBAAC,CAACrB,iBAAiB,EAAES,MAAMa,aAAa,CAACC,WAAW;YAAA;YAC3F,IAAId,MAAMa,aAAa,CAACE,YAAY,EAClCH,MAAM,CAACpB,kBAAkB,GAAGQ,MAAMa,aAAa,CAACE,YAAY;YAC9D,IAAIf,MAAMa,aAAa,CAACJ,SAAS,EAC/BG,MAAM,CAACnB,qBAAqB,GAAGO,MAAMa,aAAa,CAACJ,SAAS,CAACO,WAAW;YAC1E,MAAMlB,OAAOO,OAAO,CAACY,UAAU,CAAC;gBAC9Bb,aAAaJ,MAAMI,WAAW;gBAC9BG,WAAWb,4BAA4BM,MAAML,KAAK;gBAClDiB;YACF;YACA,MAAMvB,yBAAyB;gBAC7BO,WAAWA,UAAUI,MAAML,KAAK;gBAChCS,aAAaJ,MAAMI,WAAW;gBAC9BK;gBACAS,OAAOlB,MAAMkB,KAAK;YACpB;QACF;QAEA,MAAMC,MAAKnB,KAAK;YACd,MAAMoB,UAAU,MAAMjC,wBAAwB;gBAC5CS,WAAWA,UAAUI,MAAML,KAAK;gBAChCS,aAAaJ,MAAMI,WAAW;YAChC;YACA,IAAI,CAACgB,SAAS,OAAOC;YACrB,IAAID,QAAQX,SAAS,GAAG,IAAIC,QAAQ;gBAClC,MAAMX,MAAMC;gBACZ,OAAOqB;YACT;YACA,MAAMd,YAAYb,4BAA4BM,MAAML,KAAK;YACzD,MAAM,CAACmB,aAAaC,cAAcO,eAAe,GAAG,MAAMpB,QAAQC,GAAG,CAAC;gBACpEL,OAAOO,OAAO,CAACkB,SAAS,CAAC;oBACvBnB,aAAaJ,MAAMI,WAAW;oBAC9BG;oBACAiB,KAAKjC;gBACP;gBACAO,OAAOO,OAAO,CAACkB,SAAS,CAAC;oBACvBnB,aAAaJ,MAAMI,WAAW;oBAC9BG;oBACAiB,KAAKhC;gBACP;gBACAM,OAAOO,OAAO,CAACkB,SAAS,CAAC;oBACvBnB,aAAaJ,MAAMI,WAAW;oBAC9BG;oBACAiB,KAAK/B;gBACP;aACD;YACD,IAAI,CAACqB,eAAe,CAACC,cAAc;gBACjC,MAAMhB,MAAMC;gBACZ,OAAOqB;YACT;YACA,MAAMZ,YAAYa,iBAAiB,IAAIZ,KAAKY,kBAAkBD;YAC9D,OAAO;gBACLR,eAAe;oBACbC;oBACAC;oBACAN,WAAWA,aAAa,CAACgB,OAAOC,KAAK,CAACjB,UAAUkB,OAAO,MAAMlB,YAAYY;oBACzEO,QAAQ,EAAE;gBACZ;gBACAV,OAAOE,QAAQF,KAAK;YACtB;QACF;QAEAnB;QAEA,MAAM8B,+BAA8BlB,GAAG;YACrC,MAAMmB,UAAU,MAAM1C,iCAAiCuB;YACvD,MAAMT,QAAQC,GAAG,CACf2B,QAAQC,GAAG,CAAC,OAAOX;gBACjB,MAAMtB,OAAOO,OAAO,CAACC,aAAa,CAAC;oBACjCF,aAAagB,QAAQhB,WAAW;oBAChCG,WAAW,CAAC,iCAAiC,EAAEa,QAAQxB,SAAS,EAAE;gBACpE;gBACA,MAAMV,2BAA2B;oBAC/BU,WAAWwB,QAAQxB,SAAS;oBAC5BQ,aAAagB,QAAQhB,WAAW;gBAClC;YACF;QAEJ;IACF;AACF;AAEA,SAASR,UAAUD,KAAa;IAC9B,OAAOV,WAAW,UAAU+C,MAAM,CAACrC,OAAOsC,MAAM,CAAC;AACnD"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const JIRA_OAUTH_SCOPES: readonly ["read:jira-work", "write:jira-work", "read:jira-user", "manage:jira-webhook", "offline_access"];
|
|
2
|
+
export declare function formatJiraOAuthScopes(): string;
|
|
3
|
+
export declare function assertJiraAuthorizationScopes(scopes: readonly string[]): void;
|
|
4
|
+
//# sourceMappingURL=scopes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scopes.d.ts","sourceRoot":"","sources":["../../src/core/scopes.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,iBAAiB,2GAMpB,CAAC;AAEX,wBAAgB,qBAAqB,IAAI,MAAM,CAE9C;AAED,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAI7E"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { logger } from '@shipfox/node-opentelemetry';
|
|
2
|
+
import { JiraAuthorizationScopeMismatchError } from './errors.js';
|
|
3
|
+
export const JIRA_OAUTH_SCOPES = [
|
|
4
|
+
'read:jira-work',
|
|
5
|
+
'write:jira-work',
|
|
6
|
+
'read:jira-user',
|
|
7
|
+
'manage:jira-webhook',
|
|
8
|
+
'offline_access'
|
|
9
|
+
];
|
|
10
|
+
export function formatJiraOAuthScopes() {
|
|
11
|
+
return JIRA_OAUTH_SCOPES.join(' ');
|
|
12
|
+
}
|
|
13
|
+
export function assertJiraAuthorizationScopes(scopes) {
|
|
14
|
+
const usableScopes = scopes.filter((scope)=>scope.trim().length > 0);
|
|
15
|
+
if (usableScopes.length === 0) throw new JiraAuthorizationScopeMismatchError([
|
|
16
|
+
'site scopes'
|
|
17
|
+
]);
|
|
18
|
+
logger().info({
|
|
19
|
+
scopes: usableScopes
|
|
20
|
+
}, 'Jira site authorization scopes granted');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
//# sourceMappingURL=scopes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/scopes.ts"],"sourcesContent":["import {logger} from '@shipfox/node-opentelemetry';\nimport {JiraAuthorizationScopeMismatchError} from './errors.js';\n\nexport const JIRA_OAUTH_SCOPES = [\n 'read:jira-work',\n 'write:jira-work',\n 'read:jira-user',\n 'manage:jira-webhook',\n 'offline_access',\n] as const;\n\nexport function formatJiraOAuthScopes(): string {\n return JIRA_OAUTH_SCOPES.join(' ');\n}\n\nexport function assertJiraAuthorizationScopes(scopes: readonly string[]): void {\n const usableScopes = scopes.filter((scope) => scope.trim().length > 0);\n if (usableScopes.length === 0) throw new JiraAuthorizationScopeMismatchError(['site scopes']);\n logger().info({scopes: usableScopes}, 'Jira site authorization scopes granted');\n}\n"],"names":["logger","JiraAuthorizationScopeMismatchError","JIRA_OAUTH_SCOPES","formatJiraOAuthScopes","join","assertJiraAuthorizationScopes","scopes","usableScopes","filter","scope","trim","length","info"],"mappings":"AAAA,SAAQA,MAAM,QAAO,8BAA8B;AACnD,SAAQC,mCAAmC,QAAO,cAAc;AAEhE,OAAO,MAAMC,oBAAoB;IAC/B;IACA;IACA;IACA;IACA;CACD,CAAU;AAEX,OAAO,SAASC;IACd,OAAOD,kBAAkBE,IAAI,CAAC;AAChC;AAEA,OAAO,SAASC,8BAA8BC,MAAyB;IACrE,MAAMC,eAAeD,OAAOE,MAAM,CAAC,CAACC,QAAUA,MAAMC,IAAI,GAAGC,MAAM,GAAG;IACpE,IAAIJ,aAAaI,MAAM,KAAK,GAAG,MAAM,IAAIV,oCAAoC;QAAC;KAAc;IAC5FD,SAASY,IAAI,CAAC;QAACN,QAAQC;IAAY,GAAG;AACxC"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface JiraInstallStateClaims {
|
|
2
|
+
workspaceId: string;
|
|
3
|
+
userId: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function signJiraInstallState(params: {
|
|
6
|
+
workspaceId: string;
|
|
7
|
+
userId: string;
|
|
8
|
+
nonce?: string | undefined;
|
|
9
|
+
now?: Date | undefined;
|
|
10
|
+
}): string;
|
|
11
|
+
export declare function verifyJiraInstallState(state: string, now?: Date): JiraInstallStateClaims;
|
|
12
|
+
//# sourceMappingURL=state.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../src/core/state.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,sBAAsB;IACrC,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE;IAC3C,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,GAAG,CAAC,EAAE,IAAI,GAAG,SAAS,CAAC;CACxB,GAAG,MAAM,CAUT;AAED,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,MAAM,EACb,GAAG,GAAE,IAAiB,GACrB,sBAAsB,CAWxB"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { config } from '#config.js';
|
|
3
|
+
import { JiraInstallStateError } from './errors.js';
|
|
4
|
+
const STATE_TTL_SECONDS = 30 * 60;
|
|
5
|
+
export function signJiraInstallState(params) {
|
|
6
|
+
const now = params.now ?? new Date();
|
|
7
|
+
const payload = {
|
|
8
|
+
workspaceId: params.workspaceId,
|
|
9
|
+
userId: params.userId,
|
|
10
|
+
nonce: params.nonce ?? randomUUID(),
|
|
11
|
+
expiresAt: Math.floor(now.getTime() / 1000) + STATE_TTL_SECONDS
|
|
12
|
+
};
|
|
13
|
+
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
14
|
+
return `${encodedPayload}.${sign(encodedPayload)}`;
|
|
15
|
+
}
|
|
16
|
+
export function verifyJiraInstallState(state, now = new Date()) {
|
|
17
|
+
const [encodedPayload, signature, extra] = state.split('.');
|
|
18
|
+
if (!encodedPayload || !signature || extra !== undefined) throw new JiraInstallStateError();
|
|
19
|
+
if (!constantTimeEqual(signature, sign(encodedPayload))) {
|
|
20
|
+
throw new JiraInstallStateError('Invalid Jira install state signature');
|
|
21
|
+
}
|
|
22
|
+
const payload = parsePayload(encodedPayload);
|
|
23
|
+
if (payload.expiresAt < Math.floor(now.getTime() / 1000)) {
|
|
24
|
+
throw new JiraInstallStateError('Expired Jira install state');
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
workspaceId: payload.workspaceId,
|
|
28
|
+
userId: payload.userId
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function sign(encodedPayload) {
|
|
32
|
+
return createHmac('sha256', config.JIRA_OAUTH_CLIENT_SECRET).update(encodedPayload).digest('base64url');
|
|
33
|
+
}
|
|
34
|
+
function constantTimeEqual(a, b) {
|
|
35
|
+
const left = Buffer.from(a);
|
|
36
|
+
const right = Buffer.from(b);
|
|
37
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
38
|
+
}
|
|
39
|
+
function parsePayload(encodedPayload) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
|
|
42
|
+
if (typeof parsed.workspaceId !== 'string' || typeof parsed.userId !== 'string' || typeof parsed.nonce !== 'string' || typeof parsed.expiresAt !== 'number') {
|
|
43
|
+
throw new Error('Invalid payload shape');
|
|
44
|
+
}
|
|
45
|
+
return parsed;
|
|
46
|
+
} catch {
|
|
47
|
+
throw new JiraInstallStateError('Invalid Jira install state payload');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
//# sourceMappingURL=state.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/state.ts"],"sourcesContent":["import {createHmac, randomUUID, timingSafeEqual} from 'node:crypto';\nimport {config} from '#config.js';\nimport {JiraInstallStateError} from './errors.js';\n\nconst STATE_TTL_SECONDS = 30 * 60;\n\ninterface JiraInstallStatePayload {\n workspaceId: string;\n userId: string;\n nonce: string;\n expiresAt: number;\n}\n\nexport interface JiraInstallStateClaims {\n workspaceId: string;\n userId: string;\n}\n\nexport function signJiraInstallState(params: {\n workspaceId: string;\n userId: string;\n nonce?: string | undefined;\n now?: Date | undefined;\n}): string {\n const now = params.now ?? new Date();\n const payload: JiraInstallStatePayload = {\n workspaceId: params.workspaceId,\n userId: params.userId,\n nonce: params.nonce ?? randomUUID(),\n expiresAt: Math.floor(now.getTime() / 1000) + STATE_TTL_SECONDS,\n };\n const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');\n return `${encodedPayload}.${sign(encodedPayload)}`;\n}\n\nexport function verifyJiraInstallState(\n state: string,\n now: Date = new Date(),\n): JiraInstallStateClaims {\n const [encodedPayload, signature, extra] = state.split('.');\n if (!encodedPayload || !signature || extra !== undefined) throw new JiraInstallStateError();\n if (!constantTimeEqual(signature, sign(encodedPayload))) {\n throw new JiraInstallStateError('Invalid Jira install state signature');\n }\n const payload = parsePayload(encodedPayload);\n if (payload.expiresAt < Math.floor(now.getTime() / 1000)) {\n throw new JiraInstallStateError('Expired Jira install state');\n }\n return {workspaceId: payload.workspaceId, userId: payload.userId};\n}\n\nfunction sign(encodedPayload: string): string {\n return createHmac('sha256', config.JIRA_OAUTH_CLIENT_SECRET)\n .update(encodedPayload)\n .digest('base64url');\n}\n\nfunction constantTimeEqual(a: string, b: string): boolean {\n const left = Buffer.from(a);\n const right = Buffer.from(b);\n return left.length === right.length && timingSafeEqual(left, right);\n}\n\nfunction parsePayload(encodedPayload: string): JiraInstallStatePayload {\n try {\n const parsed = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));\n if (\n typeof parsed.workspaceId !== 'string' ||\n typeof parsed.userId !== 'string' ||\n typeof parsed.nonce !== 'string' ||\n typeof parsed.expiresAt !== 'number'\n ) {\n throw new Error('Invalid payload shape');\n }\n return parsed;\n } catch {\n throw new JiraInstallStateError('Invalid Jira install state payload');\n }\n}\n"],"names":["createHmac","randomUUID","timingSafeEqual","config","JiraInstallStateError","STATE_TTL_SECONDS","signJiraInstallState","params","now","Date","payload","workspaceId","userId","nonce","expiresAt","Math","floor","getTime","encodedPayload","Buffer","from","JSON","stringify","toString","sign","verifyJiraInstallState","state","signature","extra","split","undefined","constantTimeEqual","parsePayload","JIRA_OAUTH_CLIENT_SECRET","update","digest","a","b","left","right","length","parsed","parse","Error"],"mappings":"AAAA,SAAQA,UAAU,EAAEC,UAAU,EAAEC,eAAe,QAAO,cAAc;AACpE,SAAQC,MAAM,QAAO,aAAa;AAClC,SAAQC,qBAAqB,QAAO,cAAc;AAElD,MAAMC,oBAAoB,KAAK;AAc/B,OAAO,SAASC,qBAAqBC,MAKpC;IACC,MAAMC,MAAMD,OAAOC,GAAG,IAAI,IAAIC;IAC9B,MAAMC,UAAmC;QACvCC,aAAaJ,OAAOI,WAAW;QAC/BC,QAAQL,OAAOK,MAAM;QACrBC,OAAON,OAAOM,KAAK,IAAIZ;QACvBa,WAAWC,KAAKC,KAAK,CAACR,IAAIS,OAAO,KAAK,QAAQZ;IAChD;IACA,MAAMa,iBAAiBC,OAAOC,IAAI,CAACC,KAAKC,SAAS,CAACZ,UAAUa,QAAQ,CAAC;IACrE,OAAO,GAAGL,eAAe,CAAC,EAAEM,KAAKN,iBAAiB;AACpD;AAEA,OAAO,SAASO,uBACdC,KAAa,EACblB,MAAY,IAAIC,MAAM;IAEtB,MAAM,CAACS,gBAAgBS,WAAWC,MAAM,GAAGF,MAAMG,KAAK,CAAC;IACvD,IAAI,CAACX,kBAAkB,CAACS,aAAaC,UAAUE,WAAW,MAAM,IAAI1B;IACpE,IAAI,CAAC2B,kBAAkBJ,WAAWH,KAAKN,kBAAkB;QACvD,MAAM,IAAId,sBAAsB;IAClC;IACA,MAAMM,UAAUsB,aAAad;IAC7B,IAAIR,QAAQI,SAAS,GAAGC,KAAKC,KAAK,CAACR,IAAIS,OAAO,KAAK,OAAO;QACxD,MAAM,IAAIb,sBAAsB;IAClC;IACA,OAAO;QAACO,aAAaD,QAAQC,WAAW;QAAEC,QAAQF,QAAQE,MAAM;IAAA;AAClE;AAEA,SAASY,KAAKN,cAAsB;IAClC,OAAOlB,WAAW,UAAUG,OAAO8B,wBAAwB,EACxDC,MAAM,CAAChB,gBACPiB,MAAM,CAAC;AACZ;AAEA,SAASJ,kBAAkBK,CAAS,EAAEC,CAAS;IAC7C,MAAMC,OAAOnB,OAAOC,IAAI,CAACgB;IACzB,MAAMG,QAAQpB,OAAOC,IAAI,CAACiB;IAC1B,OAAOC,KAAKE,MAAM,KAAKD,MAAMC,MAAM,IAAItC,gBAAgBoC,MAAMC;AAC/D;AAEA,SAASP,aAAad,cAAsB;IAC1C,IAAI;QACF,MAAMuB,SAASpB,KAAKqB,KAAK,CAACvB,OAAOC,IAAI,CAACF,gBAAgB,aAAaK,QAAQ,CAAC;QAC5E,IACE,OAAOkB,OAAO9B,WAAW,KAAK,YAC9B,OAAO8B,OAAO7B,MAAM,KAAK,YACzB,OAAO6B,OAAO5B,KAAK,KAAK,YACxB,OAAO4B,OAAO3B,SAAS,KAAK,UAC5B;YACA,MAAM,IAAI6B,MAAM;QAClB;QACA,OAAOF;IACT,EAAE,OAAM;QACN,MAAM,IAAIrC,sBAAsB;IAClC;AACF"}
|
package/dist/core/tokens.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type JiraApiClient } from '#api/client.js';
|
|
1
2
|
export interface JiraConnectionResolverResult {
|
|
2
3
|
workspaceId: string;
|
|
3
4
|
}
|
|
@@ -17,6 +18,10 @@ export interface JiraSecretsStore {
|
|
|
17
18
|
export interface CreateJiraTokenStoreParams {
|
|
18
19
|
resolveConnection(connectionId: string): Promise<JiraConnectionResolverResult | undefined>;
|
|
19
20
|
secrets: JiraSecretsStore;
|
|
21
|
+
client?: JiraApiClient | undefined;
|
|
22
|
+
markConnectionError?: (params: {
|
|
23
|
+
connectionId: string;
|
|
24
|
+
}) => Promise<void>;
|
|
20
25
|
}
|
|
21
26
|
export interface StoreJiraTokensParams {
|
|
22
27
|
connectionId: string;
|
|
@@ -26,6 +31,7 @@ export interface StoreJiraTokensParams {
|
|
|
26
31
|
}
|
|
27
32
|
export interface GetJiraAccessTokenParams {
|
|
28
33
|
connectionId: string;
|
|
34
|
+
forceRefresh?: boolean | undefined;
|
|
29
35
|
}
|
|
30
36
|
export interface JiraTokenStore {
|
|
31
37
|
storeTokens(params: StoreJiraTokensParams): Promise<void>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/core/tokens.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/core/tokens.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,aAAa,EAAC,MAAM,gBAAgB,CAAC;AAkBvE,MAAM,WAAW,4BAA4B;IAC3C,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,CAAC,MAAM,EAAE;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACjG,UAAU,CAAC,MAAM,EAAE;QACjB,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;KACtC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnB;AAED,MAAM,WAAW,0BAA0B;IACzC,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,4BAA4B,GAAG,SAAS,CAAC,CAAC;IAC3F,OAAO,EAAE,gBAAgB,CAAC;IAC1B,MAAM,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;IACnC,mBAAmB,CAAC,EAAE,CAAC,MAAM,EAAE;QAAC,YAAY,EAAE,MAAM,CAAA;KAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACzE;AAED,MAAM,WAAW,qBAAqB;IACpC,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CACtC;AAED,MAAM,WAAW,wBAAwB;IACvC,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACpC;AAED,MAAM,WAAW,cAAc;IAC7B,WAAW,CAAC,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1D,cAAc,CAAC,MAAM,EAAE,wBAAwB,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACnE;AAED,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAEjE;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,0BAA0B,GAAG,cAAc,CA6DvF"}
|
package/dist/core/tokens.js
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createJiraApiClient } from '#api/client.js';
|
|
2
|
+
import { JiraAccessTokenMissingError, JiraConnectionNotFoundError, JiraIntegrationProviderError, JiraTokenUnrefreshableError } from '#core/errors.js';
|
|
3
|
+
import { getJiraInstallationByConnectionId, updateJiraInstallationTokenExpiry, withJiraRefreshLock } from '#db/installations.js';
|
|
2
4
|
const ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';
|
|
3
5
|
const REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';
|
|
6
|
+
const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
7
|
+
const tokenRefreshes = new Map();
|
|
4
8
|
export function jiraSecretsNamespace(connectionId) {
|
|
5
9
|
return `system/integrations/jira/${connectionId}`;
|
|
6
10
|
}
|
|
7
11
|
export function createJiraTokenStore(params) {
|
|
12
|
+
const client = params.client ?? createJiraApiClient();
|
|
8
13
|
async function resolveWorkspaceId(connectionId) {
|
|
9
14
|
const connection = await params.resolveConnection(connectionId);
|
|
10
15
|
if (!connection) throw new JiraConnectionNotFoundError(connectionId);
|
|
11
16
|
return connection.workspaceId;
|
|
12
17
|
}
|
|
18
|
+
const readSecretToken = (workspaceId, connectionId, key)=>params.secrets.getSecret({
|
|
19
|
+
workspaceId,
|
|
20
|
+
namespace: jiraSecretsNamespace(connectionId),
|
|
21
|
+
key
|
|
22
|
+
});
|
|
23
|
+
async function readAccessToken(workspaceId, connectionId) {
|
|
24
|
+
const token = await readSecretToken(workspaceId, connectionId, ACCESS_TOKEN_KEY);
|
|
25
|
+
if (!token) throw new JiraAccessTokenMissingError(connectionId);
|
|
26
|
+
return token;
|
|
27
|
+
}
|
|
13
28
|
return {
|
|
14
29
|
async storeTokens (input) {
|
|
15
30
|
const workspaceId = await resolveWorkspaceId(input.connectionId);
|
|
@@ -26,15 +41,76 @@ export function createJiraTokenStore(params) {
|
|
|
26
41
|
},
|
|
27
42
|
async getAccessToken (input) {
|
|
28
43
|
const workspaceId = await resolveWorkspaceId(input.connectionId);
|
|
29
|
-
const
|
|
44
|
+
const accessToken = await readAccessToken(workspaceId, input.connectionId);
|
|
45
|
+
if (!input.forceRefresh && !shouldRefresh((await getJiraInstallationByConnectionId(input.connectionId))?.tokenExpiresAt ?? null)) return accessToken;
|
|
46
|
+
const inFlight = tokenRefreshes.get(input.connectionId);
|
|
47
|
+
if (inFlight) return inFlight;
|
|
48
|
+
const refresh = refreshAccessTokenWithLock({
|
|
49
|
+
connectionId: input.connectionId,
|
|
30
50
|
workspaceId,
|
|
31
|
-
|
|
32
|
-
|
|
51
|
+
originalAccessToken: accessToken,
|
|
52
|
+
forceRefresh: input.forceRefresh === true,
|
|
53
|
+
client,
|
|
54
|
+
secrets: params.secrets,
|
|
55
|
+
readAccessToken,
|
|
56
|
+
readSecretToken,
|
|
57
|
+
markConnectionError: params.markConnectionError
|
|
33
58
|
});
|
|
34
|
-
|
|
35
|
-
|
|
59
|
+
tokenRefreshes.set(input.connectionId, refresh);
|
|
60
|
+
void refresh.then(()=>clearTokenRefresh(input.connectionId, refresh), ()=>clearTokenRefresh(input.connectionId, refresh));
|
|
61
|
+
return refresh;
|
|
36
62
|
}
|
|
37
63
|
};
|
|
38
64
|
}
|
|
65
|
+
function shouldRefresh(expiresAt) {
|
|
66
|
+
return expiresAt !== null && expiresAt.getTime() <= Date.now() + TOKEN_REFRESH_MARGIN_MS;
|
|
67
|
+
}
|
|
68
|
+
function clearTokenRefresh(connectionId, refresh) {
|
|
69
|
+
if (tokenRefreshes.get(connectionId) === refresh) tokenRefreshes.delete(connectionId);
|
|
70
|
+
}
|
|
71
|
+
async function refreshAccessTokenWithLock(params) {
|
|
72
|
+
const lock = await withJiraRefreshLock(params.connectionId, ()=>refreshAccessTokenForConnection(params));
|
|
73
|
+
if (lock.acquired) return lock.value;
|
|
74
|
+
const reread = await params.readAccessToken(params.workspaceId, params.connectionId);
|
|
75
|
+
if (reread !== params.originalAccessToken) return reread;
|
|
76
|
+
throw new JiraIntegrationProviderError('provider-unavailable', 'Jira token refresh is already in progress');
|
|
77
|
+
}
|
|
78
|
+
async function refreshAccessTokenForConnection(params) {
|
|
79
|
+
const current = await params.readAccessToken(params.workspaceId, params.connectionId);
|
|
80
|
+
const installation = await getJiraInstallationByConnectionId(params.connectionId);
|
|
81
|
+
if (current !== params.originalAccessToken || !params.forceRefresh && !shouldRefresh(installation?.tokenExpiresAt ?? null)) return current;
|
|
82
|
+
const refreshToken = await params.readSecretToken(params.workspaceId, params.connectionId, REFRESH_TOKEN_KEY);
|
|
83
|
+
if (!refreshToken) {
|
|
84
|
+
if (params.forceRefresh || shouldRefresh(installation?.tokenExpiresAt ?? null)) throw new JiraTokenUnrefreshableError(params.connectionId);
|
|
85
|
+
return current;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const refreshed = await params.client.refreshAccessToken({
|
|
89
|
+
refreshToken
|
|
90
|
+
});
|
|
91
|
+
if (!refreshed.refreshToken) throw new JiraTokenUnrefreshableError(params.connectionId);
|
|
92
|
+
await params.secrets.setSecrets({
|
|
93
|
+
workspaceId: params.workspaceId,
|
|
94
|
+
namespace: jiraSecretsNamespace(params.connectionId),
|
|
95
|
+
values: {
|
|
96
|
+
[ACCESS_TOKEN_KEY]: refreshed.accessToken,
|
|
97
|
+
[REFRESH_TOKEN_KEY]: refreshed.refreshToken
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
await updateJiraInstallationTokenExpiry({
|
|
101
|
+
connectionId: params.connectionId,
|
|
102
|
+
tokenExpiresAt: refreshed.expiresAt ?? null,
|
|
103
|
+
scopes: refreshed.scopes.length > 0 ? refreshed.scopes : undefined
|
|
104
|
+
});
|
|
105
|
+
return refreshed.accessToken;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof JiraIntegrationProviderError && error.reason === 'access-denied') {
|
|
108
|
+
await params.markConnectionError?.({
|
|
109
|
+
connectionId: params.connectionId
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
39
115
|
|
|
40
116
|
//# sourceMappingURL=tokens.js.map
|
package/dist/core/tokens.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/core/tokens.ts"],"sourcesContent":["import {JiraAccessTokenMissingError, JiraConnectionNotFoundError} from '#core/errors.js';\n\nconst ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';\nconst REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';\n\nexport interface JiraConnectionResolverResult {\n workspaceId: string;\n}\n\nexport interface JiraSecretsStore {\n getSecret(params: {workspaceId: string; namespace: string; key: string}): Promise<string | null>;\n setSecrets(params: {\n workspaceId: string;\n namespace: string;\n values: Record<string, string>;\n editedBy?: string | null | undefined;\n }): Promise<void>;\n}\n\nexport interface CreateJiraTokenStoreParams {\n resolveConnection(connectionId: string): Promise<JiraConnectionResolverResult | undefined>;\n secrets: JiraSecretsStore;\n}\n\nexport interface StoreJiraTokensParams {\n connectionId: string;\n accessToken: string;\n refreshToken?: string | undefined;\n editedBy?: string | null | undefined;\n}\n\nexport interface GetJiraAccessTokenParams {\n connectionId: string;\n}\n\nexport interface JiraTokenStore {\n storeTokens(params: StoreJiraTokensParams): Promise<void>;\n getAccessToken(params: GetJiraAccessTokenParams): Promise<string>;\n}\n\nexport function jiraSecretsNamespace(connectionId: string): string {\n return `system/integrations/jira/${connectionId}`;\n}\n\nexport function createJiraTokenStore(params: CreateJiraTokenStoreParams): JiraTokenStore {\n async function resolveWorkspaceId(connectionId: string): Promise<string> {\n const connection = await params.resolveConnection(connectionId);\n if (!connection) throw new JiraConnectionNotFoundError(connectionId);\n return connection.workspaceId;\n }\n\n return {\n async storeTokens(input) {\n const workspaceId = await resolveWorkspaceId(input.connectionId);\n const values: Record<string, string> = {[ACCESS_TOKEN_KEY]: input.accessToken};\n if (input.refreshToken) values[REFRESH_TOKEN_KEY] = input.refreshToken;\n\n await params.secrets.setSecrets({\n workspaceId,\n namespace: jiraSecretsNamespace(input.connectionId),\n values,\n editedBy: input.editedBy,\n });\n },\n\n async getAccessToken(input) {\n const workspaceId = await resolveWorkspaceId(input.connectionId);\n const token = await params.secrets.getSecret({\n workspaceId,\n namespace: jiraSecretsNamespace(input.connectionId),\n key: ACCESS_TOKEN_KEY,\n });\n if (!token) throw new JiraAccessTokenMissingError(input.connectionId);\n return token;\n },\n };\n}\n"],"names":["JiraAccessTokenMissingError","JiraConnectionNotFoundError","ACCESS_TOKEN_KEY","REFRESH_TOKEN_KEY","jiraSecretsNamespace","connectionId","createJiraTokenStore","params","resolveWorkspaceId","connection","resolveConnection","workspaceId","storeTokens","input","values","accessToken","refreshToken","secrets","setSecrets","namespace","editedBy","getAccessToken","token","getSecret","key"],"mappings":"AAAA,SAAQA,2BAA2B,EAAEC,2BAA2B,QAAO,kBAAkB;AAEzF,MAAMC,mBAAmB;AACzB,MAAMC,oBAAoB;AAqC1B,OAAO,SAASC,qBAAqBC,YAAoB;IACvD,OAAO,CAAC,yBAAyB,EAAEA,cAAc;AACnD;AAEA,OAAO,SAASC,qBAAqBC,MAAkC;IACrE,eAAeC,mBAAmBH,YAAoB;QACpD,MAAMI,aAAa,MAAMF,OAAOG,iBAAiB,CAACL;QAClD,IAAI,CAACI,YAAY,MAAM,IAAIR,4BAA4BI;QACvD,OAAOI,WAAWE,WAAW;IAC/B;IAEA,OAAO;QACL,MAAMC,aAAYC,KAAK;YACrB,MAAMF,cAAc,MAAMH,mBAAmBK,MAAMR,YAAY;YAC/D,MAAMS,SAAiC;gBAAC,CAACZ,iBAAiB,EAAEW,MAAME,WAAW;YAAA;YAC7E,IAAIF,MAAMG,YAAY,EAAEF,MAAM,CAACX,kBAAkB,GAAGU,MAAMG,YAAY;YAEtE,MAAMT,OAAOU,OAAO,CAACC,UAAU,CAAC;gBAC9BP;gBACAQ,WAAWf,qBAAqBS,MAAMR,YAAY;gBAClDS;gBACAM,UAAUP,MAAMO,QAAQ;YAC1B;QACF;QAEA,MAAMC,gBAAeR,KAAK;YACxB,MAAMF,cAAc,MAAMH,mBAAmBK,MAAMR,YAAY;YAC/D,MAAMiB,QAAQ,MAAMf,OAAOU,OAAO,CAACM,SAAS,CAAC;gBAC3CZ;gBACAQ,WAAWf,qBAAqBS,MAAMR,YAAY;gBAClDmB,KAAKtB;YACP;YACA,IAAI,CAACoB,OAAO,MAAM,IAAItB,4BAA4Ba,MAAMR,YAAY;YACpE,OAAOiB;QACT;IACF;AACF"}
|
|
1
|
+
{"version":3,"sources":["../../src/core/tokens.ts"],"sourcesContent":["import {createJiraApiClient, type JiraApiClient} from '#api/client.js';\nimport {\n JiraAccessTokenMissingError,\n JiraConnectionNotFoundError,\n JiraIntegrationProviderError,\n JiraTokenUnrefreshableError,\n} from '#core/errors.js';\nimport {\n getJiraInstallationByConnectionId,\n updateJiraInstallationTokenExpiry,\n withJiraRefreshLock,\n} from '#db/installations.js';\n\nconst ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';\nconst REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';\nconst TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;\nconst tokenRefreshes = new Map<string, Promise<string>>();\n\nexport interface JiraConnectionResolverResult {\n workspaceId: string;\n}\n\nexport interface JiraSecretsStore {\n getSecret(params: {workspaceId: string; namespace: string; key: string}): Promise<string | null>;\n setSecrets(params: {\n workspaceId: string;\n namespace: string;\n values: Record<string, string>;\n editedBy?: string | null | undefined;\n }): Promise<void>;\n}\n\nexport interface CreateJiraTokenStoreParams {\n resolveConnection(connectionId: string): Promise<JiraConnectionResolverResult | undefined>;\n secrets: JiraSecretsStore;\n client?: JiraApiClient | undefined;\n markConnectionError?: (params: {connectionId: string}) => Promise<void>;\n}\n\nexport interface StoreJiraTokensParams {\n connectionId: string;\n accessToken: string;\n refreshToken?: string | undefined;\n editedBy?: string | null | undefined;\n}\n\nexport interface GetJiraAccessTokenParams {\n connectionId: string;\n forceRefresh?: boolean | undefined;\n}\n\nexport interface JiraTokenStore {\n storeTokens(params: StoreJiraTokensParams): Promise<void>;\n getAccessToken(params: GetJiraAccessTokenParams): Promise<string>;\n}\n\nexport function jiraSecretsNamespace(connectionId: string): string {\n return `system/integrations/jira/${connectionId}`;\n}\n\nexport function createJiraTokenStore(params: CreateJiraTokenStoreParams): JiraTokenStore {\n const client = params.client ?? createJiraApiClient();\n async function resolveWorkspaceId(connectionId: string): Promise<string> {\n const connection = await params.resolveConnection(connectionId);\n if (!connection) throw new JiraConnectionNotFoundError(connectionId);\n return connection.workspaceId;\n }\n const readSecretToken = (\n workspaceId: string,\n connectionId: string,\n key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,\n ) => params.secrets.getSecret({workspaceId, namespace: jiraSecretsNamespace(connectionId), key});\n async function readAccessToken(workspaceId: string, connectionId: string): Promise<string> {\n const token = await readSecretToken(workspaceId, connectionId, ACCESS_TOKEN_KEY);\n if (!token) throw new JiraAccessTokenMissingError(connectionId);\n return token;\n }\n\n return {\n async storeTokens(input) {\n const workspaceId = await resolveWorkspaceId(input.connectionId);\n const values: Record<string, string> = {[ACCESS_TOKEN_KEY]: input.accessToken};\n if (input.refreshToken) values[REFRESH_TOKEN_KEY] = input.refreshToken;\n await params.secrets.setSecrets({\n workspaceId,\n namespace: jiraSecretsNamespace(input.connectionId),\n values,\n editedBy: input.editedBy,\n });\n },\n async getAccessToken(input) {\n const workspaceId = await resolveWorkspaceId(input.connectionId);\n const accessToken = await readAccessToken(workspaceId, input.connectionId);\n if (\n !input.forceRefresh &&\n !shouldRefresh(\n (await getJiraInstallationByConnectionId(input.connectionId))?.tokenExpiresAt ?? null,\n )\n )\n return accessToken;\n const inFlight = tokenRefreshes.get(input.connectionId);\n if (inFlight) return inFlight;\n const refresh = refreshAccessTokenWithLock({\n connectionId: input.connectionId,\n workspaceId,\n originalAccessToken: accessToken,\n forceRefresh: input.forceRefresh === true,\n client,\n secrets: params.secrets,\n readAccessToken,\n readSecretToken,\n markConnectionError: params.markConnectionError,\n });\n tokenRefreshes.set(input.connectionId, refresh);\n void refresh.then(\n () => clearTokenRefresh(input.connectionId, refresh),\n () => clearTokenRefresh(input.connectionId, refresh),\n );\n return refresh;\n },\n };\n}\n\nfunction shouldRefresh(expiresAt: Date | null): boolean {\n return expiresAt !== null && expiresAt.getTime() <= Date.now() + TOKEN_REFRESH_MARGIN_MS;\n}\n\nfunction clearTokenRefresh(connectionId: string, refresh: Promise<string>): void {\n if (tokenRefreshes.get(connectionId) === refresh) tokenRefreshes.delete(connectionId);\n}\n\nasync function refreshAccessTokenWithLock(params: {\n connectionId: string;\n workspaceId: string;\n originalAccessToken: string;\n forceRefresh: boolean;\n client: JiraApiClient;\n secrets: JiraSecretsStore;\n readAccessToken(workspaceId: string, connectionId: string): Promise<string>;\n readSecretToken(\n workspaceId: string,\n connectionId: string,\n key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,\n ): Promise<string | null>;\n markConnectionError?: ((params: {connectionId: string}) => Promise<void>) | undefined;\n}): Promise<string> {\n const lock = await withJiraRefreshLock(params.connectionId, () =>\n refreshAccessTokenForConnection(params),\n );\n if (lock.acquired) return lock.value;\n const reread = await params.readAccessToken(params.workspaceId, params.connectionId);\n if (reread !== params.originalAccessToken) return reread;\n throw new JiraIntegrationProviderError(\n 'provider-unavailable',\n 'Jira token refresh is already in progress',\n );\n}\n\nasync function refreshAccessTokenForConnection(params: {\n connectionId: string;\n workspaceId: string;\n originalAccessToken: string;\n forceRefresh: boolean;\n client: JiraApiClient;\n secrets: JiraSecretsStore;\n readAccessToken(workspaceId: string, connectionId: string): Promise<string>;\n readSecretToken(\n workspaceId: string,\n connectionId: string,\n key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,\n ): Promise<string | null>;\n markConnectionError?: ((params: {connectionId: string}) => Promise<void>) | undefined;\n}): Promise<string> {\n const current = await params.readAccessToken(params.workspaceId, params.connectionId);\n const installation = await getJiraInstallationByConnectionId(params.connectionId);\n if (\n current !== params.originalAccessToken ||\n (!params.forceRefresh && !shouldRefresh(installation?.tokenExpiresAt ?? null))\n )\n return current;\n const refreshToken = await params.readSecretToken(\n params.workspaceId,\n params.connectionId,\n REFRESH_TOKEN_KEY,\n );\n if (!refreshToken) {\n if (params.forceRefresh || shouldRefresh(installation?.tokenExpiresAt ?? null))\n throw new JiraTokenUnrefreshableError(params.connectionId);\n return current;\n }\n try {\n const refreshed = await params.client.refreshAccessToken({refreshToken});\n if (!refreshed.refreshToken) throw new JiraTokenUnrefreshableError(params.connectionId);\n await params.secrets.setSecrets({\n workspaceId: params.workspaceId,\n namespace: jiraSecretsNamespace(params.connectionId),\n values: {\n [ACCESS_TOKEN_KEY]: refreshed.accessToken,\n [REFRESH_TOKEN_KEY]: refreshed.refreshToken,\n },\n });\n await updateJiraInstallationTokenExpiry({\n connectionId: params.connectionId,\n tokenExpiresAt: refreshed.expiresAt ?? null,\n scopes: refreshed.scopes.length > 0 ? refreshed.scopes : undefined,\n });\n return refreshed.accessToken;\n } catch (error) {\n if (error instanceof JiraIntegrationProviderError && error.reason === 'access-denied') {\n await params.markConnectionError?.({connectionId: params.connectionId});\n }\n throw error;\n }\n}\n"],"names":["createJiraApiClient","JiraAccessTokenMissingError","JiraConnectionNotFoundError","JiraIntegrationProviderError","JiraTokenUnrefreshableError","getJiraInstallationByConnectionId","updateJiraInstallationTokenExpiry","withJiraRefreshLock","ACCESS_TOKEN_KEY","REFRESH_TOKEN_KEY","TOKEN_REFRESH_MARGIN_MS","tokenRefreshes","Map","jiraSecretsNamespace","connectionId","createJiraTokenStore","params","client","resolveWorkspaceId","connection","resolveConnection","workspaceId","readSecretToken","key","secrets","getSecret","namespace","readAccessToken","token","storeTokens","input","values","accessToken","refreshToken","setSecrets","editedBy","getAccessToken","forceRefresh","shouldRefresh","tokenExpiresAt","inFlight","get","refresh","refreshAccessTokenWithLock","originalAccessToken","markConnectionError","set","then","clearTokenRefresh","expiresAt","getTime","Date","now","delete","lock","refreshAccessTokenForConnection","acquired","value","reread","current","installation","refreshed","refreshAccessToken","scopes","length","undefined","error","reason"],"mappings":"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"}
|