@serviceme/devtools-core 2.0.0 → 2.0.2
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/auth-Bzss8zOv.mjs +995 -0
- package/dist/auth-Bzss8zOv.mjs.map +1 -0
- package/dist/auth-DXam3c4S.js +1078 -0
- package/dist/auth-DXam3c4S.js.map +1 -0
- package/dist/auth.d.mts +2 -2
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +14 -1029
- package/dist/auth.mjs +2 -991
- package/dist/device-CDDcwDY6.mjs +758 -0
- package/dist/device-CDDcwDY6.mjs.map +1 -0
- package/dist/device-DuAYH8bk.js +839 -0
- package/dist/device-DuAYH8bk.js.map +1 -0
- package/dist/device.d.mts +2 -455
- package/dist/device.d.ts +2 -455
- package/dist/device.js +15 -728
- package/dist/device.mjs +2 -679
- package/dist/index-2rX86yMq.d.mts +331 -0
- package/dist/index-BI11AfMH.d.mts +377 -0
- package/dist/index-BI11AfMH.d.ts +377 -0
- package/dist/index-CoA6y0MP.d.mts +498 -0
- package/dist/index-CoA6y0MP.d.ts +498 -0
- package/dist/index-DaW7IYA-.d.mts +185 -0
- package/dist/index-DaW7IYA-.d.ts +185 -0
- package/dist/index-Dp6FwWKh.d.ts +331 -0
- package/dist/index.d.mts +2340 -2101
- package/dist/index.d.ts +2340 -2101
- package/dist/index.js +8117 -10538
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +7895 -10318
- package/dist/index.mjs.map +1 -1
- package/dist/rolldown-runtime-VH7oDXx4.js +28 -0
- package/dist/skill-linker.d.mts +82 -81
- package/dist/skill-linker.d.ts +82 -81
- package/dist/skill-linker.js +359 -402
- package/dist/skill-linker.js.map +1 -1
- package/dist/skill-linker.mjs +345 -357
- package/dist/skill-linker.mjs.map +1 -1
- package/dist/skill-store-Bym2yinU.mjs +388 -0
- package/dist/skill-store-Bym2yinU.mjs.map +1 -0
- package/dist/skill-store-CJdYyyxu.js +408 -0
- package/dist/skill-store-CJdYyyxu.js.map +1 -0
- package/dist/skill-store.d.mts +41 -42
- package/dist/skill-store.d.ts +41 -42
- package/dist/skill-store.js +5 -338
- package/dist/skill-store.mjs +2 -301
- package/dist/submit-Cg7wSa3x.mjs +84 -0
- package/dist/submit-Cg7wSa3x.mjs.map +1 -0
- package/dist/submit-CltE6YOZ.js +98 -0
- package/dist/submit-CltE6YOZ.js.map +1 -0
- package/dist/submit.d.mts +2 -3
- package/dist/submit.d.ts +2 -3
- package/dist/submit.js +4 -166
- package/dist/submit.mjs +2 -130
- package/dist/toolbox-CbfxpNg-.mjs +540 -0
- package/dist/toolbox-CbfxpNg-.mjs.map +1 -0
- package/dist/toolbox-CxnOr-FY.js +614 -0
- package/dist/toolbox-CxnOr-FY.js.map +1 -0
- package/dist/toolbox.d.mts +2 -244
- package/dist/toolbox.d.ts +2 -244
- package/dist/toolbox.js +14 -584
- package/dist/toolbox.mjs +2 -536
- package/dist/{types-B9gk3dXH.d.ts → types-BCV3IedE.d.mts} +25 -23
- package/dist/{types-B9gk3dXH.d.mts → types-BCV3IedE.d.ts} +25 -23
- package/dist/userHome-BquDeFVE.js +526 -0
- package/dist/userHome-BquDeFVE.js.map +1 -0
- package/dist/userHome-CcnAQPG1.mjs +212 -0
- package/dist/userHome-CcnAQPG1.mjs.map +1 -0
- package/package.json +10 -10
- package/dist/auth.js.map +0 -1
- package/dist/auth.mjs.map +0 -1
- package/dist/device.js.map +0 -1
- package/dist/device.mjs.map +0 -1
- package/dist/index-BMk4tqIT.d.mts +0 -328
- package/dist/index-CymN0x9Z.d.ts +0 -328
- package/dist/index-PD135hlB.d.mts +0 -626
- package/dist/index-PD135hlB.d.ts +0 -626
- package/dist/skill-store.js.map +0 -1
- package/dist/skill-store.mjs.map +0 -1
- package/dist/submit.js.map +0 -1
- package/dist/submit.mjs.map +0 -1
- package/dist/toolbox.js.map +0 -1
- package/dist/toolbox.mjs.map +0 -1
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
//#region src/auth/AccessControl.ts
|
|
3
|
+
const DEFAULT_CACHE_TTL_MS = 6e5;
|
|
4
|
+
const DEFAULT_PERSISTENT_CACHE_TTL_MS = 864e5;
|
|
5
|
+
const KEY_GRANTED_USERS = "msDevTools.accessControl.grantedUsers";
|
|
6
|
+
const KEY_MEMBERSHIP_CACHE = "msDevTools.accessControl.membershipCache";
|
|
7
|
+
const KEY_SERVER_IN_ORG = "msDevTools.accessControl.serverInOrg";
|
|
8
|
+
var AccessControl = class {
|
|
9
|
+
constructor(kv, options) {
|
|
10
|
+
this.kv = kv;
|
|
11
|
+
this.memCache = /* @__PURE__ */ new Map();
|
|
12
|
+
this.serverInOrgMem = /* @__PURE__ */ new Map();
|
|
13
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
14
|
+
this.opts = {
|
|
15
|
+
org: options.org,
|
|
16
|
+
tokenFetcher: options.tokenFetcher,
|
|
17
|
+
orgFetcher: options.orgFetcher,
|
|
18
|
+
microsoftEmailDomains: options.microsoftEmailDomains ?? [],
|
|
19
|
+
cacheTtlMs: options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS,
|
|
20
|
+
persistentCacheTtlMs: options.persistentCacheTtlMs ?? DEFAULT_PERSISTENT_CACHE_TTL_MS,
|
|
21
|
+
now: options.now ?? (() => Date.now())
|
|
22
|
+
};
|
|
23
|
+
const persistedServerInOrg = this.kv.get(KEY_SERVER_IN_ORG);
|
|
24
|
+
if (persistedServerInOrg) for (const [key, value] of Object.entries(persistedServerInOrg)) this.serverInOrgMem.set(key, value);
|
|
25
|
+
}
|
|
26
|
+
/** Subscribe to inOrg changes (used by extension to refresh access-gated UI). */
|
|
27
|
+
onDidChange(listener) {
|
|
28
|
+
this.listeners.add(listener);
|
|
29
|
+
return () => this.listeners.delete(listener);
|
|
30
|
+
}
|
|
31
|
+
/** Full check: requires authentication + org membership / admin grant. */
|
|
32
|
+
async checkAccess(user) {
|
|
33
|
+
if (!user) return {
|
|
34
|
+
allowed: false,
|
|
35
|
+
reason: "not_authenticated"
|
|
36
|
+
};
|
|
37
|
+
if (user.provider === "microsoft" && user.login) {
|
|
38
|
+
const inOrg = this.serverInOrgMem.get(user.login.toLowerCase());
|
|
39
|
+
if (inOrg === true) return { allowed: true };
|
|
40
|
+
if (inOrg === false) return {
|
|
41
|
+
allowed: false,
|
|
42
|
+
reason: "not_member",
|
|
43
|
+
username: user.login
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const githubAccount = user.provider === "github" ? user : null;
|
|
47
|
+
if (!githubAccount && user.provider === "microsoft" && user.login) {
|
|
48
|
+
const [, domain = ""] = user.login.split("@");
|
|
49
|
+
if (domain.length > 0 && this.opts.microsoftEmailDomains.includes(domain.toLowerCase())) return { allowed: true };
|
|
50
|
+
return {
|
|
51
|
+
allowed: false,
|
|
52
|
+
reason: "not_member",
|
|
53
|
+
username: user.login
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (!githubAccount) return { allowed: true };
|
|
57
|
+
const accessUsername = githubAccount.login ?? user.login ?? "";
|
|
58
|
+
if (accessUsername.length === 0) return {
|
|
59
|
+
allowed: false,
|
|
60
|
+
reason: "not_member",
|
|
61
|
+
username: ""
|
|
62
|
+
};
|
|
63
|
+
if (this.getGrantedUsers().includes(accessUsername.toLowerCase())) return { allowed: true };
|
|
64
|
+
return await this.checkOrgMembership(accessUsername) ? { allowed: true } : {
|
|
65
|
+
allowed: false,
|
|
66
|
+
reason: "not_member",
|
|
67
|
+
username: accessUsername
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Admin: grant access to a GitHub username (bypasses org check). */
|
|
71
|
+
async grantAccess(username) {
|
|
72
|
+
const list = this.getGrantedUsers();
|
|
73
|
+
const key = username.toLowerCase();
|
|
74
|
+
if (!list.includes(key)) {
|
|
75
|
+
list.push(key);
|
|
76
|
+
await this.kv.update(KEY_GRANTED_USERS, list);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Admin: revoke a previously granted access. */
|
|
80
|
+
async revokeAccess(username) {
|
|
81
|
+
const updated = this.getGrantedUsers().filter((u) => u !== username.toLowerCase());
|
|
82
|
+
await this.kv.update(KEY_GRANTED_USERS, updated);
|
|
83
|
+
}
|
|
84
|
+
/** Snapshot of all admin-granted usernames (lowercased). */
|
|
85
|
+
getGrantedUsers() {
|
|
86
|
+
return this.kv.get(KEY_GRANTED_USERS) ?? [];
|
|
87
|
+
}
|
|
88
|
+
/** Cached membership lookup with both in-memory + persistent layers. */
|
|
89
|
+
async checkOrgMembership(username) {
|
|
90
|
+
const now = this.opts.now();
|
|
91
|
+
const inMem = this.memCache.get(username);
|
|
92
|
+
if (inMem && now - inMem.at < this.opts.cacheTtlMs) return inMem.isMember;
|
|
93
|
+
const persistentEntry = (this.kv.get(KEY_MEMBERSHIP_CACHE) ?? {})[username];
|
|
94
|
+
if (persistentEntry && now - persistentEntry.at < this.opts.persistentCacheTtlMs) {
|
|
95
|
+
this.memCache.set(username, persistentEntry);
|
|
96
|
+
return persistentEntry.isMember;
|
|
97
|
+
}
|
|
98
|
+
const token = await this.opts.tokenFetcher("github");
|
|
99
|
+
if (!token) return false;
|
|
100
|
+
try {
|
|
101
|
+
const result = await this.opts.orgFetcher(token, this.opts.org);
|
|
102
|
+
const decision = this.resolveDecision(username, result);
|
|
103
|
+
if (typeof decision === "boolean") {
|
|
104
|
+
this.updateCache(username, decision);
|
|
105
|
+
return decision;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Server-confirmed inOrg status (set by device claim flow). */
|
|
113
|
+
setServerInOrg(username, inOrg) {
|
|
114
|
+
const key = username.toLowerCase();
|
|
115
|
+
this.serverInOrgMem.set(key, inOrg);
|
|
116
|
+
const persisted = this.kv.get(KEY_SERVER_IN_ORG) ?? {};
|
|
117
|
+
persisted[key] = inOrg;
|
|
118
|
+
this.kv.update(KEY_SERVER_IN_ORG, persisted);
|
|
119
|
+
this.notifyListeners();
|
|
120
|
+
}
|
|
121
|
+
/** Inspect server-confirmed inOrg for a username; returns undefined when unknown. */
|
|
122
|
+
getServerInOrg(username) {
|
|
123
|
+
return this.serverInOrgMem.get(username.toLowerCase());
|
|
124
|
+
}
|
|
125
|
+
resolveDecision(_username, membership) {
|
|
126
|
+
if (membership.status === "active" || membership.status === "pending") return true;
|
|
127
|
+
if (membership.status === "not_member") return false;
|
|
128
|
+
}
|
|
129
|
+
updateCache(username, isMember) {
|
|
130
|
+
const at = this.opts.now();
|
|
131
|
+
this.memCache.set(username, {
|
|
132
|
+
isMember,
|
|
133
|
+
at
|
|
134
|
+
});
|
|
135
|
+
const persistent = this.kv.get(KEY_MEMBERSHIP_CACHE) ?? {};
|
|
136
|
+
persistent[username] = {
|
|
137
|
+
isMember,
|
|
138
|
+
at
|
|
139
|
+
};
|
|
140
|
+
this.kv.update(KEY_MEMBERSHIP_CACHE, persistent);
|
|
141
|
+
}
|
|
142
|
+
notifyListeners() {
|
|
143
|
+
for (const listener of this.listeners) try {
|
|
144
|
+
listener();
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region src/logger.ts
|
|
150
|
+
const noopLogger = {
|
|
151
|
+
debug() {},
|
|
152
|
+
info() {},
|
|
153
|
+
warn() {},
|
|
154
|
+
error() {}
|
|
155
|
+
};
|
|
156
|
+
function formatArgs(args) {
|
|
157
|
+
return args.map((arg) => {
|
|
158
|
+
if (typeof arg === "string") return arg;
|
|
159
|
+
try {
|
|
160
|
+
return JSON.stringify(arg);
|
|
161
|
+
} catch {
|
|
162
|
+
return String(arg);
|
|
163
|
+
}
|
|
164
|
+
}).join(" ");
|
|
165
|
+
}
|
|
166
|
+
function createConsoleLogger(prefix = "serviceme") {
|
|
167
|
+
return {
|
|
168
|
+
debug(message, ...args) {
|
|
169
|
+
process.stderr.write(`[${prefix}] DEBUG ${message} ${formatArgs(args)}\n`);
|
|
170
|
+
},
|
|
171
|
+
info(message, ...args) {
|
|
172
|
+
process.stderr.write(`[${prefix}] INFO ${message} ${formatArgs(args)}\n`);
|
|
173
|
+
},
|
|
174
|
+
warn(message, ...args) {
|
|
175
|
+
process.stderr.write(`[${prefix}] WARN ${message} ${formatArgs(args)}\n`);
|
|
176
|
+
},
|
|
177
|
+
error(message, ...args) {
|
|
178
|
+
process.stderr.write(`[${prefix}] ERROR ${message} ${formatArgs(args)}\n`);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region src/auth/AuthStateManager.ts
|
|
184
|
+
/**
|
|
185
|
+
* AuthStateManager — In-memory mirror of the persisted auth state.
|
|
186
|
+
*
|
|
187
|
+
* Holds the multi-account map (provider + accountId keyed) plus the
|
|
188
|
+
* "active" provider/account pair. Emits change events via Node's
|
|
189
|
+
* built-in `events.EventEmitter` so listeners stay decoupled.
|
|
190
|
+
*
|
|
191
|
+
* Important: this class does NOT touch `vscode.SecretStorage` or any
|
|
192
|
+
* file — it only keeps the metadata (`AuthAccountMeta`) and the
|
|
193
|
+
* `activeProvider` selection. Token bytes are looked up via the
|
|
194
|
+
* `KeychainAuthTokenStore` on demand. This separation is what lets
|
|
195
|
+
* `AuthCore` run identically in CLI + Extension.
|
|
196
|
+
*
|
|
197
|
+
* Refs:
|
|
198
|
+
* - 4.功能规划.md §2.1 — `AuthStateManager.ts events.EventEmitter, NO VSCode dep`
|
|
199
|
+
* - ADL-004 — token bytes never enter Core state
|
|
200
|
+
*/
|
|
201
|
+
var AuthStateManager = class {
|
|
202
|
+
constructor(opts = {}) {
|
|
203
|
+
this.emitter = new EventEmitter();
|
|
204
|
+
this.accounts = [];
|
|
205
|
+
this.activeProvider = null;
|
|
206
|
+
this.activeAccountId = null;
|
|
207
|
+
this.lastError = null;
|
|
208
|
+
if (opts.maxListeners !== void 0) this.emitter.setMaxListeners(opts.maxListeners);
|
|
209
|
+
}
|
|
210
|
+
/** Subscribe to state-change events. Returns a disposer. */
|
|
211
|
+
onDidChange(listener) {
|
|
212
|
+
this.emitter.on("change", listener);
|
|
213
|
+
return () => this.emitter.off("change", listener);
|
|
214
|
+
}
|
|
215
|
+
/** Snapshot of all known accounts (immutable copy). */
|
|
216
|
+
listAccounts() {
|
|
217
|
+
return [...this.accounts];
|
|
218
|
+
}
|
|
219
|
+
/** Find an account by `(provider, accountId)` tuple; returns `null` when absent. */
|
|
220
|
+
findAccount(provider, accountId) {
|
|
221
|
+
return this.accounts.find((a) => a.provider === provider && a.id === accountId) ?? null;
|
|
222
|
+
}
|
|
223
|
+
/** First account for the requested provider — used for "switch to GitHub" UX. */
|
|
224
|
+
findFirstForProvider(provider) {
|
|
225
|
+
return this.accounts.find((a) => a.provider === provider) ?? null;
|
|
226
|
+
}
|
|
227
|
+
/** Insert or update an account entry. New accounts land at the head of the list. */
|
|
228
|
+
upsertAccount(meta) {
|
|
229
|
+
const idx = this.accounts.findIndex((a) => a.provider === meta.provider && a.id === meta.id);
|
|
230
|
+
if (idx >= 0) this.accounts[idx] = meta;
|
|
231
|
+
else this.accounts.unshift(meta);
|
|
232
|
+
this.fire();
|
|
233
|
+
}
|
|
234
|
+
/** Remove an account entry. Returns `true` when an entry was removed. */
|
|
235
|
+
removeAccount(provider, accountId) {
|
|
236
|
+
const before = this.accounts.length;
|
|
237
|
+
this.accounts = this.accounts.filter((a) => !(a.provider === provider && a.id === accountId));
|
|
238
|
+
const removed = this.accounts.length !== before;
|
|
239
|
+
if (removed && this.activeProvider === provider && this.activeAccountId === accountId) {
|
|
240
|
+
if (!this.accounts.some((a) => a.provider === provider)) {
|
|
241
|
+
this.activeProvider = null;
|
|
242
|
+
this.activeAccountId = null;
|
|
243
|
+
} else {
|
|
244
|
+
const next = this.accounts.find((a) => a.provider === provider);
|
|
245
|
+
if (next) this.activeAccountId = next.id;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (removed) this.fire();
|
|
249
|
+
return removed;
|
|
250
|
+
}
|
|
251
|
+
/** Set the active provider. When `accountId` is omitted, picks the first account for that provider. */
|
|
252
|
+
setActive(provider, accountId) {
|
|
253
|
+
const match = accountId !== void 0 ? this.accounts.find((a) => a.provider === provider && a.id === accountId) : this.accounts.find((a) => a.provider === provider);
|
|
254
|
+
if (!match) return false;
|
|
255
|
+
this.activeProvider = provider;
|
|
256
|
+
this.activeAccountId = match.id;
|
|
257
|
+
this.fire();
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
/** Currently active provider — `null` when no session is active. */
|
|
261
|
+
getActiveProvider() {
|
|
262
|
+
return this.activeProvider;
|
|
263
|
+
}
|
|
264
|
+
/** Currently active account metadata — `null` when none. */
|
|
265
|
+
getActiveAccount() {
|
|
266
|
+
if (!this.activeProvider || !this.activeAccountId) return null;
|
|
267
|
+
return this.accounts.find((a) => a.provider === this.activeProvider && a.id === this.activeAccountId) ?? null;
|
|
268
|
+
}
|
|
269
|
+
/** True when at least one provider has an account entry. */
|
|
270
|
+
hasAnySession() {
|
|
271
|
+
return this.accounts.length > 0 && this.activeProvider !== null;
|
|
272
|
+
}
|
|
273
|
+
/** Snapshot of the state for `auth.status` and bridge serialization. */
|
|
274
|
+
getStatus() {
|
|
275
|
+
const grouped = {};
|
|
276
|
+
for (const account of this.accounts) grouped[account.provider] = account;
|
|
277
|
+
return {
|
|
278
|
+
activeProvider: this.activeProvider,
|
|
279
|
+
accounts: grouped,
|
|
280
|
+
hasAnySession: this.hasAnySession(),
|
|
281
|
+
lastError: this.lastError ?? void 0
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/** Record a non-fatal error from the last login/refresh attempt. */
|
|
285
|
+
recordError(message) {
|
|
286
|
+
this.lastError = message;
|
|
287
|
+
this.fire();
|
|
288
|
+
}
|
|
289
|
+
/** Clear the recorded error (e.g. after a successful login). */
|
|
290
|
+
clearError() {
|
|
291
|
+
if (this.lastError === null) return;
|
|
292
|
+
this.lastError = null;
|
|
293
|
+
this.fire();
|
|
294
|
+
}
|
|
295
|
+
/** Drop every account — used by `auth.logout` with no provider. */
|
|
296
|
+
clearAll() {
|
|
297
|
+
this.accounts = [];
|
|
298
|
+
this.activeProvider = null;
|
|
299
|
+
this.activeAccountId = null;
|
|
300
|
+
this.lastError = null;
|
|
301
|
+
this.fire();
|
|
302
|
+
}
|
|
303
|
+
fire() {
|
|
304
|
+
this.emitter.emit("change");
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
//#endregion
|
|
308
|
+
//#region src/auth/ProviderRegistry.ts
|
|
309
|
+
var ProviderRegistry = class {
|
|
310
|
+
constructor() {
|
|
311
|
+
this.providers = /* @__PURE__ */ new Map();
|
|
312
|
+
}
|
|
313
|
+
/** Register or replace a provider implementation. */
|
|
314
|
+
register(provider) {
|
|
315
|
+
this.providers.set(provider.providerId, provider);
|
|
316
|
+
}
|
|
317
|
+
/** Look up a registered provider by id; throws when missing. */
|
|
318
|
+
get(providerId) {
|
|
319
|
+
const p = this.providers.get(providerId);
|
|
320
|
+
if (!p) throw new Error(`Auth provider not registered: ${providerId}`);
|
|
321
|
+
return p;
|
|
322
|
+
}
|
|
323
|
+
/** Non-throwing lookup; returns `undefined` when the provider is unknown. */
|
|
324
|
+
tryGet(providerId) {
|
|
325
|
+
return this.providers.get(providerId);
|
|
326
|
+
}
|
|
327
|
+
/** Return all registered provider ids — used by `auth.status` and bridge capability hints. */
|
|
328
|
+
list() {
|
|
329
|
+
return [...this.providers.keys()];
|
|
330
|
+
}
|
|
331
|
+
/** True when a provider has been registered for this id. */
|
|
332
|
+
has(providerId) {
|
|
333
|
+
return this.providers.has(providerId);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
//#endregion
|
|
337
|
+
//#region src/auth/AuthCore.ts
|
|
338
|
+
var AuthCore = class {
|
|
339
|
+
constructor(opts) {
|
|
340
|
+
this.registry = new ProviderRegistry();
|
|
341
|
+
for (const provider of opts.providers) this.registry.register(provider);
|
|
342
|
+
this.state = opts.stateManager ?? new AuthStateManager();
|
|
343
|
+
this.tokenStore = opts.tokenStore;
|
|
344
|
+
this.accessControl = opts.accessControl;
|
|
345
|
+
this.logger = opts.logger ?? noopLogger;
|
|
346
|
+
}
|
|
347
|
+
/** Snapshot of every account, the active provider, and the last error. */
|
|
348
|
+
status() {
|
|
349
|
+
return this.state.getStatus();
|
|
350
|
+
}
|
|
351
|
+
/** List accounts (immutable copy). */
|
|
352
|
+
listAccounts() {
|
|
353
|
+
return this.state.listAccounts();
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Drive the device-flow login for `provider`.
|
|
357
|
+
*
|
|
358
|
+
* Sequence:
|
|
359
|
+
* 1. Ask the provider for the user code + verification URL.
|
|
360
|
+
* 2. Surface the code to the user (via `ui`).
|
|
361
|
+
* 3. Poll until the user authorizes (or `shouldContinue` aborts).
|
|
362
|
+
* 4. Persist the token via `KeychainAuthTokenStore.set()`.
|
|
363
|
+
* 5. Insert the resulting `AuthAccountMeta` into state and mark active.
|
|
364
|
+
*/
|
|
365
|
+
async login(provider, ui, shouldContinue) {
|
|
366
|
+
const providerImpl = this.registry.get(provider);
|
|
367
|
+
this.logger.info("[AuthCore] Starting device-flow login", { provider });
|
|
368
|
+
try {
|
|
369
|
+
const initial = await providerImpl.requestDeviceFlow();
|
|
370
|
+
await ui(initial);
|
|
371
|
+
if (!initial.deviceCode) {
|
|
372
|
+
this.logger.error("[AuthCore] Provider did not return deviceCode", provider);
|
|
373
|
+
throw new Error("Auth provider did not return deviceCode for device flow completion");
|
|
374
|
+
}
|
|
375
|
+
const session = await providerImpl.completeDeviceFlow(initial.deviceCode, shouldContinue, initial.pollIntervalMs);
|
|
376
|
+
const result = await this.persistSession(providerImpl, session);
|
|
377
|
+
this.logger.info("[AuthCore] Device-flow login completed", { provider });
|
|
378
|
+
return result;
|
|
379
|
+
} catch (err) {
|
|
380
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
381
|
+
this.logger.error("[AuthCore] Device-flow login failed", message, { provider });
|
|
382
|
+
this.state.recordError(message);
|
|
383
|
+
throw err;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Complete the device-flow handshake given a pre-fetched user code.
|
|
388
|
+
* Useful when the caller (Phase 5.3 CLI) wants to fetch the code,
|
|
389
|
+
* print the URL, and then poll on a subsequent invocation.
|
|
390
|
+
*/
|
|
391
|
+
async completeLogin(provider, deviceCode, shouldContinue) {
|
|
392
|
+
const providerImpl = this.registry.get(provider);
|
|
393
|
+
try {
|
|
394
|
+
const session = await providerImpl.completeDeviceFlow(deviceCode, shouldContinue);
|
|
395
|
+
return await this.persistSession(providerImpl, session);
|
|
396
|
+
} catch (err) {
|
|
397
|
+
this.state.recordError(err instanceof Error ? err.message : String(err));
|
|
398
|
+
throw err;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/** Resolve the provider + token for the active session; returns null when no session is active. */
|
|
402
|
+
async resolveActiveToken() {
|
|
403
|
+
const provider = this.state.getActiveProvider();
|
|
404
|
+
if (!provider) return null;
|
|
405
|
+
const account = this.state.getActiveAccount();
|
|
406
|
+
if (!account) return null;
|
|
407
|
+
const envelope = await this.tokenStore.get({
|
|
408
|
+
provider,
|
|
409
|
+
accountId: account.id
|
|
410
|
+
});
|
|
411
|
+
if (!envelope) return null;
|
|
412
|
+
return {
|
|
413
|
+
provider,
|
|
414
|
+
account,
|
|
415
|
+
token: envelope.token
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Logout: removes the token bytes from the keychain + drops the
|
|
420
|
+
* account entry from state. When `provider` is omitted, clears
|
|
421
|
+
* every account.
|
|
422
|
+
*/
|
|
423
|
+
async logout(provider) {
|
|
424
|
+
this.logger.info("[AuthCore] Logout requested", { provider: provider ?? "all" });
|
|
425
|
+
if (!provider) {
|
|
426
|
+
for (const account of this.state.listAccounts()) {
|
|
427
|
+
await this.tokenStore.delete({
|
|
428
|
+
provider: account.provider,
|
|
429
|
+
accountId: account.id
|
|
430
|
+
});
|
|
431
|
+
this.state.removeAccount(account.provider, account.id);
|
|
432
|
+
}
|
|
433
|
+
this.state.clearAll();
|
|
434
|
+
return {
|
|
435
|
+
provider: null,
|
|
436
|
+
success: true
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
const accountsForProvider = this.state.listAccounts().filter((a) => a.provider === provider);
|
|
440
|
+
let removed = false;
|
|
441
|
+
for (const account of accountsForProvider) {
|
|
442
|
+
await this.tokenStore.delete({
|
|
443
|
+
provider,
|
|
444
|
+
accountId: account.id
|
|
445
|
+
});
|
|
446
|
+
const didRemove = this.state.removeAccount(provider, account.id);
|
|
447
|
+
removed = removed || didRemove;
|
|
448
|
+
}
|
|
449
|
+
if (this.state.getActiveProvider() === provider) {
|
|
450
|
+
const firstRemaining = this.state.listAccounts()[0];
|
|
451
|
+
if (firstRemaining) this.state.setActive(firstRemaining.provider);
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
provider,
|
|
455
|
+
success: removed
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/** Minimal identity for `auth.whoami`. */
|
|
459
|
+
async whoami() {
|
|
460
|
+
const provider = this.state.getActiveProvider();
|
|
461
|
+
const account = this.state.getActiveAccount();
|
|
462
|
+
if (!provider || !account) return { provider: null };
|
|
463
|
+
return {
|
|
464
|
+
provider,
|
|
465
|
+
login: account.login,
|
|
466
|
+
name: account.displayName,
|
|
467
|
+
avatarUrl: account.avatarUrl,
|
|
468
|
+
email: account.email
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
/** Switch the active provider. Returns the resulting active account. */
|
|
472
|
+
switchProvider(provider, accountId) {
|
|
473
|
+
const ok = this.state.setActive(provider, accountId);
|
|
474
|
+
const account = this.state.getActiveAccount();
|
|
475
|
+
return {
|
|
476
|
+
activeProvider: ok ? provider : this.state.getActiveProvider(),
|
|
477
|
+
account
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
/** Run the access-control check against the active account (optional, requires AccessControl). */
|
|
481
|
+
async checkAccess() {
|
|
482
|
+
if (!this.accessControl) return null;
|
|
483
|
+
const account = this.state.getActiveAccount();
|
|
484
|
+
return this.accessControl.checkAccess(account);
|
|
485
|
+
}
|
|
486
|
+
/** Expose the state manager for test inspection (not for mutation). */
|
|
487
|
+
getStateManager() {
|
|
488
|
+
return this.state;
|
|
489
|
+
}
|
|
490
|
+
/** Expose the provider registry for test inspection. */
|
|
491
|
+
getProviderRegistry() {
|
|
492
|
+
return this.registry;
|
|
493
|
+
}
|
|
494
|
+
/** Expose the access control (or undefined when not configured). */
|
|
495
|
+
getAccessControl() {
|
|
496
|
+
return this.accessControl;
|
|
497
|
+
}
|
|
498
|
+
async persistSession(providerImpl, session) {
|
|
499
|
+
const expiresAt = session.expiresIn ? Date.now() + session.expiresIn * 1e3 : null;
|
|
500
|
+
const meta = await providerImpl.fetchAccountMeta(session.token);
|
|
501
|
+
const accountMeta = {
|
|
502
|
+
id: meta.id,
|
|
503
|
+
provider: meta.provider,
|
|
504
|
+
displayName: meta.displayName,
|
|
505
|
+
login: meta.login,
|
|
506
|
+
email: meta.email,
|
|
507
|
+
avatarUrl: meta.avatarUrl,
|
|
508
|
+
expiresAt: meta.expiresAt ?? expiresAt
|
|
509
|
+
};
|
|
510
|
+
this.logger.debug("[AuthCore] Persisting session", {
|
|
511
|
+
provider: meta.provider,
|
|
512
|
+
accountId: meta.id
|
|
513
|
+
});
|
|
514
|
+
await this.tokenStore.set({
|
|
515
|
+
provider: meta.provider,
|
|
516
|
+
accountId: meta.id
|
|
517
|
+
}, session.token, { expiresAt });
|
|
518
|
+
this.state.upsertAccount(accountMeta);
|
|
519
|
+
this.state.setActive(meta.provider, meta.id);
|
|
520
|
+
this.state.clearError();
|
|
521
|
+
return {
|
|
522
|
+
provider: meta.provider,
|
|
523
|
+
message: `Logged in as ${meta.login ?? meta.id}`
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
//#endregion
|
|
528
|
+
//#region src/auth/KeychainAuthTokenStore.ts
|
|
529
|
+
/**
|
|
530
|
+
* Sentinel error thrown by `KeychainAuthTokenStore.get()` / `.set()`
|
|
531
|
+
* when the underlying keychain is unavailable. Callers map this to
|
|
532
|
+
* `AUTH_KEYRING_UNAVAILABLE` (Phase 5.3 error code).
|
|
533
|
+
*/
|
|
534
|
+
var KeychainUnavailableError = class extends Error {
|
|
535
|
+
constructor(message, cause) {
|
|
536
|
+
super(message, cause !== void 0 ? { cause } : void 0);
|
|
537
|
+
this.name = "KeychainUnavailableError";
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
/**
|
|
541
|
+
* In-memory `KeychainAuthTokenStore` — test/dev fallback. NEVER use
|
|
542
|
+
* in production: tokens live in process memory and disappear on exit.
|
|
543
|
+
* Production wiring is `KeyringTokenStore` (CLI) / `SecretStorageTokenStore`
|
|
544
|
+
* (Extension).
|
|
545
|
+
*/
|
|
546
|
+
var InMemoryKeychainAuthTokenStore = class {
|
|
547
|
+
constructor() {
|
|
548
|
+
this.entries = /* @__PURE__ */ new Map();
|
|
549
|
+
}
|
|
550
|
+
compositeKey(key) {
|
|
551
|
+
return `${key.provider}:${key.accountId}`;
|
|
552
|
+
}
|
|
553
|
+
async set(key, token, opts) {
|
|
554
|
+
this.entries.set(this.compositeKey(key), {
|
|
555
|
+
token,
|
|
556
|
+
expiresAt: opts?.expiresAt ?? null,
|
|
557
|
+
storedAt: Date.now()
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
async get(key) {
|
|
561
|
+
const entry = this.entries.get(this.compositeKey(key));
|
|
562
|
+
if (!entry) return null;
|
|
563
|
+
return {
|
|
564
|
+
token: entry.token,
|
|
565
|
+
metadata: {
|
|
566
|
+
provider: key.provider,
|
|
567
|
+
accountId: key.accountId,
|
|
568
|
+
expiresAt: entry.expiresAt,
|
|
569
|
+
storedAt: entry.storedAt
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
async delete(key) {
|
|
574
|
+
this.entries.delete(this.compositeKey(key));
|
|
575
|
+
}
|
|
576
|
+
async list() {
|
|
577
|
+
const out = [];
|
|
578
|
+
for (const composite of this.entries.keys()) {
|
|
579
|
+
const sepIdx = composite.indexOf(":");
|
|
580
|
+
if (sepIdx <= 0) continue;
|
|
581
|
+
out.push({
|
|
582
|
+
provider: composite.slice(0, sepIdx),
|
|
583
|
+
accountId: composite.slice(sepIdx + 1)
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
return out;
|
|
587
|
+
}
|
|
588
|
+
async isAvailable() {
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
//#endregion
|
|
593
|
+
//#region src/auth/utils/githubUserEmail.ts
|
|
594
|
+
/**
|
|
595
|
+
* AuthCore — Pure local utilities for GitHub local email handling.
|
|
596
|
+
*
|
|
597
|
+
* Copied verbatim from `@serviceme/devtools-shared/src/github-user-email.ts` (15 LOC)
|
|
598
|
+
* because ADL-003 forbids Core from depending on `@serviceme/devtools-shared`. These
|
|
599
|
+
* helpers are pure functions with zero side effects, so the duplication is
|
|
600
|
+
* trivial to keep in sync.
|
|
601
|
+
*
|
|
602
|
+
* Refs:
|
|
603
|
+
* - 4.功能规划.md §2.1 — "utils/githubUserEmail.ts 纯函数,直接搬"
|
|
604
|
+
* - ADL-003 — `@serviceme/devtools-shared` boundary decision
|
|
605
|
+
*/
|
|
606
|
+
/**
|
|
607
|
+
* Returns true when the supplied address is a synthetic GitHub "local" email
|
|
608
|
+
* (suffixed with `@github.local`). Real GitHub OAuth clients often return
|
|
609
|
+
* `null` for `email` (user kept it private) and the application substitutes
|
|
610
|
+
* `<login>@github.local` to keep the field non-null.
|
|
611
|
+
*/
|
|
612
|
+
function isGitHubLocalEmail(email) {
|
|
613
|
+
return typeof email === "string" && email.trim().toLowerCase().endsWith("@github.local");
|
|
614
|
+
}
|
|
615
|
+
/** Build a synthetic `<login>@github.local` address. */
|
|
616
|
+
function buildGitHubLocalEmail(login) {
|
|
617
|
+
return `${login}@github.local`;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Resolve the user's primary email address — falling back to the synthetic
|
|
621
|
+
* `<login>@github.local` form when the upstream email is missing or blank.
|
|
622
|
+
*/
|
|
623
|
+
function resolvePrimaryEmail(login, email) {
|
|
624
|
+
return email?.trim() || buildGitHubLocalEmail(login);
|
|
625
|
+
}
|
|
626
|
+
//#endregion
|
|
627
|
+
//#region src/auth/providers/GitHubAuthProvider.ts
|
|
628
|
+
const DEFAULT_DEVICE_CODE_URL = "https://github.com/login/device/code";
|
|
629
|
+
const DEFAULT_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
630
|
+
const DEFAULT_USER_URL = "https://api.github.com/user";
|
|
631
|
+
const DEFAULT_SCOPE = "read:user user:email";
|
|
632
|
+
/** Attempts for the one-shot device-code request before surfacing a network error. */
|
|
633
|
+
const DEVICE_CODE_MAX_ATTEMPTS = 3;
|
|
634
|
+
const DEFAULT_DEVICE_CODE_RETRY_BASE_DELAY_MS = 500;
|
|
635
|
+
/**
|
|
636
|
+
* `fetch` (both browser and Node's undici) rejects with a `TypeError`
|
|
637
|
+
* (e.g. "fetch failed") when the underlying connection never completes —
|
|
638
|
+
* DNS failure, TLS handshake reset, ECONNRESET, etc. These are transient
|
|
639
|
+
* network hiccups, distinct from a well-formed non-2xx HTTP response, and
|
|
640
|
+
* should be retried rather than immediately failing the whole login.
|
|
641
|
+
*/
|
|
642
|
+
function isTransientNetworkError(error) {
|
|
643
|
+
return error instanceof TypeError;
|
|
644
|
+
}
|
|
645
|
+
var GitHubAuthProvider = class {
|
|
646
|
+
constructor(config) {
|
|
647
|
+
this.providerId = "github";
|
|
648
|
+
if (!config.clientId || config.clientId.length === 0) throw new Error("GitHubAuthProvider requires a non-empty `clientId`");
|
|
649
|
+
this.cfg = {
|
|
650
|
+
clientId: config.clientId,
|
|
651
|
+
deviceCodeUrl: config.deviceCodeUrl ?? DEFAULT_DEVICE_CODE_URL,
|
|
652
|
+
tokenUrl: config.tokenUrl ?? DEFAULT_TOKEN_URL,
|
|
653
|
+
userUrl: config.userUrl ?? DEFAULT_USER_URL,
|
|
654
|
+
scope: config.scope ?? DEFAULT_SCOPE,
|
|
655
|
+
minPollIntervalMs: config.minPollIntervalMs ?? 1e3,
|
|
656
|
+
maxPollIntervalMs: config.maxPollIntervalMs ?? 6e4,
|
|
657
|
+
maxWaitMs: config.maxWaitMs,
|
|
658
|
+
fetchImpl: config.fetchImpl ?? fetch,
|
|
659
|
+
deviceCodeRetryBaseDelayMs: config.deviceCodeRetryBaseDelayMs ?? DEFAULT_DEVICE_CODE_RETRY_BASE_DELAY_MS
|
|
660
|
+
};
|
|
661
|
+
this.logger = config.logger ?? noopLogger;
|
|
662
|
+
this.sleepImpl = config.sleepImpl ?? sleep;
|
|
663
|
+
}
|
|
664
|
+
async requestDeviceFlow(opts) {
|
|
665
|
+
const body = JSON.stringify({
|
|
666
|
+
client_id: this.cfg.clientId,
|
|
667
|
+
scope: opts?.scope ?? this.cfg.scope
|
|
668
|
+
});
|
|
669
|
+
let lastNetworkError;
|
|
670
|
+
for (let attempt = 1; attempt <= DEVICE_CODE_MAX_ATTEMPTS; attempt++) {
|
|
671
|
+
this.logger.debug("[GitHubAuthProvider] Requesting device code", {
|
|
672
|
+
attempt,
|
|
673
|
+
maxAttempts: DEVICE_CODE_MAX_ATTEMPTS
|
|
674
|
+
});
|
|
675
|
+
let resp;
|
|
676
|
+
try {
|
|
677
|
+
resp = await this.cfg.fetchImpl(this.cfg.deviceCodeUrl, {
|
|
678
|
+
method: "POST",
|
|
679
|
+
headers: {
|
|
680
|
+
Accept: "application/json",
|
|
681
|
+
"Content-Type": "application/json",
|
|
682
|
+
"User-Agent": "serviceme-core"
|
|
683
|
+
},
|
|
684
|
+
body
|
|
685
|
+
});
|
|
686
|
+
} catch (error) {
|
|
687
|
+
if (!isTransientNetworkError(error) || attempt === DEVICE_CODE_MAX_ATTEMPTS) {
|
|
688
|
+
this.logger.error("[GitHubAuthProvider] Device-code request failed (non-retryable)", error instanceof Error ? error.message : String(error), { attempt });
|
|
689
|
+
throw error;
|
|
690
|
+
}
|
|
691
|
+
const delayMs = this.cfg.deviceCodeRetryBaseDelayMs * 2 ** (attempt - 1);
|
|
692
|
+
this.logger.warn("[GitHubAuthProvider] Transient network error requesting device code, retrying", {
|
|
693
|
+
attempt,
|
|
694
|
+
delayMs,
|
|
695
|
+
error: error instanceof Error ? error.message : String(error)
|
|
696
|
+
});
|
|
697
|
+
lastNetworkError = error;
|
|
698
|
+
await this.sleepImpl(delayMs);
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
if (!resp.ok) {
|
|
702
|
+
this.logger.error("[GitHubAuthProvider] Device-code request rejected by GitHub", `HTTP ${resp.status}`);
|
|
703
|
+
throw new Error(`GitHub device-code request failed: HTTP ${resp.status}`);
|
|
704
|
+
}
|
|
705
|
+
const data = await resp.json();
|
|
706
|
+
if (!data.device_code || !data.user_code || !data.verification_uri) {
|
|
707
|
+
this.logger.error("[GitHubAuthProvider] Device-code response missing required fields", JSON.stringify(Object.keys(data)));
|
|
708
|
+
throw new Error("GitHub device-code response missing required fields");
|
|
709
|
+
}
|
|
710
|
+
this.logger.info("[GitHubAuthProvider] Device code obtained", {
|
|
711
|
+
userCode: data.user_code,
|
|
712
|
+
verificationUri: data.verification_uri,
|
|
713
|
+
expiresInSec: data.expires_in,
|
|
714
|
+
pollIntervalSec: data.interval
|
|
715
|
+
});
|
|
716
|
+
return {
|
|
717
|
+
provider: this.providerId,
|
|
718
|
+
deviceCode: data.device_code,
|
|
719
|
+
userCode: data.user_code,
|
|
720
|
+
verificationUrl: data.verification_uri,
|
|
721
|
+
expiresAt: Date.now() + data.expires_in * 1e3,
|
|
722
|
+
pollIntervalMs: data.interval * 1e3,
|
|
723
|
+
message: `Open ${data.verification_uri} and enter ${data.user_code}`
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
throw lastNetworkError ?? /* @__PURE__ */ new Error("GitHub device-code request failed");
|
|
727
|
+
}
|
|
728
|
+
async completeDeviceFlow(deviceCode, shouldContinue, initialPollIntervalMs) {
|
|
729
|
+
const start = Date.now();
|
|
730
|
+
let pollIntervalMs = Math.max(this.cfg.minPollIntervalMs, initialPollIntervalMs ?? 0);
|
|
731
|
+
let consecutiveSlowDown = 0;
|
|
732
|
+
let pollCount = 0;
|
|
733
|
+
this.logger.info("[GitHubAuthProvider] Starting device-flow polling", {
|
|
734
|
+
initialPollIntervalMs: pollIntervalMs,
|
|
735
|
+
maxWaitMs: this.cfg.maxWaitMs
|
|
736
|
+
});
|
|
737
|
+
while (true) {
|
|
738
|
+
if (shouldContinue && !shouldContinue()) {
|
|
739
|
+
this.logger.info("[GitHubAuthProvider] Device flow cancelled by caller", {
|
|
740
|
+
elapsedMs: Date.now() - start,
|
|
741
|
+
pollCount
|
|
742
|
+
});
|
|
743
|
+
throw new Error("GitHub device flow cancelled by caller");
|
|
744
|
+
}
|
|
745
|
+
const elapsed = Date.now() - start;
|
|
746
|
+
if (this.cfg.maxWaitMs !== void 0 && elapsed > this.cfg.maxWaitMs) {
|
|
747
|
+
this.logger.warn("[GitHubAuthProvider] Device flow exceeded max wait time", {
|
|
748
|
+
elapsedMs: elapsed,
|
|
749
|
+
maxWaitMs: this.cfg.maxWaitMs,
|
|
750
|
+
pollCount
|
|
751
|
+
});
|
|
752
|
+
throw new Error("GitHub device flow exceeded max wait time");
|
|
753
|
+
}
|
|
754
|
+
await this.sleepImpl(pollIntervalMs);
|
|
755
|
+
if (shouldContinue && !shouldContinue()) {
|
|
756
|
+
this.logger.info("[GitHubAuthProvider] Device flow cancelled by caller", {
|
|
757
|
+
elapsedMs: Date.now() - start,
|
|
758
|
+
pollCount
|
|
759
|
+
});
|
|
760
|
+
throw new Error("GitHub device flow cancelled by caller");
|
|
761
|
+
}
|
|
762
|
+
pollCount++;
|
|
763
|
+
this.logger.debug("[GitHubAuthProvider] Polling for authorization", {
|
|
764
|
+
pollCount,
|
|
765
|
+
elapsedMs: Date.now() - start,
|
|
766
|
+
pollIntervalMs
|
|
767
|
+
});
|
|
768
|
+
let resp;
|
|
769
|
+
try {
|
|
770
|
+
resp = await this.cfg.fetchImpl(this.cfg.tokenUrl, {
|
|
771
|
+
method: "POST",
|
|
772
|
+
headers: {
|
|
773
|
+
Accept: "application/json",
|
|
774
|
+
"Content-Type": "application/json",
|
|
775
|
+
"User-Agent": "serviceme-core"
|
|
776
|
+
},
|
|
777
|
+
body: JSON.stringify({
|
|
778
|
+
client_id: this.cfg.clientId,
|
|
779
|
+
device_code: deviceCode,
|
|
780
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
781
|
+
})
|
|
782
|
+
});
|
|
783
|
+
} catch (error) {
|
|
784
|
+
if (!isTransientNetworkError(error)) {
|
|
785
|
+
this.logger.error("[GitHubAuthProvider] Non-retryable error while polling token endpoint", error instanceof Error ? error.message : String(error), { pollCount });
|
|
786
|
+
throw error;
|
|
787
|
+
}
|
|
788
|
+
const nextPollIntervalMs = Math.min(Math.round(pollIntervalMs * 1.5), this.cfg.maxPollIntervalMs);
|
|
789
|
+
this.logger.warn("[GitHubAuthProvider] Transient network error while polling, backing off", {
|
|
790
|
+
pollCount,
|
|
791
|
+
error: error instanceof Error ? error.message : String(error),
|
|
792
|
+
previousPollIntervalMs: pollIntervalMs,
|
|
793
|
+
nextPollIntervalMs
|
|
794
|
+
});
|
|
795
|
+
pollIntervalMs = nextPollIntervalMs;
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
if (!resp.ok) {
|
|
799
|
+
const nextPollIntervalMs = Math.min(Math.round(pollIntervalMs * 1.5), this.cfg.maxPollIntervalMs);
|
|
800
|
+
this.logger.warn("[GitHubAuthProvider] Token endpoint returned non-OK status, backing off", {
|
|
801
|
+
pollCount,
|
|
802
|
+
status: resp.status,
|
|
803
|
+
previousPollIntervalMs: pollIntervalMs,
|
|
804
|
+
nextPollIntervalMs
|
|
805
|
+
});
|
|
806
|
+
pollIntervalMs = nextPollIntervalMs;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
const data = await resp.json();
|
|
810
|
+
if (data.access_token) {
|
|
811
|
+
this.logger.info("[GitHubAuthProvider] Authorization granted, fetching user profile", {
|
|
812
|
+
pollCount,
|
|
813
|
+
elapsedMs: Date.now() - start
|
|
814
|
+
});
|
|
815
|
+
const rawUser = await this.fetchGitHubUser(data.access_token);
|
|
816
|
+
const email = await this.resolveEmail(data.access_token, rawUser);
|
|
817
|
+
this.logger.info("[GitHubAuthProvider] Device-flow login completed", {
|
|
818
|
+
pollCount,
|
|
819
|
+
elapsedMs: Date.now() - start,
|
|
820
|
+
login: rawUser.login
|
|
821
|
+
});
|
|
822
|
+
return {
|
|
823
|
+
token: data.access_token,
|
|
824
|
+
refreshToken: data.refresh_token,
|
|
825
|
+
expiresIn: data.expires_in,
|
|
826
|
+
user: {
|
|
827
|
+
id: String(rawUser.id),
|
|
828
|
+
login: rawUser.login,
|
|
829
|
+
name: rawUser.name ?? void 0,
|
|
830
|
+
email: email ?? null,
|
|
831
|
+
avatarUrl: rawUser.avatar_url ?? void 0
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
if (data.error === "authorization_pending") {
|
|
836
|
+
this.logger.debug("[GitHubAuthProvider] Authorization still pending", {
|
|
837
|
+
pollCount,
|
|
838
|
+
elapsedMs: Date.now() - start
|
|
839
|
+
});
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
if (data.error === "slow_down") {
|
|
843
|
+
consecutiveSlowDown++;
|
|
844
|
+
const nextPollIntervalMs = Math.min(pollIntervalMs + 5e3, this.cfg.maxPollIntervalMs);
|
|
845
|
+
this.logger.warn("[GitHubAuthProvider] GitHub requested slower polling (slow_down)", {
|
|
846
|
+
pollCount,
|
|
847
|
+
consecutiveSlowDown,
|
|
848
|
+
previousPollIntervalMs: pollIntervalMs,
|
|
849
|
+
nextPollIntervalMs
|
|
850
|
+
});
|
|
851
|
+
pollIntervalMs = nextPollIntervalMs;
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
if (data.error === "access_denied") {
|
|
855
|
+
this.logger.warn("[GitHubAuthProvider] User denied authorization", { pollCount });
|
|
856
|
+
throw new Error("User denied authorization");
|
|
857
|
+
}
|
|
858
|
+
if (data.error === "expired_token") {
|
|
859
|
+
this.logger.warn("[GitHubAuthProvider] Device code expired before authorization completed", {
|
|
860
|
+
pollCount,
|
|
861
|
+
elapsedMs: Date.now() - start
|
|
862
|
+
});
|
|
863
|
+
throw new Error("GitHub device code expired — restart the flow");
|
|
864
|
+
}
|
|
865
|
+
this.logger.error("[GitHubAuthProvider] Unexpected device-flow error from token endpoint", data.error ?? "unknown", { pollCount });
|
|
866
|
+
throw new Error(`GitHub device flow error: ${data.error ?? "unknown"}`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
async refreshAccessToken(refreshToken) {
|
|
870
|
+
if (!refreshToken) throw new Error("refreshAccessToken requires a non-empty refresh token");
|
|
871
|
+
const resp = await this.cfg.fetchImpl(this.cfg.tokenUrl, {
|
|
872
|
+
method: "POST",
|
|
873
|
+
headers: {
|
|
874
|
+
Accept: "application/json",
|
|
875
|
+
"Content-Type": "application/json",
|
|
876
|
+
"User-Agent": "serviceme-core"
|
|
877
|
+
},
|
|
878
|
+
body: JSON.stringify({
|
|
879
|
+
client_id: this.cfg.clientId,
|
|
880
|
+
grant_type: "refresh_token",
|
|
881
|
+
refresh_token: refreshToken
|
|
882
|
+
})
|
|
883
|
+
});
|
|
884
|
+
if (!resp.ok) throw new Error(`GitHub refresh failed: HTTP ${resp.status}`);
|
|
885
|
+
const data = await resp.json();
|
|
886
|
+
if (!data.access_token) throw new Error(`GitHub refresh failed: ${data.error ?? "no_access_token"}`);
|
|
887
|
+
const rawUser = await this.fetchGitHubUser(data.access_token);
|
|
888
|
+
const email = await this.resolveEmail(data.access_token, rawUser);
|
|
889
|
+
return {
|
|
890
|
+
token: data.access_token,
|
|
891
|
+
refreshToken: data.refresh_token ?? refreshToken,
|
|
892
|
+
expiresIn: data.expires_in,
|
|
893
|
+
user: {
|
|
894
|
+
id: String(rawUser.id),
|
|
895
|
+
login: rawUser.login,
|
|
896
|
+
name: rawUser.name ?? void 0,
|
|
897
|
+
email: email ?? null,
|
|
898
|
+
avatarUrl: rawUser.avatar_url ?? void 0
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
async validateToken(token) {
|
|
903
|
+
try {
|
|
904
|
+
const resp = await this.cfg.fetchImpl(this.cfg.userUrl, { headers: {
|
|
905
|
+
Accept: "application/vnd.github+json",
|
|
906
|
+
Authorization: `Bearer ${token}`,
|
|
907
|
+
"User-Agent": "serviceme-core"
|
|
908
|
+
} });
|
|
909
|
+
if (resp.status === 401) return false;
|
|
910
|
+
if (!resp.ok) throw new Error(`GitHub /user returned HTTP ${resp.status}`);
|
|
911
|
+
return true;
|
|
912
|
+
} catch (err) {
|
|
913
|
+
if (err instanceof Error && err.message.includes("401")) return false;
|
|
914
|
+
throw err;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
async fetchAccountMeta(token) {
|
|
918
|
+
const user = await this.fetchGitHubUser(token);
|
|
919
|
+
const email = await this.resolveEmail(token, user);
|
|
920
|
+
return {
|
|
921
|
+
id: String(user.id),
|
|
922
|
+
provider: this.providerId,
|
|
923
|
+
displayName: user.name ?? user.login,
|
|
924
|
+
login: user.login,
|
|
925
|
+
email: email ?? buildGitHubLocalEmail(user.login),
|
|
926
|
+
avatarUrl: user.avatar_url ?? void 0,
|
|
927
|
+
expiresAt: null
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
async fetchGitHubUser(token) {
|
|
931
|
+
const resp = await this.cfg.fetchImpl(this.cfg.userUrl, { headers: {
|
|
932
|
+
Accept: "application/vnd.github+json",
|
|
933
|
+
Authorization: `Bearer ${token}`,
|
|
934
|
+
"User-Agent": "serviceme-core"
|
|
935
|
+
} });
|
|
936
|
+
if (!resp.ok) throw new Error(`GitHub /user failed: HTTP ${resp.status}`);
|
|
937
|
+
return await resp.json();
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* GitHub's `/user` endpoint returns `null` when the user kept their
|
|
941
|
+
* email private. The `/user/emails` endpoint reveals verified emails;
|
|
942
|
+
* we pick the primary one, falling back to the synthetic
|
|
943
|
+
* `<login>@github.local` form so the account is never email-less.
|
|
944
|
+
*/
|
|
945
|
+
async resolveEmail(token, user) {
|
|
946
|
+
if (user.email) return user.email;
|
|
947
|
+
const emailsResp = await this.cfg.fetchImpl("https://api.github.com/user/emails", { headers: {
|
|
948
|
+
Accept: "application/vnd.github+json",
|
|
949
|
+
Authorization: `Bearer ${token}`,
|
|
950
|
+
"User-Agent": "serviceme-core"
|
|
951
|
+
} });
|
|
952
|
+
if (!emailsResp.ok) return null;
|
|
953
|
+
return (await emailsResp.json()).find((e) => e.primary && e.verified)?.email ?? null;
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
function sleep(ms) {
|
|
957
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
958
|
+
}
|
|
959
|
+
//#endregion
|
|
960
|
+
//#region src/auth/providers/MicrosoftAuthProvider.ts
|
|
961
|
+
/**
|
|
962
|
+
* Error thrown when callers invoke Microsoft auth from a non-Extension
|
|
963
|
+
* runtime (CLI / Bridge). The CLI maps this to `AUTH_PROVIDER_REQUIRES_HOST`
|
|
964
|
+
* so the user is prompted to retry inside VSCode.
|
|
965
|
+
*/
|
|
966
|
+
var MicrosoftProviderNotHostedError = class extends Error {
|
|
967
|
+
constructor(message = "Microsoft auth requires the VSCode host; CLI does not yet implement MSA device flow") {
|
|
968
|
+
super(message);
|
|
969
|
+
this.name = "MicrosoftProviderNotHostedError";
|
|
970
|
+
}
|
|
971
|
+
};
|
|
972
|
+
var MicrosoftAuthProvider = class {
|
|
973
|
+
constructor() {
|
|
974
|
+
this.providerId = "microsoft";
|
|
975
|
+
}
|
|
976
|
+
async requestDeviceFlow() {
|
|
977
|
+
throw new MicrosoftProviderNotHostedError();
|
|
978
|
+
}
|
|
979
|
+
async completeDeviceFlow(_deviceCode, _shouldContinue) {
|
|
980
|
+
throw new MicrosoftProviderNotHostedError();
|
|
981
|
+
}
|
|
982
|
+
async refreshAccessToken(_refreshToken) {
|
|
983
|
+
throw new MicrosoftProviderNotHostedError();
|
|
984
|
+
}
|
|
985
|
+
async validateToken(_token) {
|
|
986
|
+
throw new MicrosoftProviderNotHostedError();
|
|
987
|
+
}
|
|
988
|
+
async fetchAccountMeta(_token) {
|
|
989
|
+
throw new MicrosoftProviderNotHostedError();
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
//#endregion
|
|
993
|
+
export { isGitHubLocalEmail as a, KeychainUnavailableError as c, AuthStateManager as d, createConsoleLogger as f, buildGitHubLocalEmail as i, AuthCore as l, AccessControl as m, MicrosoftProviderNotHostedError as n, resolvePrimaryEmail as o, noopLogger as p, GitHubAuthProvider as r, InMemoryKeychainAuthTokenStore as s, MicrosoftAuthProvider as t, ProviderRegistry as u };
|
|
994
|
+
|
|
995
|
+
//# sourceMappingURL=auth-Bzss8zOv.mjs.map
|