@cspeach/cli 1.1.33 → 1.1.35
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/dist/agent/anthropic-provider.js +2 -0
- package/dist/agent/azure-profile.js +7 -0
- package/dist/agent/connection-profile.js +49 -0
- package/dist/agent/loop.js +182 -39
- package/dist/agent/provider-shape.js +36 -0
- package/dist/agent/providers/ai-hub-provider.js +6 -5
- package/dist/agent/providers/azure-provider.js +125 -0
- package/dist/agent/providers/byok-provider.js +8 -7
- package/dist/agent/providers/factory.js +8 -3
- package/dist/agent/providers/local-provider.js +18 -22
- package/dist/agent/risk-gate.js +202 -0
- package/dist/agent/summarise-via-provider.js +55 -8
- package/dist/agent/tool-dispatch.js +19 -0
- package/dist/agent/turn-error-ux.js +22 -0
- package/dist/auth/api-key.js +10 -5
- package/dist/auth/auth-file.js +35 -6
- package/dist/auth/azure/azure-key.js +21 -0
- package/dist/auth/azure/azure-log.js +45 -0
- package/dist/auth/azure/cache-store.js +171 -0
- package/dist/auth/azure/clear.js +28 -0
- package/dist/auth/azure/loopback.js +192 -0
- package/dist/auth/azure/msal-client.js +41 -0
- package/dist/auth/azure/sign-in.js +219 -0
- package/dist/auth/azure/startup-sign-in.js +64 -0
- package/dist/auth/azure/token-provider.js +116 -0
- package/dist/auth/key-resolver.js +258 -0
- package/dist/auth/me.js +11 -5
- package/dist/cli.js +21 -2
- package/dist/commands/compact.js +12 -1
- package/dist/commands/config-set.js +74 -3
- package/dist/commands/config-show.js +25 -0
- package/dist/commands/cost.js +19 -6
- package/dist/commands/help.js +2 -2
- package/dist/commands/login-azure.js +33 -0
- package/dist/commands/login.js +56 -8
- package/dist/commands/logout.js +34 -10
- package/dist/commands/plan-boundary.js +4 -4
- package/dist/commands/plan-model-tier.js +8 -3
- package/dist/commands/usage.js +6 -0
- package/dist/commands/whoami.js +113 -9
- package/dist/config/loader.js +5 -0
- package/dist/connection/azure-auth.js +135 -0
- package/dist/connection/azure-errors.js +103 -0
- package/dist/connection/azure-facts.js +21 -0
- package/dist/connection/azure-gate.js +40 -0
- package/dist/connection/azure-last-call.js +5 -0
- package/dist/connection/azure-turn-error.js +25 -0
- package/dist/connection/cache.js +70 -0
- package/dist/connection/fetch.js +33 -0
- package/dist/connection/guard.js +94 -0
- package/dist/connection/limit-gate.js +26 -0
- package/dist/connection/local-usage.js +71 -0
- package/dist/connection/login-step.js +62 -0
- package/dist/connection/startup.js +67 -0
- package/dist/connection/state.js +68 -0
- package/dist/connection/types.js +1 -0
- package/dist/cost/cost-log.js +3 -1
- package/dist/cost/credits-wire.js +1 -1
- package/dist/cost/credits.js +4 -0
- package/dist/cost/display-mode.js +7 -0
- package/dist/cost/router-spend.js +3 -0
- package/dist/cost/session-cost.js +4 -0
- package/dist/doctor/checks/azure.js +100 -0
- package/dist/doctor/checks/credits.js +4 -3
- package/dist/doctor/checks/usage-reporting.js +22 -0
- package/dist/doctor/run.js +7 -0
- package/dist/lib/graceful-exit.js +17 -0
- package/dist/licence/licence-check.js +205 -0
- package/dist/licence/paths.js +6 -0
- package/dist/licence/turn-gate.js +66 -0
- package/dist/models/model-identity.js +65 -0
- package/dist/models/resolve.js +8 -0
- package/dist/models/server-config.js +3 -2
- package/dist/one-shot.js +86 -2
- package/dist/projects/answer-blockers.js +25 -12
- package/dist/renderer/change-card.js +28 -4
- package/dist/renderer/footer-line.js +7 -1
- package/dist/renderer/notices.js +27 -13
- package/dist/renderer/progress-chatter.js +3 -1
- package/dist/renderer/status-footer.js +13 -6
- package/dist/renderer/tool-labels.js +8 -2
- package/dist/renderer/verify-chain.js +41 -18
- package/dist/renderer/wrap-rows.js +87 -0
- package/dist/repl/ask-or-no.js +18 -0
- package/dist/repl/builtin-commands.js +10 -2
- package/dist/repl/post-turn-status.js +3 -2
- package/dist/repl/safety-confirm.js +47 -2
- package/dist/repl/session-spend-line.js +4 -1
- package/dist/repl/startup-account.js +51 -0
- package/dist/repl.js +217 -48
- package/dist/router/byok-classifier.js +41 -14
- package/dist/router/classifier.js +5 -16
- package/dist/router/router-target.js +41 -0
- package/dist/sap/connection-manager.js +15 -0
- package/dist/sap/onboarding.js +70 -64
- package/dist/sap/session-end.js +66 -0
- package/dist/session/audit-export.js +11 -4
- package/dist/skills/manifest-client.js +37 -4
- package/dist/skills/skills-cache.js +115 -0
- package/dist/skills/source-authed.js +276 -0
- package/dist/tools/ask-question.js +1 -1
- package/dist/tools/filesystem/glob.js +12 -3
- package/dist/tools/filesystem/grep.js +14 -3
- package/dist/tools/sap-read.js +22 -22
- package/dist/tools/sap-write.js +249 -18
- package/dist/tools/snapshot.js +26 -0
- package/dist/tools/tool-loading.js +9 -10
- package/dist/ui/app.js +19 -9
- package/dist/ui/approval-modal.js +37 -5
- package/dist/ui/live-area.js +14 -1
- package/dist/ui/widgets/ask-card.js +183 -107
- package/dist/ui/widgets/coaching-picker.js +22 -1
- package/dist/ui/widgets/confirm-card.js +102 -76
- package/dist/usage/usage-queue.js +323 -0
- package/dist/usage/usage-reporter.js +270 -0
- package/package.json +4 -3
- package/dist/skills/bundled-skills.js +0 -286
- package/dist/skills/source-bundled.js +0 -20
- package/dist/skills/source-manifest.js +0 -36
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure tenant mode — MSAL and sign-in diagnostics go to
|
|
3
|
+
* <CSPEACH_HOME>/logs/azure-signin.log, never the screen, always redacted:
|
|
4
|
+
* JWTs, Bearer values, refresh tokens, authorization codes, PKCE verifiers,
|
|
5
|
+
* states and long hex keys are replaced. Callers still log fixed lines only;
|
|
6
|
+
* this is the second fence, not the first.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { cspeachRoot } from '../../config/paths.js';
|
|
11
|
+
const JWT = /eyJ[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]*){1,2}/g;
|
|
12
|
+
const BEARER = /(Bearer\s+)\S+/gi;
|
|
13
|
+
const PARAMS = /(?<![A-Za-z_])((?:refresh_token|access_token|id_token|client_secret|code_verifier|code|state|session_state|client_info)=)[^&\s]+/gi;
|
|
14
|
+
const HEADERS = /((?:x-api-key|api-key)\s*[:=]\s*)\S+/gi;
|
|
15
|
+
const LONG_HEX = /\b[0-9a-f]{32,}\b/gi;
|
|
16
|
+
export function redactSecrets(line) {
|
|
17
|
+
return line
|
|
18
|
+
.replace(JWT, '<redacted>')
|
|
19
|
+
.replace(BEARER, '$1<redacted>')
|
|
20
|
+
.replace(PARAMS, '$1<redacted>')
|
|
21
|
+
.replace(HEADERS, '$1<redacted>')
|
|
22
|
+
.replace(LONG_HEX, '<redacted>');
|
|
23
|
+
}
|
|
24
|
+
/** The log never grows past this; when it does, the oldest whole lines are dropped. */
|
|
25
|
+
export const AZURE_LOG_MAX_BYTES = 256 * 1024;
|
|
26
|
+
function trim(file) {
|
|
27
|
+
if (fs.statSync(file).size <= AZURE_LOG_MAX_BYTES)
|
|
28
|
+
return;
|
|
29
|
+
const buf = fs.readFileSync(file);
|
|
30
|
+
let tail = buf.subarray(buf.length - Math.floor(AZURE_LOG_MAX_BYTES / 2));
|
|
31
|
+
const nl = tail.indexOf(0x0a);
|
|
32
|
+
if (nl >= 0)
|
|
33
|
+
tail = tail.subarray(nl + 1);
|
|
34
|
+
fs.writeFileSync(file, tail);
|
|
35
|
+
}
|
|
36
|
+
export function azureLog(line) {
|
|
37
|
+
try {
|
|
38
|
+
const dir = path.join(cspeachRoot(), 'logs');
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
const file = path.join(dir, 'azure-signin.log');
|
|
41
|
+
fs.appendFileSync(file, `${new Date().toISOString()} ${redactSecrets(line).replace(/\s+/g, ' ').slice(0, 2000)}\n`);
|
|
42
|
+
trim(file);
|
|
43
|
+
}
|
|
44
|
+
catch { /* logging never breaks sign-in */ }
|
|
45
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure tenant mode — MSAL's token cache (3–6 KB) is too big for one Windows
|
|
3
|
+
* credential (2,560-byte blob cap), so it lives in
|
|
4
|
+
* <CSPEACH_HOME>/azure/msal-cache.enc, AES-256-GCM. The 32-byte key lives in
|
|
5
|
+
* the OS credential store: service cspeach.azure, account cache-key[:<home hash>].
|
|
6
|
+
* Two homes never share a file or a key. No credential store ⇒ no persisted
|
|
7
|
+
* cache (sign in each session), never plaintext.
|
|
8
|
+
*
|
|
9
|
+
* File format: [1 byte version=1][12 bytes IV][16 bytes tag][ciphertext].
|
|
10
|
+
* Every write draws a fresh IV, goes to a temp file in the same folder and is
|
|
11
|
+
* renamed over the old file (atomic). POSIX: folder 0700, file 0600. Windows
|
|
12
|
+
* ignores those modes; the file sits in the person's own profile folder.
|
|
13
|
+
*/
|
|
14
|
+
import { promises as fs } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
17
|
+
import { cspeachRoot } from '../../config/paths.js';
|
|
18
|
+
import { AZURE_SERVICE, azureAccountForHome } from './azure-key.js';
|
|
19
|
+
import { azureLog } from './azure-log.js';
|
|
20
|
+
const VERSION = 1;
|
|
21
|
+
const IV_BYTES = 12;
|
|
22
|
+
const TAG_BYTES = 16;
|
|
23
|
+
const HEADER = 1 + IV_BYTES + TAG_BYTES;
|
|
24
|
+
const KEY_BYTES = 32;
|
|
25
|
+
export const NO_STORE_NOTICE = 'Your Microsoft sign-in is kept for this session only: no OS credential store is available on this machine.';
|
|
26
|
+
export function azureCachePath() {
|
|
27
|
+
return path.join(cspeachRoot(), 'azure', 'msal-cache.enc');
|
|
28
|
+
}
|
|
29
|
+
export function encryptCache(plain, key, iv = randomBytes(IV_BYTES)) {
|
|
30
|
+
const c = createCipheriv('aes-256-gcm', key, iv);
|
|
31
|
+
const ct = Buffer.concat([c.update(plain, 'utf8'), c.final()]);
|
|
32
|
+
return Buffer.concat([Buffer.from([VERSION]), iv, c.getAuthTag(), ct]);
|
|
33
|
+
}
|
|
34
|
+
export function decryptCache(blob, key) {
|
|
35
|
+
if (blob.length < HEADER || blob[0] !== VERSION)
|
|
36
|
+
throw new Error('unreadable cache');
|
|
37
|
+
const d = createDecipheriv('aes-256-gcm', key, blob.subarray(1, 1 + IV_BYTES));
|
|
38
|
+
d.setAuthTag(blob.subarray(1 + IV_BYTES, HEADER));
|
|
39
|
+
return Buffer.concat([d.update(blob.subarray(HEADER)), d.final()]).toString('utf8');
|
|
40
|
+
}
|
|
41
|
+
async function defaultKeychain() {
|
|
42
|
+
return (await import('../keychain.js')).default;
|
|
43
|
+
}
|
|
44
|
+
async function writeAtomic(file, data) {
|
|
45
|
+
const dir = path.dirname(file);
|
|
46
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
47
|
+
// mkdir's mode only applies to a NEW folder; tighten one that already existed.
|
|
48
|
+
// A failure (a folder owned by someone else, a file system without modes)
|
|
49
|
+
// is logged and never fails the cache write: the file itself is still 0600.
|
|
50
|
+
if (process.platform !== 'win32') {
|
|
51
|
+
try {
|
|
52
|
+
await fs.chmod(dir, 0o700);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
azureLog('cache: could not tighten the sign-in cache folder permissions');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const tmp = `${file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
59
|
+
try {
|
|
60
|
+
await fs.writeFile(tmp, data, { mode: 0o600, flag: 'wx' });
|
|
61
|
+
await fs.rename(tmp, file);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
await fs.rm(tmp, { force: true }).catch(() => { });
|
|
65
|
+
throw e;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function asKey(stored) {
|
|
69
|
+
const decoded = stored ? Buffer.from(stored, 'base64') : null;
|
|
70
|
+
return decoded && decoded.length === KEY_BYTES ? decoded : null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Two processes can start on the same home at once. Rules that keep them on
|
|
74
|
+
* ONE key without deleting each other's file:
|
|
75
|
+
* - first use: store a new key, then read it back and adopt what is stored
|
|
76
|
+
* (another process may have written last);
|
|
77
|
+
* - before each write: re-read the stored key and adopt it if it changed;
|
|
78
|
+
* - a read that fails to decrypt: re-read the stored key once and retry
|
|
79
|
+
* before treating the file as damaged.
|
|
80
|
+
*/
|
|
81
|
+
export async function openCacheStore(deps = {}) {
|
|
82
|
+
const account = azureAccountForHome('cache-key');
|
|
83
|
+
let kc;
|
|
84
|
+
let key;
|
|
85
|
+
try {
|
|
86
|
+
kc = deps.keychain ?? (await defaultKeychain());
|
|
87
|
+
const existing = asKey(await kc.getPassword(AZURE_SERVICE, account));
|
|
88
|
+
if (existing)
|
|
89
|
+
key = existing;
|
|
90
|
+
else {
|
|
91
|
+
const fresh = (deps.random ?? randomBytes)(KEY_BYTES);
|
|
92
|
+
await kc.setPassword(AZURE_SERVICE, account, fresh.toString('base64'));
|
|
93
|
+
key = asKey(await kc.getPassword(AZURE_SERVICE, account)) ?? fresh;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
deps.notice?.(NO_STORE_NOTICE);
|
|
98
|
+
return { plugin: null, persisted: false };
|
|
99
|
+
}
|
|
100
|
+
/** The stored key when it is readable and different from ours; otherwise null. */
|
|
101
|
+
const storedIfChanged = async () => {
|
|
102
|
+
try {
|
|
103
|
+
const stored = asKey(await kc.getPassword(AZURE_SERVICE, account));
|
|
104
|
+
return stored && !stored.equals(key) ? stored : null;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const file = azureCachePath();
|
|
111
|
+
const plugin = {
|
|
112
|
+
async beforeCacheAccess(ctx) {
|
|
113
|
+
let blob;
|
|
114
|
+
try {
|
|
115
|
+
blob = await fs.readFile(file);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
let plain = null;
|
|
121
|
+
try {
|
|
122
|
+
plain = decryptCache(blob, key);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
const stored = await storedIfChanged();
|
|
126
|
+
if (stored) {
|
|
127
|
+
try {
|
|
128
|
+
plain = decryptCache(blob, stored);
|
|
129
|
+
key = stored;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
plain = null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
if (plain === null)
|
|
138
|
+
throw new Error('unreadable');
|
|
139
|
+
ctx.tokenCache.deserialize(plain);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// Damaged, written under a key no longer stored, or unreadable to MSAL: start empty and drop it.
|
|
143
|
+
await fs.rm(file, { force: true }).catch(() => { });
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
async afterCacheAccess(ctx) {
|
|
147
|
+
if (!ctx.cacheHasChanged)
|
|
148
|
+
return;
|
|
149
|
+
const stored = await storedIfChanged();
|
|
150
|
+
if (stored)
|
|
151
|
+
key = stored;
|
|
152
|
+
await writeAtomic(file, encryptCache(ctx.tokenCache.serialize(), key));
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
return { plugin, persisted: true };
|
|
156
|
+
}
|
|
157
|
+
/** Logout: the cache file and its key for THIS home. True when either existed. */
|
|
158
|
+
export async function clearAzureCache(deps = {}) {
|
|
159
|
+
const kc = deps.keychain ?? (await defaultKeychain());
|
|
160
|
+
let file = false;
|
|
161
|
+
try {
|
|
162
|
+
await fs.unlink(azureCachePath());
|
|
163
|
+
file = true;
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
if (e.code !== 'ENOENT')
|
|
167
|
+
throw e;
|
|
168
|
+
}
|
|
169
|
+
const key = await kc.deletePassword(AZURE_SERVICE, azureAccountForHome('cache-key'));
|
|
170
|
+
return file || key;
|
|
171
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure tenant mode — what `cspeach logout` removes on top of today's
|
|
3
|
+
* entries: the company settings (Task 4), the Azure key (Task 5), the
|
|
4
|
+
* Microsoft sign-in cache file and its key (Task 6), and this home's local
|
|
5
|
+
* limit count, azure-usage-local.json (final fix wave E). Reports in plain words
|
|
6
|
+
* what it removed and what it could not.
|
|
7
|
+
*/
|
|
8
|
+
import { deleteConnectionCache } from '../../connection/cache.js';
|
|
9
|
+
import { deleteAzureKey } from './azure-key.js';
|
|
10
|
+
import { clearAzureCache } from './cache-store.js';
|
|
11
|
+
import { deleteLocalUsage } from '../../connection/local-usage.js';
|
|
12
|
+
export async function clearAzureSignIn(deps = {}) {
|
|
13
|
+
const out = { removed: [], failed: [] };
|
|
14
|
+
const step = async (label, run) => {
|
|
15
|
+
try {
|
|
16
|
+
if (await run())
|
|
17
|
+
out.removed.push(label);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
out.failed.push(label);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
await step("your company's Azure settings", deps.deleteConnection ?? deleteConnectionCache);
|
|
24
|
+
await step('your Azure key', deps.deleteKey ?? deleteAzureKey);
|
|
25
|
+
await step('your Microsoft sign-in', deps.clearCache ?? (() => clearAzureCache()));
|
|
26
|
+
await step('your Azure usage count on this machine', deps.deleteLocalCount ?? deleteLocalUsage);
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure tenant mode — our own loopback listener for the browser sign-in
|
|
3
|
+
* (owner decision P5: it is ALWAYS closed when sign-in ends — success,
|
|
4
|
+
* error, timeout or abort). MSAL Node 6.0.1 gives no way to supply or close
|
|
5
|
+
* its own listener, so sign-in.ts uses this one with MSAL's public
|
|
6
|
+
* getAuthCodeUrl + acquireTokenByCode.
|
|
7
|
+
*
|
|
8
|
+
* - Loopback only: 127.0.0.1, plus ::1 on the SAME port when IPv6 is there
|
|
9
|
+
* (a browser may resolve localhost either way). Never 0.0.0.0 or '::'.
|
|
10
|
+
* IPv4 alone only when this machine has no IPv6 loopback; when the port is
|
|
11
|
+
* busy on ::1, up to 20 new ports, then LoopbackPortError (a plain line).
|
|
12
|
+
* - Redirect URI http://localhost:<port> (Entra matches any port for localhost).
|
|
13
|
+
* - Exactly one completing GET on "/": the state must match (single use) and
|
|
14
|
+
* the Host must be localhost / 127.0.0.1 / [::1] on our port. Anything else
|
|
15
|
+
* gets a 4xx and does not end the sign-in.
|
|
16
|
+
* - The page is short fixed HTML: no code, state, token or Microsoft text.
|
|
17
|
+
* - Nothing here logs. The code, state and redirect query never leave this
|
|
18
|
+
* module except as the resolved value.
|
|
19
|
+
*/
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
22
|
+
/** The signal fired (timeout or abort) before the browser came back. */
|
|
23
|
+
export class LoopbackCancelledError extends Error {
|
|
24
|
+
constructor() { super('The sign-in was stopped before the browser came back.'); this.name = 'LoopbackCancelledError'; }
|
|
25
|
+
}
|
|
26
|
+
/** Microsoft sent the browser back with `error`. `detail` is for classifying only — never log or print it. */
|
|
27
|
+
export class LoopbackAuthError extends Error {
|
|
28
|
+
errorCode;
|
|
29
|
+
detail;
|
|
30
|
+
constructor(errorCode, detail) {
|
|
31
|
+
super('Microsoft sent the sign-in back with an error.');
|
|
32
|
+
this.name = 'LoopbackAuthError';
|
|
33
|
+
this.errorCode = errorCode;
|
|
34
|
+
this.detail = detail;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** No port was free on both 127.0.0.1 and ::1 after every try. */
|
|
38
|
+
export class LoopbackPortError extends Error {
|
|
39
|
+
constructor() { super('Could not open a sign-in port on this computer. Try again, or use cspeach login azure --device-code.'); this.name = 'LoopbackPortError'; }
|
|
40
|
+
}
|
|
41
|
+
const IPV4 = '127.0.0.1';
|
|
42
|
+
const IPV6 = '::1';
|
|
43
|
+
/** ::1 cannot be used on this machine: no IPv6 loopback. */
|
|
44
|
+
const IPV6_UNAVAILABLE = new Set(['EADDRNOTAVAIL', 'EAFNOSUPPORT', 'EINVAL', 'EPROTONOSUPPORT']);
|
|
45
|
+
const page = (text) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>CSPeach sign-in</title></head><body><p>${text}</p></body></html>`;
|
|
46
|
+
const PAGE_OK = page('Signed in. You can close this tab and go back to CSPeach.');
|
|
47
|
+
const PAGE_FAILED = page('Sign-in did not finish. You can close this tab and go back to CSPeach for the next step.');
|
|
48
|
+
const PAGE_NOT_OURS = page('This page is not part of the CSPeach sign-in. You can close this tab.');
|
|
49
|
+
function listen(server, port, host) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const onError = (e) => { server.off('listening', onListening); reject(e); };
|
|
52
|
+
const onListening = () => { server.off('error', onError); resolve(); };
|
|
53
|
+
server.once('error', onError);
|
|
54
|
+
server.once('listening', onListening);
|
|
55
|
+
server.listen({ port, host, exclusive: true });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function closeServer(server) {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
if (!server.listening) {
|
|
61
|
+
server.closeAllConnections?.();
|
|
62
|
+
resolve();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
server.close(() => resolve());
|
|
66
|
+
server.closeAllConnections?.();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function sameSecret(a, b) {
|
|
70
|
+
const x = Buffer.from(a, 'utf8');
|
|
71
|
+
const y = Buffer.from(b, 'utf8');
|
|
72
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
73
|
+
}
|
|
74
|
+
/** Microsoft error codes are short snake_case words; anything else is reduced to a fixed word. */
|
|
75
|
+
function errorCodeOf(raw) {
|
|
76
|
+
return /^[a-z_]{1,64}$/.test(raw) ? raw : 'unknown_error';
|
|
77
|
+
}
|
|
78
|
+
async function bind(make, attempts) {
|
|
79
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
80
|
+
const v4 = make(IPV4);
|
|
81
|
+
await listen(v4, 0, IPV4);
|
|
82
|
+
const port = v4.address().port;
|
|
83
|
+
const v6 = make(IPV6);
|
|
84
|
+
try {
|
|
85
|
+
await listen(v6, port, IPV6);
|
|
86
|
+
return [v4, v6];
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
const code = e.code ?? '';
|
|
90
|
+
// IPv4 alone ONLY when this machine has no IPv6 loopback at all.
|
|
91
|
+
if (IPV6_UNAVAILABLE.has(code))
|
|
92
|
+
return [v4];
|
|
93
|
+
await closeServer(v4);
|
|
94
|
+
if (code !== 'EADDRINUSE')
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// ::1 exists but no port was free on both: a browser that resolves
|
|
99
|
+
// localhost to ::1 would never reach an IPv4-only listener. Stop plainly.
|
|
100
|
+
throw new LoopbackPortError();
|
|
101
|
+
}
|
|
102
|
+
export async function startLoopback(o) {
|
|
103
|
+
if (o.signal?.aborted)
|
|
104
|
+
throw new LoopbackCancelledError();
|
|
105
|
+
const make = o.makeServer ?? (() => http.createServer());
|
|
106
|
+
const servers = await bind(make, o.maxAttempts ?? 20);
|
|
107
|
+
const port = servers[0].address().port;
|
|
108
|
+
const hosts = servers.map((s) => s.address().address);
|
|
109
|
+
const allowedHosts = new Set([`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`]);
|
|
110
|
+
let settle;
|
|
111
|
+
const result = new Promise((resolve, reject) => { settle = { resolve, reject }; });
|
|
112
|
+
result.catch(() => { });
|
|
113
|
+
let done = false;
|
|
114
|
+
let closing = null;
|
|
115
|
+
const onAbort = () => {
|
|
116
|
+
if (done)
|
|
117
|
+
return;
|
|
118
|
+
done = true;
|
|
119
|
+
void close();
|
|
120
|
+
settle.reject(new LoopbackCancelledError());
|
|
121
|
+
};
|
|
122
|
+
const close = () => {
|
|
123
|
+
if (!closing) {
|
|
124
|
+
o.signal?.removeEventListener('abort', onAbort);
|
|
125
|
+
closing = Promise.all(servers.map(closeServer)).then(() => undefined);
|
|
126
|
+
if (!done) {
|
|
127
|
+
done = true;
|
|
128
|
+
settle.reject(new LoopbackCancelledError());
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return closing;
|
|
132
|
+
};
|
|
133
|
+
const handler = (req, res) => {
|
|
134
|
+
const send = (status, body) => {
|
|
135
|
+
res.writeHead(status, {
|
|
136
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
137
|
+
'Cache-Control': 'no-store',
|
|
138
|
+
'Referrer-Policy': 'no-referrer',
|
|
139
|
+
'Content-Security-Policy': "default-src 'none'",
|
|
140
|
+
'X-Content-Type-Options': 'nosniff',
|
|
141
|
+
Connection: 'close',
|
|
142
|
+
});
|
|
143
|
+
res.end(body);
|
|
144
|
+
};
|
|
145
|
+
if (done)
|
|
146
|
+
return send(400, PAGE_NOT_OURS);
|
|
147
|
+
if (req.method !== 'GET')
|
|
148
|
+
return send(405, PAGE_NOT_OURS);
|
|
149
|
+
if (!allowedHosts.has((req.headers.host ?? '').toLowerCase()))
|
|
150
|
+
return send(400, PAGE_NOT_OURS);
|
|
151
|
+
let url;
|
|
152
|
+
try {
|
|
153
|
+
url = new URL(req.url ?? '', 'http://localhost');
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return send(400, PAGE_NOT_OURS);
|
|
157
|
+
}
|
|
158
|
+
if (url.pathname !== '/')
|
|
159
|
+
return send(404, PAGE_NOT_OURS);
|
|
160
|
+
const states = url.searchParams.getAll('state');
|
|
161
|
+
if (states.length !== 1 || !sameSecret(states[0], o.state))
|
|
162
|
+
return send(400, PAGE_NOT_OURS);
|
|
163
|
+
const error = url.searchParams.get('error');
|
|
164
|
+
if (error) {
|
|
165
|
+
done = true;
|
|
166
|
+
send(200, PAGE_FAILED);
|
|
167
|
+
settle.reject(new LoopbackAuthError(errorCodeOf(error), (url.searchParams.get('error_description') ?? '').slice(0, 500)));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const codes = url.searchParams.getAll('code');
|
|
171
|
+
if (codes.length !== 1 || !codes[0])
|
|
172
|
+
return send(400, PAGE_NOT_OURS);
|
|
173
|
+
done = true;
|
|
174
|
+
send(200, PAGE_OK);
|
|
175
|
+
const clientInfo = url.searchParams.get('client_info');
|
|
176
|
+
settle.resolve({ code: codes[0], ...(clientInfo ? { clientInfo } : {}) });
|
|
177
|
+
};
|
|
178
|
+
for (const s of servers)
|
|
179
|
+
s.on('request', handler);
|
|
180
|
+
o.signal?.addEventListener('abort', onAbort, { once: true });
|
|
181
|
+
if (o.signal?.aborted)
|
|
182
|
+
onAbort();
|
|
183
|
+
return {
|
|
184
|
+
redirectUri: `http://localhost:${port}`,
|
|
185
|
+
port,
|
|
186
|
+
hosts,
|
|
187
|
+
boundAddresses: () => servers.filter((s) => s.listening).map((s) => s.address()),
|
|
188
|
+
isListening: () => servers.some((s) => s.listening),
|
|
189
|
+
waitForCode: () => result,
|
|
190
|
+
close,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const createMsal = async (o) => {
|
|
2
|
+
const msal = await import('@azure/msal-node');
|
|
3
|
+
const pca = new msal.PublicClientApplication({
|
|
4
|
+
auth: { clientId: o.clientId, authority: `https://login.microsoftonline.com/${o.tenantId}` },
|
|
5
|
+
...(o.cachePlugin ? { cache: { cachePlugin: o.cachePlugin } } : {}),
|
|
6
|
+
system: {
|
|
7
|
+
loggerOptions: {
|
|
8
|
+
logLevel: msal.LogLevel.Error,
|
|
9
|
+
piiLoggingEnabled: false,
|
|
10
|
+
loggerCallback: (_level, message, containsPii) => { if (!containsPii)
|
|
11
|
+
o.log(message); },
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
const crypto = new msal.CryptoProvider();
|
|
16
|
+
const app = {
|
|
17
|
+
generatePkceCodes: () => crypto.generatePkceCodes(),
|
|
18
|
+
getAuthCodeUrl: (r) => pca.getAuthCodeUrl(r),
|
|
19
|
+
acquireTokenByCode: async (r) => (await pca.acquireTokenByCode(r)),
|
|
20
|
+
acquireTokenByDeviceCode: async (r) => (await pca.acquireTokenByDeviceCode(r)),
|
|
21
|
+
acquireTokenSilent: async (r) => (await pca.acquireTokenSilent(r)),
|
|
22
|
+
getAllAccounts: async () => (await pca.getAllAccounts()),
|
|
23
|
+
removeAccount: (a) => pca.getTokenCache().removeAccount(a),
|
|
24
|
+
};
|
|
25
|
+
return app;
|
|
26
|
+
};
|
|
27
|
+
const INTERACTION_CODES = new Set([
|
|
28
|
+
'interaction_required', 'invalid_grant', 'login_required', 'consent_required',
|
|
29
|
+
'no_tokens_found', 'no_account_in_silent_request', 'token_refresh_required',
|
|
30
|
+
]);
|
|
31
|
+
export function isInteractionRequired(err) {
|
|
32
|
+
const e = err;
|
|
33
|
+
return e?.name === 'InteractionRequiredAuthError' || (typeof e?.errorCode === 'string' && INTERACTION_CODES.has(e.errorCode));
|
|
34
|
+
}
|
|
35
|
+
export function tenantOf(r) {
|
|
36
|
+
const t = r.tenantId ?? (typeof r.idTokenClaims?.tid === 'string' ? r.idTokenClaims.tid : undefined) ?? r.account?.tenantId;
|
|
37
|
+
return typeof t === 'string' && t ? t.toLowerCase() : null;
|
|
38
|
+
}
|
|
39
|
+
export class AzureWrongTenantError extends Error {
|
|
40
|
+
constructor() { super('This Microsoft account belongs to a different company directory.'); this.name = 'AzureWrongTenantError'; }
|
|
41
|
+
}
|