@falldownthesystem/devtool 0.5.0 → 0.6.1
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/README.md +167 -9
- package/package.json +7 -3
- package/src/clients/bitbucket.js +22 -26
- package/src/clients/confluence.js +170 -171
- package/src/clients/graph-auth.js +110 -0
- package/src/clients/graph-cache.js +148 -0
- package/src/clients/graph.js +263 -0
- package/src/clients/jira.js +20 -20
- package/src/clients/serviceDesk.js +33 -37
- package/src/commands/config.js +71 -27
- package/src/commands/confluence.js +8 -7
- package/src/commands/graph.js +147 -0
- package/src/commands/jira.js +6 -5
- package/src/commands/pr.js +6 -22
- package/src/commands/sd.js +8 -7
- package/src/config/setup.js +113 -0
- package/src/config/store.js +207 -171
- package/src/formatters/graph.js +220 -0
- package/src/index.js +9 -20
- package/src/utils/connection.js +5 -0
|
@@ -1,171 +1,170 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
1
|
+
const deployments = new Map();
|
|
2
|
+
|
|
3
|
+
// Custom domains hide the atlassian.net hostname, so anything else is probed:
|
|
4
|
+
// Atlassian's edge answers tenant_info anonymously, Server/Data Center does not.
|
|
5
|
+
async function detectDeployment(origin) {
|
|
6
|
+
if (/\.atlassian\.net$/i.test(new URL(origin).hostname)) return 'cloud';
|
|
7
|
+
try {
|
|
8
|
+
const res = await fetch(new URL('/_edge/tenant_info', origin), {
|
|
9
|
+
headers: { 'Accept': 'application/json' },
|
|
10
|
+
});
|
|
11
|
+
if (res.ok && (await res.json()).cloudId) return 'cloud';
|
|
12
|
+
} catch {
|
|
13
|
+
// An unreachable or non-JSON probe means no Atlassian edge; the real request reports connection errors.
|
|
14
|
+
}
|
|
15
|
+
return 'server';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function connect({ baseUrl, email, token }) {
|
|
19
|
+
const base = new URL(baseUrl);
|
|
20
|
+
if (!deployments.has(base.origin)) deployments.set(base.origin, detectDeployment(base.origin));
|
|
21
|
+
const kind = await deployments.get(base.origin);
|
|
22
|
+
// Cloud always serves Confluence under /wiki; Server/Data Center keeps its own context path.
|
|
23
|
+
const path = kind === 'cloud' ? '/wiki' : base.pathname.replace(/\/+$/, '');
|
|
24
|
+
return { kind, origin: base.origin, path, email, token };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function apiUrl(conn, path) {
|
|
28
|
+
return new URL(`${conn.origin}${conn.path}${path}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Cloud v2 links are origin-relative, Server/Data Center links are relative to the context path.
|
|
32
|
+
function nextUrl(conn, link, current) {
|
|
33
|
+
const contextRelative = conn.kind === 'server'
|
|
34
|
+
&& link.startsWith('/') && !link.startsWith('//')
|
|
35
|
+
&& !(conn.path && link.startsWith(`${conn.path}/`));
|
|
36
|
+
return contextRelative ? apiUrl(conn, link) : new URL(link, current);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Server/Data Center personal access tokens use Bearer auth; a username switches to Basic.
|
|
40
|
+
function authHeader({ kind, email = '', token = '' }) {
|
|
41
|
+
if (kind === 'server' && !email) return `Bearer ${token}`;
|
|
42
|
+
return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function request(conn, url, params = {}) {
|
|
46
|
+
if (url.origin !== conn.origin) {
|
|
47
|
+
throw new Error('Confluence pagination points to a different site. Check the base URL of the Confluence connection.');
|
|
48
|
+
}
|
|
49
|
+
for (const [k, v] of Object.entries(params)) {
|
|
50
|
+
if (v !== undefined && v !== null) url.searchParams.set(k, v);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const res = await fetch(url, {
|
|
54
|
+
headers: {
|
|
55
|
+
'Authorization': authHeader(conn),
|
|
56
|
+
'Accept': 'application/json',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
const err = new Error(`Confluence API error: ${res.status} ${res.statusText}`);
|
|
62
|
+
err.status = res.status;
|
|
63
|
+
err.url = url.toString();
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Server/Data Center answers rejected credentials with a login page instead of a 401.
|
|
68
|
+
if (!res.headers.get('content-type')?.includes('json')) {
|
|
69
|
+
const err = new Error('Confluence returned a non-JSON response. Check the base URL and token of the Confluence connection.');
|
|
70
|
+
err.url = url.toString();
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return res.json();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function paginate(conn, path, params = {}) {
|
|
78
|
+
const results = [];
|
|
79
|
+
let url = apiUrl(conn, path);
|
|
80
|
+
let data = await request(conn, url, params);
|
|
81
|
+
results.push(...(data.results || []));
|
|
82
|
+
|
|
83
|
+
while (data._links?.next) {
|
|
84
|
+
url = nextUrl(conn, data._links.next, url);
|
|
85
|
+
data = await request(conn, url);
|
|
86
|
+
results.push(...(data.results || []));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return results;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Server/Data Center v1 content is reshaped to the Cloud v2 fields the formatters read.
|
|
93
|
+
function serverSpace(space) {
|
|
94
|
+
return {
|
|
95
|
+
id: String(space.id),
|
|
96
|
+
key: space.key,
|
|
97
|
+
name: space.name,
|
|
98
|
+
type: space.type,
|
|
99
|
+
status: space.status,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function serverPage(page) {
|
|
104
|
+
return {
|
|
105
|
+
id: page.id,
|
|
106
|
+
title: page.title,
|
|
107
|
+
status: page.status,
|
|
108
|
+
spaceId: page.space?.id != null ? String(page.space.id) : undefined,
|
|
109
|
+
createdAt: page.history?.createdDate,
|
|
110
|
+
version: page.version ? { number: page.version.number } : undefined,
|
|
111
|
+
body: page.body,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The content listing silently returns nothing for an unknown space key, so the space is looked up first.
|
|
116
|
+
async function serverSpaceKey(conn, { spaceId, spaceKey }) {
|
|
117
|
+
if (!spaceKey) return (await spaces(conn)).find((s) => s.id === String(spaceId))?.key;
|
|
118
|
+
try {
|
|
119
|
+
return (await request(conn, apiUrl(conn, `/rest/api/space/${encodeURIComponent(spaceKey)}`))).key;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (err.status === 404) return undefined;
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function spaces(conn) {
|
|
127
|
+
if (conn.kind === 'cloud') return paginate(conn, '/api/v2/spaces');
|
|
128
|
+
return (await paginate(conn, '/rest/api/space', { limit: 100 })).map(serverSpace);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function listSpaces(connection) {
|
|
132
|
+
return spaces(await connect(connection));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function getPage(connection, id) {
|
|
136
|
+
const conn = await connect(connection);
|
|
137
|
+
if (conn.kind === 'cloud') {
|
|
138
|
+
return request(conn, apiUrl(conn, `/api/v2/pages/${id}`), { 'body-format': 'storage' });
|
|
139
|
+
}
|
|
140
|
+
return serverPage(await request(conn, apiUrl(conn, `/rest/api/content/${id}`), {
|
|
141
|
+
expand: 'body.storage,version,space,history',
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Cloud lists pages by space ID and Server/Data Center by space key; returns null for an unknown space.
|
|
146
|
+
export async function listSpacePages(connection, { spaceId, spaceKey }) {
|
|
147
|
+
const conn = await connect(connection);
|
|
148
|
+
if (conn.kind === 'cloud') {
|
|
149
|
+
const id = spaceId
|
|
150
|
+
?? (await request(conn, apiUrl(conn, '/api/v2/spaces'), { keys: spaceKey })).results?.[0]?.id;
|
|
151
|
+
if (!id) return null;
|
|
152
|
+
return paginate(conn, `/api/v2/spaces/${id}/pages`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const key = await serverSpaceKey(conn, { spaceId, spaceKey });
|
|
156
|
+
if (!key) return null;
|
|
157
|
+
const pages = await paginate(conn, '/rest/api/content', {
|
|
158
|
+
spaceKey: key,
|
|
159
|
+
type: 'page',
|
|
160
|
+
expand: 'version,history',
|
|
161
|
+
limit: 100,
|
|
162
|
+
});
|
|
163
|
+
return pages.map(serverPage);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function searchPages(connection, cql) {
|
|
167
|
+
const conn = await connect(connection);
|
|
168
|
+
const result = await request(conn, apiUrl(conn, '/rest/api/search'), { cql });
|
|
169
|
+
return { ...result, totalSize: result.totalSize ?? result.totalCount };
|
|
170
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
4
|
+
import { configPath, validateGraphId } from '../config/store.js';
|
|
5
|
+
import { createProtectedCache } from './graph-cache.js';
|
|
6
|
+
|
|
7
|
+
export const GRAPH_SCOPES = ['User.Read', 'Chat.Read', 'Mail.Read', 'Sites.Read.All'];
|
|
8
|
+
export const SHARED_MAIL_SCOPE = 'Mail.Read.Shared';
|
|
9
|
+
export const CALENDAR_SCOPE = 'Calendars.Read.Shared';
|
|
10
|
+
export const AVAILABILITY_SCOPE = 'Calendars.ReadBasic';
|
|
11
|
+
|
|
12
|
+
function requestedScopes({ sharedMail = false, calendars = false, availability = false, all = false } = {}) {
|
|
13
|
+
return [...GRAPH_SCOPES,
|
|
14
|
+
...(sharedMail || all ? [SHARED_MAIL_SCOPE] : []),
|
|
15
|
+
...(calendars || all ? [CALENDAR_SCOPE, AVAILABILITY_SCOPE] : availability ? [AVAILABILITY_SCOPE] : []),
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function graphIdentity(connection = {}) {
|
|
20
|
+
const tenantId = connection.tenantId?.trim().toLowerCase();
|
|
21
|
+
const clientId = connection.clientId?.trim().toLowerCase();
|
|
22
|
+
if (validateGraphId(tenantId) || validateGraphId(clientId)) {
|
|
23
|
+
throw new Error('The Microsoft Graph connection needs a valid tenant ID and client ID. Run: dev graph setup');
|
|
24
|
+
}
|
|
25
|
+
return { tenantId, clientId };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The token cache is partitioned by tenant and client ID, so connections for the same application share one sign-in.
|
|
29
|
+
async function createApplication(connection) {
|
|
30
|
+
const { tenantId, clientId } = graphIdentity(connection);
|
|
31
|
+
const { PublicClientApplication } = await import('@azure/msal-node');
|
|
32
|
+
const identity = createHash('sha256').update(`${tenantId}:${clientId}`).digest('hex');
|
|
33
|
+
const directory = join(dirname(configPath()), 'graph-auth');
|
|
34
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
35
|
+
// Caches written by the earlier msal-node-extensions storage cannot be read by this one.
|
|
36
|
+
await rm(join(directory, `${identity}.bin`), { force: true });
|
|
37
|
+
await rm(join(directory, `${identity}.bin.lockfile`), { force: true });
|
|
38
|
+
return new PublicClientApplication({
|
|
39
|
+
auth: { clientId, authority: `https://login.microsoftonline.com/${tenantId}` },
|
|
40
|
+
cache: { cachePlugin: createProtectedCache({ path: join(directory, `${identity}.cache`), identity }) },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createGraphAuth({ connection, applicationFactory = () => createApplication(connection), browserOpener } = {}) {
|
|
45
|
+
let application;
|
|
46
|
+
const app = () => application ||= applicationFactory();
|
|
47
|
+
|
|
48
|
+
async function account() {
|
|
49
|
+
const accounts = await (await app()).getTokenCache().getAllAccounts();
|
|
50
|
+
if (accounts.length !== 1) throw new Error('Sign in to Microsoft Graph first. Run: dev graph login');
|
|
51
|
+
return accounts[0];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
async login(options = {}) {
|
|
56
|
+
const client = await app();
|
|
57
|
+
const openBrowser = browserOpener || (async (url) => {
|
|
58
|
+
const { default: open } = await import('open');
|
|
59
|
+
await open(url);
|
|
60
|
+
});
|
|
61
|
+
const scopes = requestedScopes(options);
|
|
62
|
+
const result = await client.acquireTokenInteractive({
|
|
63
|
+
scopes,
|
|
64
|
+
prompt: 'select_account',
|
|
65
|
+
openBrowser,
|
|
66
|
+
successTemplate: 'Return to the terminal to check the sign-in result. You can close this window.',
|
|
67
|
+
errorTemplate: 'Sign-in failed. Return to the terminal for details.',
|
|
68
|
+
});
|
|
69
|
+
if (!result?.account) throw new Error('Microsoft did not return an account. Run: dev graph login');
|
|
70
|
+
const cache = client.getTokenCache();
|
|
71
|
+
// A cache belongs to one CLI identity so silent requests never pick an arbitrary account.
|
|
72
|
+
for (const cached of await cache.getAllAccounts()) {
|
|
73
|
+
if (cached.homeAccountId !== result.account.homeAccountId) await cache.removeAccount(cached);
|
|
74
|
+
}
|
|
75
|
+
return { username: result.account.username, name: result.account.name || null, tenantId: result.account.tenantId };
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async token({ sharedMail = false, calendars = false, availability = false } = {}) {
|
|
79
|
+
const client = await app();
|
|
80
|
+
const current = await account();
|
|
81
|
+
const scopes = requestedScopes({ sharedMail, calendars, availability });
|
|
82
|
+
try {
|
|
83
|
+
const result = await client.acquireTokenSilent({
|
|
84
|
+
account: current,
|
|
85
|
+
scopes,
|
|
86
|
+
});
|
|
87
|
+
if (!result?.accessToken) throw new Error('No access token returned.');
|
|
88
|
+
return result.accessToken;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (['interaction_required', 'consent_required', 'login_required', 'invalid_grant', 'no_tokens_found'].includes(err.errorCode)
|
|
91
|
+
|| err.name === 'InteractionRequiredAuthError') {
|
|
92
|
+
const flags = `${sharedMail ? ' --shared-mail' : ''}${calendars || availability ? ' --calendars' : ''}`;
|
|
93
|
+
throw new Error(`Microsoft sign-in expired or needs consent for ${scopes.join(', ')}. Check delegated permissions, then run: dev graph login${flags}`);
|
|
94
|
+
}
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async status() {
|
|
100
|
+
const current = await account();
|
|
101
|
+
return { username: current.username, name: current.name || null, tenantId: current.tenantId, cached: true };
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async logout() {
|
|
105
|
+
const cache = (await app()).getTokenCache();
|
|
106
|
+
for (const cached of await cache.getAllAccounts()) await cache.removeAccount(cached);
|
|
107
|
+
return { signedOut: true, message: 'Local Graph account removed. Browser sessions and server-side grants are unchanged.' };
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
2
|
+
import { open, readFile, rename, rm, stat } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
const FORMAT = 1;
|
|
5
|
+
const KEY_BYTES = 32;
|
|
6
|
+
const IV_BYTES = 12;
|
|
7
|
+
const TAG_BYTES = 16;
|
|
8
|
+
const HEADER_BYTES = 1 + IV_BYTES + TAG_BYTES;
|
|
9
|
+
|
|
10
|
+
function storeError(err) {
|
|
11
|
+
return new Error('Cannot open the OS credential store for the Graph token cache. Check your Windows account, macOS Keychain, or Linux Secret Service. Plaintext storage is disabled.', { cause: err });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// The OS store holds only the key: Windows Credential Manager limits a secret to about 2.5 KB, less than an MSAL cache.
|
|
15
|
+
export async function keyringKeyStore(identity) {
|
|
16
|
+
const { AsyncEntry } = await import('@napi-rs/keyring');
|
|
17
|
+
// Linux would otherwise fall back to the kernel keyring, which loses the key at logout or reboot.
|
|
18
|
+
const entry = new AsyncEntry('devtool-graph-key', identity, { linux: { store: 'secret-service' } });
|
|
19
|
+
return {
|
|
20
|
+
async get() {
|
|
21
|
+
try {
|
|
22
|
+
const secret = await entry.getSecret();
|
|
23
|
+
return secret ? Buffer.from(secret) : null;
|
|
24
|
+
} catch (err) {
|
|
25
|
+
throw storeError(err);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
async set(key) {
|
|
29
|
+
try {
|
|
30
|
+
await entry.setSecret(key);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
throw storeError(err);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fileLock(path, { timeout = 10000, stale = 30000, retry = 50 } = {}) {
|
|
39
|
+
let held = false;
|
|
40
|
+
return {
|
|
41
|
+
async acquire() {
|
|
42
|
+
const deadline = Date.now() + timeout;
|
|
43
|
+
for (;;) {
|
|
44
|
+
try {
|
|
45
|
+
await (await open(path, 'wx')).close();
|
|
46
|
+
held = true;
|
|
47
|
+
return;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (err.code !== 'EEXIST') throw err;
|
|
50
|
+
}
|
|
51
|
+
// A process killed while holding the lock leaves the file behind.
|
|
52
|
+
const age = await stat(path).then((s) => Date.now() - s.mtimeMs, () => 0);
|
|
53
|
+
if (age > stale) await rm(path, { force: true });
|
|
54
|
+
else if (Date.now() > deadline) throw new Error('Timed out waiting for another devtool process to release the Graph token cache.');
|
|
55
|
+
else await new Promise((resolve) => setTimeout(resolve, retry));
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
async release() {
|
|
59
|
+
if (!held) return;
|
|
60
|
+
held = false;
|
|
61
|
+
await rm(path, { force: true });
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* MSAL cache plugin that stores the token cache AES-256-GCM encrypted in a file, keyed by a random
|
|
68
|
+
* key in the OS credential store. The identity is bound as associated data so files cannot be swapped.
|
|
69
|
+
*/
|
|
70
|
+
export function createProtectedCache({ path, identity, keyStore = () => keyringKeyStore(identity), lockOptions }) {
|
|
71
|
+
const lock = fileLock(`${path}.lock`, lockOptions);
|
|
72
|
+
const aad = Buffer.from(identity);
|
|
73
|
+
let store;
|
|
74
|
+
const keys = async () => (store ||= await keyStore());
|
|
75
|
+
|
|
76
|
+
async function readKey() {
|
|
77
|
+
const key = await (await keys()).get();
|
|
78
|
+
return key?.length === KEY_BYTES ? key : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A missing key or a file that fails authentication is an empty cache: the user signs in again.
|
|
82
|
+
async function load() {
|
|
83
|
+
let file;
|
|
84
|
+
try {
|
|
85
|
+
file = await readFile(path);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (err.code === 'ENOENT') return null;
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
const key = await readKey();
|
|
91
|
+
if (!key || file.length < HEADER_BYTES || file[0] !== FORMAT) return null;
|
|
92
|
+
try {
|
|
93
|
+
const decipher = createDecipheriv('aes-256-gcm', key, file.subarray(1, 1 + IV_BYTES));
|
|
94
|
+
decipher.setAAD(aad);
|
|
95
|
+
decipher.setAuthTag(file.subarray(1 + IV_BYTES, HEADER_BYTES));
|
|
96
|
+
return Buffer.concat([decipher.update(file.subarray(HEADER_BYTES)), decipher.final()]).toString('utf8');
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function save(data) {
|
|
103
|
+
let key = await readKey();
|
|
104
|
+
if (!key) {
|
|
105
|
+
key = randomBytes(KEY_BYTES);
|
|
106
|
+
await (await keys()).set(key);
|
|
107
|
+
}
|
|
108
|
+
const iv = randomBytes(IV_BYTES);
|
|
109
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
110
|
+
cipher.setAAD(aad);
|
|
111
|
+
const body = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]);
|
|
112
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
113
|
+
try {
|
|
114
|
+
const file = await open(temporary, 'w', 0o600);
|
|
115
|
+
try {
|
|
116
|
+
await file.writeFile(Buffer.concat([Buffer.from([FORMAT]), iv, cipher.getAuthTag(), body]));
|
|
117
|
+
} finally {
|
|
118
|
+
await file.close();
|
|
119
|
+
}
|
|
120
|
+
await rename(temporary, path);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
await rm(temporary, { force: true });
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
// MSAL pairs every beforeCacheAccess with afterCacheAccess; the lock spans both when the cache will change.
|
|
129
|
+
async beforeCacheAccess(context) {
|
|
130
|
+
await lock.acquire();
|
|
131
|
+
try {
|
|
132
|
+
const data = await load();
|
|
133
|
+
if (data) context.tokenCache.deserialize(data);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
await lock.release();
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
if (!context.cacheHasChanged) await lock.release();
|
|
139
|
+
},
|
|
140
|
+
async afterCacheAccess(context) {
|
|
141
|
+
try {
|
|
142
|
+
if (context.cacheHasChanged) await save(context.tokenCache.serialize());
|
|
143
|
+
} finally {
|
|
144
|
+
await lock.release();
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|