@cjhyy/code-shell-server 0.9.26 → 0.9.28
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/links/browser-handoff.d.ts +16 -0
- package/dist/links/browser-handoff.js +150 -0
- package/dist/links/http.d.ts +8 -2
- package/dist/links/http.js +63 -4
- package/dist/links/remote-catalog.d.ts +94 -0
- package/dist/links/remote-catalog.js +90 -0
- package/dist/links/service.d.ts +8 -1
- package/dist/links/service.js +558 -76
- package/dist/links/types.d.ts +1 -1
- package/dist/project-runtime/proxy.js +60 -4
- package/dist/serve/headless-server.js +28 -2
- package/package.json +6 -6
package/dist/links/service.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import { CredentialStore, beginRemoteLinkAuthorization, completeRemoteLinkAuthorization, revokeRemoteLinkAuthorization, isRemoteLinkCredential, connectCliLink, getCliLinkStatus, isCliLinkProvider, isCredentialSecretAvailable, summarizeOAuthCredentialSecret, validateLocalLinkToken, } from "@cjhyy/code-shell-core";
|
|
3
3
|
import { listDesktopLinkProviders } from "./catalog.js";
|
|
4
|
+
import { readRemoteProviderCapabilities, REMOTE_LINK_ADAPTERS, } from "./remote-catalog.js";
|
|
4
5
|
import { LinkDeviceOAuthBroker, isLocalBrowserLinkProvider, } from "./device-oauth.js";
|
|
5
6
|
const messages = {
|
|
6
7
|
invalid_request: "Link 请求参数无效。",
|
|
@@ -49,6 +50,9 @@ export function createLinkService(options = {}) {
|
|
|
49
50
|
const operations = new Set();
|
|
50
51
|
const jobs = new Map();
|
|
51
52
|
const remoteJobs = new Map();
|
|
53
|
+
const interactiveJobs = new Map();
|
|
54
|
+
let remoteCatalog;
|
|
55
|
+
let catalogPending;
|
|
52
56
|
const revokedOwners = new Set();
|
|
53
57
|
let closed = false;
|
|
54
58
|
let snapshotSignature = "";
|
|
@@ -148,11 +152,15 @@ export function createLinkService(options = {}) {
|
|
|
148
152
|
? summarizeOAuthCredentialSecret(credential.secret)
|
|
149
153
|
: undefined;
|
|
150
154
|
const remote = isRemoteLinkCredential(credential);
|
|
151
|
-
const status = remote && meta.linkRemoteState !== "connected"
|
|
155
|
+
const status = (remote && meta.linkRemoteState !== "connected") ||
|
|
156
|
+
(meta.linkAuthSource === "browser-oauth" &&
|
|
157
|
+
meta.linkOAuthState &&
|
|
158
|
+
meta.linkOAuthState !== "connected") ||
|
|
159
|
+
(meta.linkAuthSource === "browser-oauth" && meta.linkCapabilityIds?.length === 0)
|
|
152
160
|
? "unavailable"
|
|
153
161
|
: !isCredentialSecretAvailable(credential.secret)
|
|
154
162
|
? "unavailable"
|
|
155
|
-
: oauth?.state === "expired"
|
|
163
|
+
: oauth?.state === "expired" && !oauth.canRefresh
|
|
156
164
|
? "expired"
|
|
157
165
|
: oauth?.state === "invalid" || oauth?.state === "missing"
|
|
158
166
|
? "invalid"
|
|
@@ -193,17 +201,96 @@ export function createLinkService(options = {}) {
|
|
|
193
201
|
if (!ids.has(id))
|
|
194
202
|
revisions.delete(id);
|
|
195
203
|
const remoteConfig = options.remoteLink?.();
|
|
204
|
+
const remoteProviders = remoteConfig
|
|
205
|
+
? remoteCatalog?.issuer === remoteConfig.issuer
|
|
206
|
+
? remoteCatalog.providers
|
|
207
|
+
: []
|
|
208
|
+
: [];
|
|
196
209
|
const result = {
|
|
197
|
-
providers: providers.map((provider) =>
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
210
|
+
providers: providers.map((provider) => {
|
|
211
|
+
const deviceAuth = isLocalBrowserLinkProvider(provider.id)
|
|
212
|
+
? createBroker().status(provider.id)
|
|
213
|
+
: undefined;
|
|
214
|
+
const authModes = [];
|
|
215
|
+
for (const method of provider.connectionMethods) {
|
|
216
|
+
if (method.executionRuntime !== "local" || method.availability !== "available")
|
|
217
|
+
continue;
|
|
218
|
+
if (method.browserAuth?.flow === "device-code")
|
|
219
|
+
authModes.push({
|
|
220
|
+
id: "browser-oauth",
|
|
221
|
+
methodId: method.id,
|
|
222
|
+
kind: "device-code",
|
|
223
|
+
label: "网页授权",
|
|
224
|
+
available: !!deviceAuth?.configured,
|
|
225
|
+
preferred: !!deviceAuth?.configured,
|
|
226
|
+
...(!deviceAuth?.configured
|
|
227
|
+
? { unavailableReason: "当前 Host 未配置服务商授权。" }
|
|
228
|
+
: {}),
|
|
229
|
+
});
|
|
230
|
+
if (method.quickAuth)
|
|
231
|
+
authModes.push({
|
|
232
|
+
id: "cli-session",
|
|
233
|
+
methodId: method.id,
|
|
234
|
+
kind: "local-session",
|
|
235
|
+
label: "使用已有 CLI 登录",
|
|
236
|
+
available: true,
|
|
237
|
+
});
|
|
238
|
+
authModes.push({
|
|
239
|
+
id: "token",
|
|
240
|
+
methodId: method.id,
|
|
241
|
+
kind: "credential-input",
|
|
242
|
+
label: method.tokenLabel ?? provider.tokenLabel,
|
|
243
|
+
available: true,
|
|
244
|
+
preferred: !deviceAuth?.configured,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
const remote = REMOTE_LINK_ADAPTERS.find((adapter) => adapter.id === provider.id &&
|
|
248
|
+
remoteProviders.some((remote) => remote.id === adapter.id));
|
|
249
|
+
return {
|
|
250
|
+
...provider,
|
|
251
|
+
connectionMethods: [
|
|
252
|
+
...provider.connectionMethods,
|
|
253
|
+
...(remote
|
|
254
|
+
? [
|
|
255
|
+
{
|
|
256
|
+
id: remote.methodId,
|
|
257
|
+
displayName: { zh: "服务器授权", en: "Server authorization" },
|
|
258
|
+
executionRuntime: "server",
|
|
259
|
+
secretLocation: "server",
|
|
260
|
+
authKind: "oauth",
|
|
261
|
+
availability: "available",
|
|
262
|
+
},
|
|
263
|
+
]
|
|
264
|
+
: []),
|
|
265
|
+
],
|
|
266
|
+
authModes: [
|
|
267
|
+
...(remote
|
|
268
|
+
? [
|
|
269
|
+
{
|
|
270
|
+
id: remote.authModeId,
|
|
271
|
+
methodId: remote.methodId,
|
|
272
|
+
kind: "redirect",
|
|
273
|
+
label: "网页授权",
|
|
274
|
+
preferred: true,
|
|
275
|
+
available: true,
|
|
276
|
+
},
|
|
277
|
+
]
|
|
278
|
+
: []),
|
|
279
|
+
...authModes,
|
|
280
|
+
],
|
|
281
|
+
...(deviceAuth ? { deviceAuth } : {}),
|
|
282
|
+
};
|
|
283
|
+
}),
|
|
203
284
|
connections: current.credentials
|
|
204
285
|
.filter(isLink)
|
|
205
286
|
.map((credential) => masked(credential, current.projectIds.has(credential.id) ? "project" : "user")),
|
|
206
|
-
capabilities: {
|
|
287
|
+
capabilities: {
|
|
288
|
+
token: true,
|
|
289
|
+
cliBinding: true,
|
|
290
|
+
deviceAuth: true,
|
|
291
|
+
remoteAuth: remoteProviders.length > 0,
|
|
292
|
+
authorizationSteps: 1,
|
|
293
|
+
},
|
|
207
294
|
...(remoteConfig ? { remoteServer: { issuer: remoteConfig.issuer } } : {}),
|
|
208
295
|
remoteCleanupPending: store.remoteLinkRetirementCount(),
|
|
209
296
|
};
|
|
@@ -214,6 +301,41 @@ export function createLinkService(options = {}) {
|
|
|
214
301
|
}
|
|
215
302
|
return { ...result, revision: snapshotRevision };
|
|
216
303
|
}
|
|
304
|
+
async function refreshRemoteCatalog(context) {
|
|
305
|
+
await authorize(context);
|
|
306
|
+
const config = options.remoteLink?.();
|
|
307
|
+
if (!config || (remoteCatalog?.issuer === config.issuer && remoteCatalog.expiresAt > now()))
|
|
308
|
+
return;
|
|
309
|
+
if (!catalogPending) {
|
|
310
|
+
catalogPending = (async () => {
|
|
311
|
+
let available;
|
|
312
|
+
try {
|
|
313
|
+
available = options.readRemoteCatalog
|
|
314
|
+
? (await options.readRemoteCatalog(config)).flatMap((id) => {
|
|
315
|
+
const adapter = REMOTE_LINK_ADAPTERS.find((item) => item.id === id);
|
|
316
|
+
return adapter
|
|
317
|
+
? [{ id, actions: [...adapter.actions], scopes: [...adapter.scopes] }]
|
|
318
|
+
: [];
|
|
319
|
+
})
|
|
320
|
+
: await readRemoteProviderCapabilities(config);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// Preserve the reviewed v1 behavior during temporary service/network failures.
|
|
324
|
+
available = remoteCatalog?.issuer === config.issuer ? remoteCatalog.providers : [];
|
|
325
|
+
}
|
|
326
|
+
if (!closed && options.remoteLink?.()?.issuer === config.issuer)
|
|
327
|
+
remoteCatalog = {
|
|
328
|
+
issuer: config.issuer,
|
|
329
|
+
expiresAt: now() + 60_000,
|
|
330
|
+
providers: available,
|
|
331
|
+
};
|
|
332
|
+
})().finally(() => {
|
|
333
|
+
catalogPending = undefined;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
await catalogPending;
|
|
337
|
+
await authorize(context);
|
|
338
|
+
}
|
|
217
339
|
function string(value, max = 200) {
|
|
218
340
|
if (typeof value !== "string" ||
|
|
219
341
|
!value.trim() ||
|
|
@@ -286,7 +408,7 @@ export function createLinkService(options = {}) {
|
|
|
286
408
|
if (!store.compareAndSwap("user", prepared.id, prepared.expected, next, retirement))
|
|
287
409
|
throw new LinkServiceError(409, "conflict");
|
|
288
410
|
}
|
|
289
|
-
async function commit(context, prepared, validation, secret, authSource) {
|
|
411
|
+
async function commit(context, prepared, validation, secret, authSource, lifecycle = undefined) {
|
|
290
412
|
const credential = {
|
|
291
413
|
id: prepared.id,
|
|
292
414
|
type: "link",
|
|
@@ -308,8 +430,11 @@ export function createLinkService(options = {}) {
|
|
|
308
430
|
},
|
|
309
431
|
};
|
|
310
432
|
return change(context, () => {
|
|
433
|
+
lifecycle?.beforeCommit();
|
|
311
434
|
swap(prepared, credential);
|
|
312
|
-
|
|
435
|
+
const connection = masked(credential, "user");
|
|
436
|
+
lifecycle?.onCommitted(connection);
|
|
437
|
+
return connection;
|
|
313
438
|
});
|
|
314
439
|
}
|
|
315
440
|
function tokenSecret(token) {
|
|
@@ -459,6 +584,290 @@ export function createLinkService(options = {}) {
|
|
|
459
584
|
// Revocation must work during an active action; the host's credential observer aborts it.
|
|
460
585
|
await change(context, () => swap({ id: credential.id, expected: credential, input: {} }, null), true);
|
|
461
586
|
}
|
|
587
|
+
function terminal(value, state, extra = {}) {
|
|
588
|
+
const { step: _step, prompt: _prompt, redirect: _redirect, errorCode: _error, ...rest } = value;
|
|
589
|
+
return { ...rest, state, ...extra };
|
|
590
|
+
}
|
|
591
|
+
function endInteractive(job, code) {
|
|
592
|
+
if (job.public.state !== "pending")
|
|
593
|
+
return;
|
|
594
|
+
job.operation.controller.abort();
|
|
595
|
+
clearTimeout(job.timer);
|
|
596
|
+
finish(job.operation);
|
|
597
|
+
job.public = terminal(job.public, code === "cancelled" ? "cancelled" : "failed", {
|
|
598
|
+
errorCode: code,
|
|
599
|
+
});
|
|
600
|
+
job.expiresAt = now() + 5 * 60_000;
|
|
601
|
+
}
|
|
602
|
+
function pruneInteractive() {
|
|
603
|
+
for (const [id, job] of interactiveJobs) {
|
|
604
|
+
if (job.expiresAt > now())
|
|
605
|
+
continue;
|
|
606
|
+
if (job.public.state === "pending")
|
|
607
|
+
endInteractive(job, "authorization_expired");
|
|
608
|
+
else
|
|
609
|
+
interactiveJobs.delete(id);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
function assertInteractivePending(job) {
|
|
613
|
+
if (job.public.state !== "pending" || job.operation.controller.signal.aborted)
|
|
614
|
+
throw new LinkServiceError(409, "cancelled");
|
|
615
|
+
if (job.expiresAt <= now())
|
|
616
|
+
throw new LinkServiceError(409, "authorization_expired");
|
|
617
|
+
}
|
|
618
|
+
function cliStep(job, expiresAt, installed, validation, message) {
|
|
619
|
+
if (validation &&
|
|
620
|
+
(validation.providerId !== job.prepared.input.providerId ||
|
|
621
|
+
typeof validation.identity.externalAccountId !== "string" ||
|
|
622
|
+
!validation.identity.externalAccountId))
|
|
623
|
+
throw new LinkServiceError(422, "provider_rejected");
|
|
624
|
+
job.confirmedCliAccountId = validation?.identity.externalAccountId;
|
|
625
|
+
return {
|
|
626
|
+
id: randomUUID(),
|
|
627
|
+
kind: "local-session",
|
|
628
|
+
expiresAt,
|
|
629
|
+
session: {
|
|
630
|
+
installed,
|
|
631
|
+
authenticated: !!validation,
|
|
632
|
+
canLogin: options.allowCliLogin === true && installed,
|
|
633
|
+
canInstall: options.allowCliLogin === true,
|
|
634
|
+
...(validation ? { account: validation.identity.label } : {}),
|
|
635
|
+
...(message ? { message } : {}),
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
async function detectCliSession(job) {
|
|
640
|
+
const providerId = job.prepared.input.providerId;
|
|
641
|
+
const status = await cliStatus(providerId, {
|
|
642
|
+
cwd: options.cwd,
|
|
643
|
+
signal: job.operation.controller.signal,
|
|
644
|
+
});
|
|
645
|
+
await authorize(job.context);
|
|
646
|
+
assertInteractivePending(job);
|
|
647
|
+
let validation;
|
|
648
|
+
if (status.installed && status.authenticated) {
|
|
649
|
+
try {
|
|
650
|
+
validation = await bindCli(providerId, {
|
|
651
|
+
cwd: options.cwd,
|
|
652
|
+
signal: job.operation.controller.signal,
|
|
653
|
+
loginIfNeeded: false,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
// No identity has been confirmed; the user may log in or detect again.
|
|
658
|
+
}
|
|
659
|
+
await authorize(job.context);
|
|
660
|
+
assertInteractivePending(job);
|
|
661
|
+
}
|
|
662
|
+
return cliStep(job, job.public.expiresAt, status.installed, validation);
|
|
663
|
+
}
|
|
664
|
+
async function startAuthorization(context, input, rawAuthModeId) {
|
|
665
|
+
await authorize(context);
|
|
666
|
+
const authModeId = string(rawAuthModeId, 80);
|
|
667
|
+
if (authModeId === "remote-link")
|
|
668
|
+
await refreshRemoteCatalog(context);
|
|
669
|
+
const provider = snapshot().providers.find((candidate) => candidate.id === input?.providerId);
|
|
670
|
+
const mode = provider?.authModes?.find((candidate) => candidate.id === authModeId && candidate.methodId === input.methodId && candidate.available);
|
|
671
|
+
if (!mode)
|
|
672
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
673
|
+
if (mode.kind === "redirect") {
|
|
674
|
+
const result = await startRemoteAuth(context, input);
|
|
675
|
+
const job = remoteJobs.get(result.id);
|
|
676
|
+
job.public.methodId = input.methodId;
|
|
677
|
+
job.public.authModeId = authModeId;
|
|
678
|
+
job.public.expiresAt = result.redirect.expiresAt;
|
|
679
|
+
job.public.step = { id: randomUUID(), kind: "redirect", ...result.redirect };
|
|
680
|
+
return structuredClone(job.public);
|
|
681
|
+
}
|
|
682
|
+
if (mode.kind === "device-code")
|
|
683
|
+
return startDeviceAuth(context, input);
|
|
684
|
+
pruneInteractive();
|
|
685
|
+
if (interactiveJobs.size >= 32)
|
|
686
|
+
throw new LinkServiceError(409, "busy");
|
|
687
|
+
const prepared = prepare(input);
|
|
688
|
+
const operation = begin(context);
|
|
689
|
+
const expiresAt = now() + 10 * 60_000;
|
|
690
|
+
const base = { id: randomUUID(), expiresAt: new Date(expiresAt).toISOString() };
|
|
691
|
+
const step = mode.kind === "credential-input"
|
|
692
|
+
? {
|
|
693
|
+
...base,
|
|
694
|
+
kind: "credential-input",
|
|
695
|
+
purpose: "credential",
|
|
696
|
+
fields: [
|
|
697
|
+
{
|
|
698
|
+
id: "token",
|
|
699
|
+
label: provider.tokenLabel,
|
|
700
|
+
secret: true,
|
|
701
|
+
required: true,
|
|
702
|
+
placeholder: provider.tokenPlaceholder,
|
|
703
|
+
maxLength: 16_384,
|
|
704
|
+
},
|
|
705
|
+
],
|
|
706
|
+
}
|
|
707
|
+
: {
|
|
708
|
+
...base,
|
|
709
|
+
kind: "local-session",
|
|
710
|
+
session: {
|
|
711
|
+
installed: false,
|
|
712
|
+
authenticated: false,
|
|
713
|
+
canInstall: options.allowCliLogin === true,
|
|
714
|
+
},
|
|
715
|
+
};
|
|
716
|
+
const job = {
|
|
717
|
+
context,
|
|
718
|
+
prepared,
|
|
719
|
+
operation,
|
|
720
|
+
expiresAt,
|
|
721
|
+
busy: false,
|
|
722
|
+
public: {
|
|
723
|
+
id: randomUUID(),
|
|
724
|
+
providerId: prepared.input.providerId,
|
|
725
|
+
methodId: input.methodId,
|
|
726
|
+
authModeId,
|
|
727
|
+
expiresAt: base.expiresAt,
|
|
728
|
+
state: "pending",
|
|
729
|
+
step,
|
|
730
|
+
},
|
|
731
|
+
};
|
|
732
|
+
interactiveJobs.set(job.public.id, job);
|
|
733
|
+
job.timer = setTimeout(() => endInteractive(job, "authorization_expired"), Math.max(1, expiresAt - now()));
|
|
734
|
+
job.timer.unref?.();
|
|
735
|
+
try {
|
|
736
|
+
if (step.kind === "local-session")
|
|
737
|
+
job.public.step = await detectCliSession(job);
|
|
738
|
+
await authorize(context);
|
|
739
|
+
assertInteractivePending(job);
|
|
740
|
+
return structuredClone(job.public);
|
|
741
|
+
}
|
|
742
|
+
catch (error) {
|
|
743
|
+
endInteractive(job, error instanceof LinkServiceError ? error.code : "cli_unavailable");
|
|
744
|
+
throw publicLinkError(error);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
async function respondAuthorization(context, id, response) {
|
|
748
|
+
await authorize(context);
|
|
749
|
+
pruneInteractive();
|
|
750
|
+
const job = interactiveJobs.get(safeId(id));
|
|
751
|
+
if (!job || job.context.ownerId !== context.ownerId)
|
|
752
|
+
throw new LinkServiceError(404, "not_found");
|
|
753
|
+
await authorize(job.context);
|
|
754
|
+
if (job.public.state !== "pending" || job.busy)
|
|
755
|
+
throw new LinkServiceError(409, "conflict");
|
|
756
|
+
const step = job.public.step;
|
|
757
|
+
if (!response || response.stepId !== step.id || Date.parse(step.expiresAt) <= now())
|
|
758
|
+
throw new LinkServiceError(409, "conflict");
|
|
759
|
+
if (Object.keys(response).some((key) => !["stepId", "operation", "input"].includes(key)))
|
|
760
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
761
|
+
if (response.input !== undefined &&
|
|
762
|
+
(!response.input || typeof response.input !== "object" || Array.isArray(response.input)))
|
|
763
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
764
|
+
let token;
|
|
765
|
+
if (step.kind === "credential-input") {
|
|
766
|
+
if (response.operation !== "submit" ||
|
|
767
|
+
!response.input ||
|
|
768
|
+
Object.keys(response.input).some((key) => key !== "token"))
|
|
769
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
770
|
+
token = string(response.input.token, 16_384);
|
|
771
|
+
}
|
|
772
|
+
else if (step.kind !== "local-session" ||
|
|
773
|
+
!["detect-session", "login-session", "bind-session"].includes(response.operation) ||
|
|
774
|
+
Object.keys(response.input ?? {}).length)
|
|
775
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
776
|
+
if (response.operation === "login-session" && options.allowCliLogin !== true)
|
|
777
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
778
|
+
if (response.operation === "bind-session" &&
|
|
779
|
+
(step.kind !== "local-session" || !step.session.authenticated || !job.confirmedCliAccountId))
|
|
780
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
781
|
+
job.busy = true;
|
|
782
|
+
job.public = {
|
|
783
|
+
...job.public,
|
|
784
|
+
errorCode: undefined,
|
|
785
|
+
step: { id: randomUUID(), kind: "processing", expiresAt: step.expiresAt },
|
|
786
|
+
};
|
|
787
|
+
let committed = false;
|
|
788
|
+
const check = async () => {
|
|
789
|
+
await authorize(context);
|
|
790
|
+
if (!committed) {
|
|
791
|
+
await authorize(job.context);
|
|
792
|
+
assertInteractivePending(job);
|
|
793
|
+
}
|
|
794
|
+
return true;
|
|
795
|
+
};
|
|
796
|
+
try {
|
|
797
|
+
if (response.operation === "detect-session") {
|
|
798
|
+
job.public.step = await detectCliSession(job);
|
|
799
|
+
await check();
|
|
800
|
+
}
|
|
801
|
+
else if (response.operation === "login-session") {
|
|
802
|
+
let validation;
|
|
803
|
+
try {
|
|
804
|
+
validation = await bindCli(job.prepared.input.providerId, { cwd: options.cwd, signal: job.operation.controller.signal, loginIfNeeded: true });
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
await check();
|
|
808
|
+
throw new LinkServiceError(422, "cli_unavailable");
|
|
809
|
+
}
|
|
810
|
+
await check();
|
|
811
|
+
job.public.step = cliStep(job, step.expiresAt, true, validation);
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
let validation;
|
|
815
|
+
try {
|
|
816
|
+
validation =
|
|
817
|
+
token !== undefined
|
|
818
|
+
? await validateToken(job.prepared.input.providerId, token, {
|
|
819
|
+
signal: job.operation.controller.signal,
|
|
820
|
+
})
|
|
821
|
+
: await bindCli(job.prepared.input.providerId, {
|
|
822
|
+
cwd: options.cwd,
|
|
823
|
+
signal: job.operation.controller.signal,
|
|
824
|
+
loginIfNeeded: false,
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
catch {
|
|
828
|
+
await check();
|
|
829
|
+
throw new LinkServiceError(422, token !== undefined ? "provider_rejected" : "cli_unavailable");
|
|
830
|
+
}
|
|
831
|
+
await check();
|
|
832
|
+
if (validation.providerId !== job.prepared.input.providerId)
|
|
833
|
+
throw new LinkServiceError(422, "provider_rejected");
|
|
834
|
+
if (token === undefined &&
|
|
835
|
+
validation.identity.externalAccountId !== job.confirmedCliAccountId) {
|
|
836
|
+
job.public.step = cliStep(job, step.expiresAt, true, validation, "CLI 已切换账号,请确认当前账号后继续。");
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
await commit({ ownerId: context.ownerId, authorize: check }, job.prepared, validation, token ?? `cli-binding:${randomBytes(24).toString("base64url")}`, token !== undefined ? "manual-token" : "cli-session", {
|
|
840
|
+
beforeCommit: () => assertInteractivePending(job),
|
|
841
|
+
onCommitted: (connection) => {
|
|
842
|
+
committed = true;
|
|
843
|
+
job.public = terminal(job.public, "connected", { connection });
|
|
844
|
+
clearTimeout(job.timer);
|
|
845
|
+
finish(job.operation);
|
|
846
|
+
job.expiresAt = now() + 5 * 60_000;
|
|
847
|
+
},
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
await authorize(context);
|
|
852
|
+
return structuredClone(job.public);
|
|
853
|
+
}
|
|
854
|
+
catch (error) {
|
|
855
|
+
if (job.public.state === "pending") {
|
|
856
|
+
const code = error instanceof LinkServiceError ? error.code : "unavailable";
|
|
857
|
+
if (["provider_rejected", "cli_unavailable"].includes(code) &&
|
|
858
|
+
!job.operation.controller.signal.aborted &&
|
|
859
|
+
job.expiresAt > now())
|
|
860
|
+
job.public = { ...job.public, step: { ...step, id: randomUUID() }, errorCode: code };
|
|
861
|
+
else
|
|
862
|
+
endInteractive(job, code);
|
|
863
|
+
}
|
|
864
|
+
await authorize(context);
|
|
865
|
+
return structuredClone(job.public);
|
|
866
|
+
}
|
|
867
|
+
finally {
|
|
868
|
+
job.busy = false;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
462
871
|
function cancelJob(job) {
|
|
463
872
|
job.operation.controller.abort();
|
|
464
873
|
if (job.brokerAttempt)
|
|
@@ -466,12 +875,7 @@ export function createLinkService(options = {}) {
|
|
|
466
875
|
if (job.timer)
|
|
467
876
|
clearTimeout(job.timer);
|
|
468
877
|
if (job.public.state === "pending")
|
|
469
|
-
job.public = {
|
|
470
|
-
id: job.public.id,
|
|
471
|
-
providerId: job.public.providerId,
|
|
472
|
-
state: "cancelled",
|
|
473
|
-
errorCode: "cancelled",
|
|
474
|
-
};
|
|
878
|
+
job.public = terminal(job.public, "cancelled", { errorCode: "cancelled" });
|
|
475
879
|
finish(job.operation);
|
|
476
880
|
job.expiresAt = now() + 5 * 60_000;
|
|
477
881
|
}
|
|
@@ -479,12 +883,7 @@ export function createLinkService(options = {}) {
|
|
|
479
883
|
if (job.public.state !== "pending")
|
|
480
884
|
return;
|
|
481
885
|
cancelJob(job);
|
|
482
|
-
job.public = {
|
|
483
|
-
id: job.public.id,
|
|
484
|
-
providerId: job.public.providerId,
|
|
485
|
-
state: "failed",
|
|
486
|
-
errorCode: "authorization_expired",
|
|
487
|
-
};
|
|
886
|
+
job.public = terminal(job.public, "failed", { errorCode: "authorization_expired" });
|
|
488
887
|
}
|
|
489
888
|
function pruneJobs() {
|
|
490
889
|
for (const [id, job] of jobs)
|
|
@@ -496,41 +895,46 @@ export function createLinkService(options = {}) {
|
|
|
496
895
|
}
|
|
497
896
|
}
|
|
498
897
|
async function completeDevice(job, prepared) {
|
|
898
|
+
let committed = false;
|
|
899
|
+
const assertPending = () => {
|
|
900
|
+
if (job.public.state !== "pending" || job.operation.controller.signal.aborted)
|
|
901
|
+
throw new LinkServiceError(409, "cancelled");
|
|
902
|
+
if (job.expiresAt <= now())
|
|
903
|
+
throw new LinkServiceError(409, "authorization_expired");
|
|
904
|
+
};
|
|
905
|
+
const check = async () => {
|
|
906
|
+
if (!committed) {
|
|
907
|
+
await authorize(job.context);
|
|
908
|
+
assertPending();
|
|
909
|
+
}
|
|
910
|
+
return true;
|
|
911
|
+
};
|
|
499
912
|
try {
|
|
500
913
|
const token = await job.broker.complete(job.brokerAttempt);
|
|
501
|
-
await
|
|
502
|
-
if (job.operation.controller.signal.aborted || job.public.state !== "pending")
|
|
503
|
-
throw new LinkServiceError(409, "cancelled");
|
|
914
|
+
await check();
|
|
504
915
|
if (token.providerId !== prepared.input.providerId)
|
|
505
916
|
throw new LinkServiceError(422, "authorization_failed");
|
|
506
917
|
const validation = await validateToken(token.providerId, token.accessToken, {
|
|
507
918
|
signal: job.operation.controller.signal,
|
|
919
|
+
authKind: "oauth",
|
|
920
|
+
});
|
|
921
|
+
await check();
|
|
922
|
+
await commit({ ownerId: job.context.ownerId, authorize: check }, prepared, validation, tokenSecret(token), "browser-oauth", {
|
|
923
|
+
beforeCommit: assertPending,
|
|
924
|
+
onCommitted: (connection) => {
|
|
925
|
+
committed = true;
|
|
926
|
+
job.public = terminal(job.public, "connected", { connection });
|
|
927
|
+
clearTimeout(job.timer);
|
|
928
|
+
finish(job.operation);
|
|
929
|
+
job.expiresAt = now() + 5 * 60_000;
|
|
930
|
+
},
|
|
508
931
|
});
|
|
509
|
-
await authorize(job.context);
|
|
510
|
-
if (job.operation.controller.signal.aborted || job.public.state !== "pending")
|
|
511
|
-
throw new LinkServiceError(409, "cancelled");
|
|
512
|
-
const connection = await commit({
|
|
513
|
-
...job.context,
|
|
514
|
-
authorize: async () => (await job.context.authorize()) &&
|
|
515
|
-
!job.operation.controller.signal.aborted &&
|
|
516
|
-
job.public.state === "pending",
|
|
517
|
-
}, prepared, validation, tokenSecret(token), "browser-oauth");
|
|
518
|
-
if (job.public.state === "pending")
|
|
519
|
-
job.public = {
|
|
520
|
-
id: job.public.id,
|
|
521
|
-
providerId: job.public.providerId,
|
|
522
|
-
state: "connected",
|
|
523
|
-
connection,
|
|
524
|
-
};
|
|
525
932
|
}
|
|
526
933
|
catch (error) {
|
|
527
934
|
if (job.public.state === "pending")
|
|
528
|
-
job.public = {
|
|
529
|
-
id: job.public.id,
|
|
530
|
-
providerId: job.public.providerId,
|
|
531
|
-
state: "failed",
|
|
935
|
+
job.public = terminal(job.public, "failed", {
|
|
532
936
|
errorCode: error instanceof LinkServiceError ? error.code : "authorization_failed",
|
|
533
|
-
};
|
|
937
|
+
});
|
|
534
938
|
}
|
|
535
939
|
finally {
|
|
536
940
|
if (job.timer)
|
|
@@ -559,7 +963,13 @@ export function createLinkService(options = {}) {
|
|
|
559
963
|
ownerId: context.ownerId,
|
|
560
964
|
context,
|
|
561
965
|
broker,
|
|
562
|
-
public: {
|
|
966
|
+
public: {
|
|
967
|
+
id,
|
|
968
|
+
providerId,
|
|
969
|
+
methodId: input.methodId,
|
|
970
|
+
authModeId: "browser-oauth",
|
|
971
|
+
state: "pending",
|
|
972
|
+
},
|
|
563
973
|
expiresAt: now() + 30 * 60_000,
|
|
564
974
|
operation,
|
|
565
975
|
};
|
|
@@ -576,6 +986,8 @@ export function createLinkService(options = {}) {
|
|
|
576
986
|
verificationUriComplete: prompt.verificationUriComplete,
|
|
577
987
|
expiresAt: prompt.expiresAt,
|
|
578
988
|
};
|
|
989
|
+
job.public.expiresAt = prompt.expiresAt;
|
|
990
|
+
job.public.step = { id: randomUUID(), kind: "device-code", ...job.public.prompt };
|
|
579
991
|
job.timer = setTimeout(() => expireJob(job), Math.max(1, Math.min(30 * 60_000, job.expiresAt - now())));
|
|
580
992
|
job.timer.unref?.();
|
|
581
993
|
const result = structuredClone(job.public);
|
|
@@ -592,21 +1004,45 @@ export function createLinkService(options = {}) {
|
|
|
592
1004
|
}
|
|
593
1005
|
}
|
|
594
1006
|
function pruneRemoteJobs() {
|
|
595
|
-
for (const [id, job] of remoteJobs)
|
|
596
|
-
if (job.attempt.expiresAt
|
|
1007
|
+
for (const [id, job] of remoteJobs) {
|
|
1008
|
+
if (job.attempt.expiresAt > now() && !job.retireAt)
|
|
1009
|
+
continue;
|
|
1010
|
+
if (!job.public.authModeId) {
|
|
597
1011
|
remoteJobs.delete(id);
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
if (job.public.state === "pending")
|
|
1015
|
+
job.public = terminal(job.public, "failed", { errorCode: "authorization_expired" });
|
|
1016
|
+
job.retireAt ??= now() + 5 * 60_000;
|
|
1017
|
+
if (job.retireAt <= now())
|
|
1018
|
+
remoteJobs.delete(id);
|
|
1019
|
+
}
|
|
598
1020
|
}
|
|
599
1021
|
async function startRemoteAuth(context, input) {
|
|
600
1022
|
await authorize(context);
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
1023
|
+
const checkCapacity = () => {
|
|
1024
|
+
pruneRemoteJobs();
|
|
1025
|
+
if (remoteJobs.size >= 32 ||
|
|
1026
|
+
[...remoteJobs.values()].filter((job) => job.context.ownerId === context.ownerId && job.public.state === "pending").length >= 2)
|
|
1027
|
+
throw new LinkServiceError(409, "busy");
|
|
1028
|
+
};
|
|
1029
|
+
checkCapacity();
|
|
1030
|
+
if (!input ||
|
|
1031
|
+
input.methodId !== "remote-link" ||
|
|
1032
|
+
!REMOTE_LINK_ADAPTERS.some((adapter) => adapter.id === input.providerId))
|
|
606
1033
|
throw new LinkServiceError(400, "invalid_request");
|
|
607
1034
|
const config = options.remoteLink?.();
|
|
608
1035
|
if (!config)
|
|
609
1036
|
throw new LinkServiceError(503, "unavailable");
|
|
1037
|
+
await refreshRemoteCatalog(context);
|
|
1038
|
+
// Discovery awaits must not bypass owner revocation or the pending-job limit.
|
|
1039
|
+
await authorize(context);
|
|
1040
|
+
checkCapacity();
|
|
1041
|
+
const capabilities = remoteCatalog?.issuer === config.issuer
|
|
1042
|
+
? remoteCatalog.providers.find((provider) => provider.id === input.providerId)
|
|
1043
|
+
: undefined;
|
|
1044
|
+
if (!capabilities)
|
|
1045
|
+
throw new LinkServiceError(503, "unavailable");
|
|
610
1046
|
const id = input.connectionId === undefined ? `link-remote-${randomUUID()}` : safeId(input.connectionId);
|
|
611
1047
|
let expected = null;
|
|
612
1048
|
if (input.expectedRevision === null) {
|
|
@@ -615,20 +1051,23 @@ export function createLinkService(options = {}) {
|
|
|
615
1051
|
}
|
|
616
1052
|
else {
|
|
617
1053
|
expected = reviewed(id, input.expectedRevision);
|
|
618
|
-
if (!isRemoteLinkCredential(expected))
|
|
1054
|
+
if (!isRemoteLinkCredential(expected) || expected.meta?.linkProvider !== input.providerId)
|
|
619
1055
|
throw new LinkServiceError(409, "conflict");
|
|
620
1056
|
}
|
|
621
1057
|
const prepared = { id, expected, input: { ...input, label: string(input.label) } };
|
|
622
1058
|
let attempt;
|
|
623
1059
|
try {
|
|
624
|
-
attempt = beginRemoteLinkAuthorization(config, now()
|
|
1060
|
+
attempt = beginRemoteLinkAuthorization(config, now(), {
|
|
1061
|
+
providerId: input.providerId,
|
|
1062
|
+
actions: capabilities.actions,
|
|
1063
|
+
});
|
|
625
1064
|
}
|
|
626
1065
|
catch {
|
|
627
1066
|
throw new LinkServiceError(400, "invalid_request");
|
|
628
1067
|
}
|
|
629
1068
|
const publicJob = {
|
|
630
1069
|
id: randomUUID(),
|
|
631
|
-
providerId:
|
|
1070
|
+
providerId: input.providerId,
|
|
632
1071
|
state: "pending",
|
|
633
1072
|
redirect: {
|
|
634
1073
|
authorizationUrl: attempt.authorizationUrl,
|
|
@@ -653,19 +1092,24 @@ export function createLinkService(options = {}) {
|
|
|
653
1092
|
if (job.completing || job.public.state !== "pending")
|
|
654
1093
|
throw new LinkServiceError(409, "conflict");
|
|
655
1094
|
const originalConfig = JSON.stringify(job.attempt.configuration);
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
if (remoteJobs.get(id) !== job ||
|
|
660
|
-
job.attempt.expiresAt <= now() ||
|
|
661
|
-
job.public.state !== "pending")
|
|
1095
|
+
let committed = false;
|
|
1096
|
+
const assertPending = () => {
|
|
1097
|
+
if (remoteJobs.get(id) !== job || job.public.state !== "pending")
|
|
662
1098
|
throw new LinkServiceError(409, "cancelled");
|
|
663
|
-
|
|
1099
|
+
if (job.attempt.expiresAt <= now())
|
|
1100
|
+
throw new LinkServiceError(409, "authorization_expired");
|
|
664
1101
|
const config = options.remoteLink?.();
|
|
665
1102
|
if (!config ||
|
|
666
1103
|
JSON.stringify(beginRemoteLinkAuthorization(config, now()).configuration) !== originalConfig)
|
|
667
1104
|
throw new LinkServiceError(409, "conflict");
|
|
668
1105
|
};
|
|
1106
|
+
const check = async () => {
|
|
1107
|
+
if (!committed) {
|
|
1108
|
+
await authorize(context);
|
|
1109
|
+
await authorize(job.context);
|
|
1110
|
+
assertPending();
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
669
1113
|
job.completing = true;
|
|
670
1114
|
let credential;
|
|
671
1115
|
let staged;
|
|
@@ -686,15 +1130,17 @@ export function createLinkService(options = {}) {
|
|
|
686
1130
|
return true;
|
|
687
1131
|
},
|
|
688
1132
|
};
|
|
689
|
-
|
|
1133
|
+
await change(guarded, () => {
|
|
1134
|
+
assertPending();
|
|
690
1135
|
swap(job.prepared, credential, {
|
|
691
1136
|
retireExpected: !!job.prepared.expected,
|
|
692
1137
|
adopt: staged,
|
|
693
1138
|
now: now(),
|
|
694
1139
|
});
|
|
695
|
-
|
|
1140
|
+
const connection = masked(credential, "user");
|
|
1141
|
+
committed = true;
|
|
1142
|
+
job.public = terminal(job.public, "connected", { connection });
|
|
696
1143
|
});
|
|
697
|
-
job.public = { id, providerId: "github", state: "connected", connection };
|
|
698
1144
|
// Replacing the active record and retaining its previous grant is one atomic write.
|
|
699
1145
|
await retryRemoteCleanup();
|
|
700
1146
|
if (store.remoteLinkRetirementCount())
|
|
@@ -703,6 +1149,11 @@ export function createLinkService(options = {}) {
|
|
|
703
1149
|
return structuredClone(job.public);
|
|
704
1150
|
}
|
|
705
1151
|
catch (error) {
|
|
1152
|
+
if (committed) {
|
|
1153
|
+
// Notification or post-write authorization failure cannot revoke an adopted grant.
|
|
1154
|
+
await authorize(context);
|
|
1155
|
+
return structuredClone(job.public);
|
|
1156
|
+
}
|
|
706
1157
|
// If the callback minted a grant but its destination changed, do not leave it usable remotely.
|
|
707
1158
|
if (staged) {
|
|
708
1159
|
store.readyRemoteLinkRetirement(staged, now());
|
|
@@ -717,12 +1168,10 @@ export function createLinkService(options = {}) {
|
|
|
717
1168
|
/* Report failure below. */
|
|
718
1169
|
}
|
|
719
1170
|
}
|
|
720
|
-
job.public
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
errorCode: error instanceof LinkServiceError ? error.code : "authorization_failed",
|
|
725
|
-
};
|
|
1171
|
+
if (job.public.state === "pending")
|
|
1172
|
+
job.public = terminal(job.public, "failed", {
|
|
1173
|
+
errorCode: error instanceof LinkServiceError ? error.code : "authorization_failed",
|
|
1174
|
+
});
|
|
726
1175
|
if (error instanceof LinkServiceError)
|
|
727
1176
|
throw error;
|
|
728
1177
|
throw new LinkServiceError(422, "authorization_failed");
|
|
@@ -732,6 +1181,15 @@ export function createLinkService(options = {}) {
|
|
|
732
1181
|
await authorize(context);
|
|
733
1182
|
pruneJobs();
|
|
734
1183
|
pruneRemoteJobs();
|
|
1184
|
+
pruneInteractive();
|
|
1185
|
+
const interactive = interactiveJobs.get(safeId(id));
|
|
1186
|
+
if (interactive) {
|
|
1187
|
+
if (interactive.context.ownerId !== context.ownerId)
|
|
1188
|
+
throw new LinkServiceError(404, "not_found");
|
|
1189
|
+
if (interactive.public.state === "pending")
|
|
1190
|
+
await authorize(interactive.context);
|
|
1191
|
+
return structuredClone(interactive.public);
|
|
1192
|
+
}
|
|
735
1193
|
const remote = remoteJobs.get(safeId(id));
|
|
736
1194
|
if (remote) {
|
|
737
1195
|
if (remote.context.ownerId !== context.ownerId)
|
|
@@ -744,8 +1202,21 @@ export function createLinkService(options = {}) {
|
|
|
744
1202
|
return structuredClone(job.public);
|
|
745
1203
|
}
|
|
746
1204
|
async function cancelAuthorization(context, id) {
|
|
747
|
-
await
|
|
748
|
-
|
|
1205
|
+
await authorize(context);
|
|
1206
|
+
safeId(id);
|
|
1207
|
+
const ownerId = remoteJobs.get(id)?.context.ownerId ??
|
|
1208
|
+
interactiveJobs.get(id)?.context.ownerId ??
|
|
1209
|
+
jobs.get(id)?.ownerId;
|
|
1210
|
+
if (!ownerId || ownerId !== context.ownerId)
|
|
1211
|
+
throw new LinkServiceError(404, "not_found");
|
|
1212
|
+
const remote = remoteJobs.get(id);
|
|
1213
|
+
if (remote?.public.state === "pending") {
|
|
1214
|
+
remote.public = terminal(remote.public, "cancelled", { errorCode: "cancelled" });
|
|
1215
|
+
remote.retireAt = now() + 5 * 60_000;
|
|
1216
|
+
}
|
|
1217
|
+
const interactive = interactiveJobs.get(id);
|
|
1218
|
+
if (interactive)
|
|
1219
|
+
endInteractive(interactive, "cancelled");
|
|
749
1220
|
const job = jobs.get(id);
|
|
750
1221
|
if (job)
|
|
751
1222
|
cancelJob(job);
|
|
@@ -753,6 +1224,11 @@ export function createLinkService(options = {}) {
|
|
|
753
1224
|
}
|
|
754
1225
|
function cancelOwner(ownerId) {
|
|
755
1226
|
revokedOwners.add(ownerId);
|
|
1227
|
+
for (const [id, job] of interactiveJobs)
|
|
1228
|
+
if (job.context.ownerId === ownerId) {
|
|
1229
|
+
endInteractive(job, "cancelled");
|
|
1230
|
+
interactiveJobs.delete(id);
|
|
1231
|
+
}
|
|
756
1232
|
for (const [id, job] of remoteJobs)
|
|
757
1233
|
if (job.context.ownerId === ownerId)
|
|
758
1234
|
remoteJobs.delete(id);
|
|
@@ -772,6 +1248,9 @@ export function createLinkService(options = {}) {
|
|
|
772
1248
|
clearInterval(cleanupTimer);
|
|
773
1249
|
clearTimeout(cleanupStartup);
|
|
774
1250
|
remoteJobs.clear();
|
|
1251
|
+
for (const job of interactiveJobs.values())
|
|
1252
|
+
endInteractive(job, "cancelled");
|
|
1253
|
+
interactiveJobs.clear();
|
|
775
1254
|
for (const operation of operations)
|
|
776
1255
|
operation.controller.abort();
|
|
777
1256
|
operations.clear();
|
|
@@ -789,6 +1268,9 @@ export function createLinkService(options = {}) {
|
|
|
789
1268
|
rename,
|
|
790
1269
|
disconnect,
|
|
791
1270
|
startDeviceAuth,
|
|
1271
|
+
startAuthorization,
|
|
1272
|
+
respondAuthorization,
|
|
1273
|
+
refreshRemoteCatalog,
|
|
792
1274
|
startRemoteAuth,
|
|
793
1275
|
completeRemoteAuth,
|
|
794
1276
|
retryRemoteCleanup,
|