dsh-plugin-subscription-oauth 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +413 -2
- package/cordis.patch.yml +13 -0
- package/lib/auth/device-flow.d.ts +112 -0
- package/lib/auth/device-flow.js +319 -0
- package/lib/auth/device-id.d.ts +31 -0
- package/lib/auth/device-id.js +58 -0
- package/lib/auth/flow.d.ts +33 -0
- package/lib/auth/flow.js +12 -0
- package/lib/auth/glm-flow.d.ts +99 -0
- package/lib/auth/glm-flow.js +194 -0
- package/lib/auth/headers.d.ts +61 -0
- package/lib/auth/headers.js +106 -0
- package/lib/auth/pkce-flow.d.ts +78 -0
- package/lib/auth/pkce-flow.js +205 -0
- package/lib/auth/provider-controller.d.ts +241 -0
- package/lib/auth/provider-controller.js +502 -0
- package/lib/auth/registry.d.ts +91 -0
- package/lib/auth/registry.js +149 -0
- package/lib/auth/rpc.d.ts +31 -0
- package/lib/auth/rpc.js +200 -0
- package/lib/auth/store.d.ts +99 -0
- package/lib/auth/store.js +213 -0
- package/lib/auth/tokens.d.ts +76 -0
- package/lib/auth/tokens.js +103 -0
- package/lib/client/SubscriptionsSection.d.ts +34 -0
- package/lib/client/SubscriptionsSection.js +742 -0
- package/lib/client/index.d.ts +36 -0
- package/lib/client/index.js +47 -0
- package/lib/client/locales.d.ts +187 -0
- package/lib/client/locales.js +185 -0
- package/lib/client/styles.d.ts +18 -0
- package/lib/client/styles.js +257 -0
- package/lib/client/subscriptions-rpc.d.ts +24 -0
- package/lib/client/subscriptions-rpc.js +44 -0
- package/lib/client.js +2078 -0
- package/lib/client.js.map +1 -0
- package/lib/constants.d.ts +79 -0
- package/lib/constants.js +91 -0
- package/lib/errors.d.ts +28 -0
- package/lib/errors.js +34 -0
- package/lib/http.d.ts +24 -0
- package/lib/http.js +98 -0
- package/lib/index.d.ts +57 -0
- package/lib/index.js +185 -0
- package/lib/protocol.d.ts +316 -0
- package/lib/protocol.js +29 -0
- package/lib/providers/antigravity-catalog.d.ts +53 -0
- package/lib/providers/antigravity-catalog.js +269 -0
- package/lib/providers/antigravity.d.ts +331 -0
- package/lib/providers/antigravity.js +821 -0
- package/lib/providers/auto.d.ts +151 -0
- package/lib/providers/auto.js +351 -0
- package/lib/providers/catalog.d.ts +69 -0
- package/lib/providers/catalog.js +78 -0
- package/lib/providers/common.d.ts +51 -0
- package/lib/providers/common.js +169 -0
- package/lib/providers/glm-provider.d.ts +41 -0
- package/lib/providers/glm-provider.js +165 -0
- package/lib/providers/glm.d.ts +159 -0
- package/lib/providers/glm.js +445 -0
- package/lib/providers/grok.d.ts +224 -0
- package/lib/providers/grok.js +700 -0
- package/lib/providers/kimi-provider.d.ts +45 -0
- package/lib/providers/kimi-provider.js +148 -0
- package/lib/providers/kimi.d.ts +151 -0
- package/lib/providers/kimi.js +400 -0
- package/lib/providers/responses-stream.d.ts +58 -0
- package/lib/providers/responses-stream.js +115 -0
- package/lib/providers/usage.d.ts +65 -0
- package/lib/providers/usage.js +309 -0
- package/lib/settings.d.ts +122 -0
- package/lib/settings.js +333 -0
- package/lib/translate/antigravity-schema.d.ts +24 -0
- package/lib/translate/antigravity-schema.js +174 -0
- package/lib/translate/antigravity.d.ts +184 -0
- package/lib/translate/antigravity.js +419 -0
- package/lib/translate/responses.d.ts +156 -0
- package/lib/translate/responses.js +395 -0
- package/lib/translate/sse.d.ts +27 -0
- package/lib/translate/sse.js +82 -0
- package/package.json +126 -4
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ZCode CLI-style OAuth flow BigModel's coding plan signs in with.
|
|
3
|
+
*
|
|
4
|
+
* ZCode does not authorize by device code or by a loopback redirect. It asks its
|
|
5
|
+
* own backend for a *flow*: `init` returns an `authorize_url` plus a flow id, the
|
|
6
|
+
* user signs in at `bigmodel.cn` in a browser, and the callback is completed
|
|
7
|
+
* server-side by `zcode.z.ai`. The client then polls the flow until the backend
|
|
8
|
+
* reports it ready and hands back the tokens.
|
|
9
|
+
*
|
|
10
|
+
* That shape is what makes this implementable outside the ZCode app: nothing has
|
|
11
|
+
* to receive a `zcode://oauth/callback` custom-protocol redirect, and no app
|
|
12
|
+
* secret is involved. The poll is authenticated with a secret this client
|
|
13
|
+
* generates itself, so the flow belongs to whoever started it.
|
|
14
|
+
*
|
|
15
|
+
* Wire contract, read out of ZCode 3.14.4's host bundle and verified against the
|
|
16
|
+
* live endpoint:
|
|
17
|
+
*
|
|
18
|
+
* - `POST /api/v1/oauth/cli/init`, `Authorization: Bearer <64 hex>`,
|
|
19
|
+
* `{ "provider": "bigmodel" }`
|
|
20
|
+
* → `{ code: 0, data: { flow_id, poll_token, authorize_url, expires_at,
|
|
21
|
+
* poll_interval_sec } }`
|
|
22
|
+
* - `GET /api/v1/oauth/cli/poll/<flow_id>`, same bearer
|
|
23
|
+
* → `{ code: 0, data: { status: "pending" } }` while waiting, then
|
|
24
|
+
* `{ code: 0, data: { status: "ready", token, user, bigmodel: {
|
|
25
|
+
* access_token, refresh_token } } }`
|
|
26
|
+
*
|
|
27
|
+
* @module dsh-plugin-subscription-oauth/auth/glm-flow
|
|
28
|
+
*/
|
|
29
|
+
import { randomBytes } from 'node:crypto';
|
|
30
|
+
import { decodeJwtClaims } from './device-flow.js';
|
|
31
|
+
/** Backend that brokers the flow; the browser never talks to it directly. */
|
|
32
|
+
export const GLM_OAUTH_BASE = 'https://zcode.z.ai';
|
|
33
|
+
/** Flow-initiation path on the broker. */
|
|
34
|
+
export const GLM_INIT_PATH = '/api/v1/oauth/cli/init';
|
|
35
|
+
/** Flow-poll path on the broker; the flow id is appended. */
|
|
36
|
+
export const GLM_POLL_PATH = '/api/v1/oauth/cli/poll';
|
|
37
|
+
/** Provider key the broker knows the China-region coding plan by. */
|
|
38
|
+
export const GLM_BROKER_PROVIDER = 'bigmodel';
|
|
39
|
+
/** How long one request to the broker may take. */
|
|
40
|
+
const REQUEST_TIMEOUT_MS = 20_000;
|
|
41
|
+
/** Poll cadence used when `init` states none. */
|
|
42
|
+
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
|
43
|
+
/** Fallback token lifetime when neither the token nor the response states one. */
|
|
44
|
+
const DEFAULT_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
|
45
|
+
/** Non-empty trimmed string, or undefined. */
|
|
46
|
+
function text(value) {
|
|
47
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
|
48
|
+
}
|
|
49
|
+
/** A finite number, or undefined. */
|
|
50
|
+
function finite(value) {
|
|
51
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
52
|
+
}
|
|
53
|
+
/** A JSON object, or undefined. */
|
|
54
|
+
function record(value) {
|
|
55
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
56
|
+
? value
|
|
57
|
+
: undefined;
|
|
58
|
+
}
|
|
59
|
+
/** Read the token from either casing the broker has used. */
|
|
60
|
+
function tokenField(source, snake, camel) {
|
|
61
|
+
if (source === undefined)
|
|
62
|
+
return undefined;
|
|
63
|
+
return text(source[snake]) ?? text(source[camel]);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Decode one broker response body.
|
|
67
|
+
*
|
|
68
|
+
* The broker answers HTTP 200 with a business `code` for most refusals, so the
|
|
69
|
+
* envelope is checked before the payload is.
|
|
70
|
+
*
|
|
71
|
+
* @param payload - the decoded body.
|
|
72
|
+
* @param what - label used in the error message.
|
|
73
|
+
* @returns the `data` object.
|
|
74
|
+
* @throws {Error} when the envelope is a refusal or carries no data.
|
|
75
|
+
*/
|
|
76
|
+
function dataOf(payload, what) {
|
|
77
|
+
const envelope = record(payload);
|
|
78
|
+
if (envelope === undefined)
|
|
79
|
+
throw new Error(`${what}: the broker sent no JSON object`);
|
|
80
|
+
const code = finite(envelope.code);
|
|
81
|
+
if (code !== undefined && code !== 0) {
|
|
82
|
+
throw new Error(`${what}: ${text(envelope.msg) ?? `the broker refused with code ${String(code)}`}`);
|
|
83
|
+
}
|
|
84
|
+
const data = record(envelope.data);
|
|
85
|
+
if (data === undefined)
|
|
86
|
+
throw new Error(`${what}: the broker sent no data`);
|
|
87
|
+
return data;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Create a flow and return the URL the user signs in at.
|
|
91
|
+
*
|
|
92
|
+
* @param spec - broker origin, fetch, and clock.
|
|
93
|
+
* @param signal - optional caller cancellation.
|
|
94
|
+
* @returns the live flow.
|
|
95
|
+
* @throws {Error} when the broker refuses or answers a shape this build cannot use.
|
|
96
|
+
*/
|
|
97
|
+
export async function startGlmFlow(spec = {}, signal) {
|
|
98
|
+
const base = (spec.baseUrl ?? GLM_OAUTH_BASE).replace(/\/+$/, '');
|
|
99
|
+
const fetchFn = spec.fetchFn ?? fetch;
|
|
100
|
+
const now = spec.now ?? Date.now;
|
|
101
|
+
// The flow is authenticated by a secret only this caller knows, so a poll can
|
|
102
|
+
// never pick up somebody else's login.
|
|
103
|
+
const pollToken = randomBytes(32).toString('hex');
|
|
104
|
+
const response = await fetchFn(`${base}${GLM_INIT_PATH}`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: {
|
|
107
|
+
authorization: `Bearer ${pollToken}`,
|
|
108
|
+
'content-type': 'application/json',
|
|
109
|
+
},
|
|
110
|
+
body: JSON.stringify({ provider: GLM_BROKER_PROVIDER }),
|
|
111
|
+
signal: signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
112
|
+
});
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
throw new Error(`GLM sign-in could not start (HTTP ${response.status})`);
|
|
115
|
+
}
|
|
116
|
+
const data = dataOf(await response.json(), 'GLM sign-in could not start');
|
|
117
|
+
const flowId = text(data.flow_id);
|
|
118
|
+
const authorizeUrl = text(data.authorize_url);
|
|
119
|
+
if (flowId === undefined || authorizeUrl === undefined) {
|
|
120
|
+
throw new Error('GLM sign-in could not start: the broker stated no flow');
|
|
121
|
+
}
|
|
122
|
+
let parsed;
|
|
123
|
+
try {
|
|
124
|
+
parsed = new URL(authorizeUrl);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
throw new Error('GLM sign-in could not start: the broker stated an unusable authorize URL');
|
|
128
|
+
}
|
|
129
|
+
if (parsed.protocol !== 'https:') {
|
|
130
|
+
throw new Error('GLM sign-in could not start: the authorize URL is not https');
|
|
131
|
+
}
|
|
132
|
+
const expiresAtSeconds = finite(data.expires_at);
|
|
133
|
+
const intervalSeconds = finite(data.poll_interval_sec);
|
|
134
|
+
return {
|
|
135
|
+
flowId,
|
|
136
|
+
authorizeUrl: parsed.toString(),
|
|
137
|
+
expiresAt: expiresAtSeconds === undefined ? now() + 15 * 60 * 1000 : expiresAtSeconds * 1000,
|
|
138
|
+
pollIntervalMs: intervalSeconds === undefined ? DEFAULT_POLL_INTERVAL_MS : Math.max(1_000, intervalSeconds * 1000),
|
|
139
|
+
pollToken,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Ask the broker once whether the flow completed.
|
|
144
|
+
*
|
|
145
|
+
* @param spec - broker origin, fetch, and clock.
|
|
146
|
+
* @param start - the flow to ask about.
|
|
147
|
+
* @param signal - optional caller cancellation.
|
|
148
|
+
* @returns the tokens once the flow is ready, or undefined while it is pending.
|
|
149
|
+
* @throws {Error} when the flow expired, failed, or the broker refused.
|
|
150
|
+
*/
|
|
151
|
+
export async function pollGlmFlow(spec, start, signal) {
|
|
152
|
+
const base = (spec.baseUrl ?? GLM_OAUTH_BASE).replace(/\/+$/, '');
|
|
153
|
+
const fetchFn = spec.fetchFn ?? fetch;
|
|
154
|
+
const now = spec.now ?? Date.now;
|
|
155
|
+
if (now() >= start.expiresAt)
|
|
156
|
+
throw new Error('GLM sign-in expired; start it again');
|
|
157
|
+
const response = await fetchFn(`${base}${GLM_POLL_PATH}/${encodeURIComponent(start.flowId)}`, {
|
|
158
|
+
headers: { authorization: `Bearer ${start.pollToken}` },
|
|
159
|
+
signal: signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
160
|
+
});
|
|
161
|
+
if (!response.ok)
|
|
162
|
+
throw new Error(`GLM sign-in polling failed (HTTP ${response.status})`);
|
|
163
|
+
const data = dataOf(await response.json(), 'GLM sign-in polling failed');
|
|
164
|
+
const status = text(data.status);
|
|
165
|
+
if (status === 'pending')
|
|
166
|
+
return undefined;
|
|
167
|
+
if (status === 'failed')
|
|
168
|
+
throw new Error('GLM sign-in was refused in the browser');
|
|
169
|
+
if (status !== 'ready')
|
|
170
|
+
throw new Error(`GLM sign-in polling failed: unknown status ${JSON.stringify(status)}`);
|
|
171
|
+
const bigmodel = record(data.bigmodel);
|
|
172
|
+
const accessToken = tokenField(bigmodel, 'access_token', 'accessToken');
|
|
173
|
+
const zcodeJwt = text(data.token);
|
|
174
|
+
if (accessToken === undefined || zcodeJwt === undefined) {
|
|
175
|
+
throw new Error('GLM sign-in completed without the tokens this build needs');
|
|
176
|
+
}
|
|
177
|
+
const refreshToken = tokenField(bigmodel, 'refresh_token', 'refreshToken');
|
|
178
|
+
const user = record(data.user);
|
|
179
|
+
const accountId = text(user?.user_id) ?? text(user?.userId);
|
|
180
|
+
const label = text(user?.name) ?? text(user?.email);
|
|
181
|
+
// The JWT states its own expiry; the access token does not, so the earlier of
|
|
182
|
+
// the two is what the session treats as the deadline.
|
|
183
|
+
const claims = decodeJwtClaims(zcodeJwt);
|
|
184
|
+
const expSeconds = finite(claims?.exp);
|
|
185
|
+
const jwtExpiry = expSeconds === undefined ? undefined : expSeconds * 1000;
|
|
186
|
+
return {
|
|
187
|
+
accessToken,
|
|
188
|
+
zcodeJwt,
|
|
189
|
+
...refreshToken === undefined ? {} : { refreshToken },
|
|
190
|
+
expiresAt: jwtExpiry ?? now() + DEFAULT_TOKEN_TTL_MS,
|
|
191
|
+
...accountId === undefined ? {} : { accountId },
|
|
192
|
+
...label === undefined ? {} : { label },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `X-Msh-*` client-identity headers Kimi Code admits a request under.
|
|
3
|
+
*
|
|
4
|
+
* Kimi's coding service admits a caller through this header family rather than
|
|
5
|
+
* a bearer-token grant alone: the CLI declares its own version, its platform
|
|
6
|
+
* family, and a per-machine device id that must stay stable across restarts
|
|
7
|
+
* (the service correlates usage to it). The device model string is a single
|
|
8
|
+
* `<platform> <release> <arch>` line, not three headers.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-plugin-subscription-oauth/auth/headers
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The platform word Kimi's client reports. `os.platform()` yields `win32` and
|
|
14
|
+
* `darwin`, which the service does not recognise; the CLI reports the
|
|
15
|
+
* marketing name instead.
|
|
16
|
+
* @param platform - `process.platform` value.
|
|
17
|
+
* @returns the platform word used in `X-Msh-Device-Model`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function platformWord(platform?: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Build the device-model header value: `<platform> <release> <arch>`.
|
|
22
|
+
* @param platform - `process.platform` value.
|
|
23
|
+
* @returns one space-separated descriptor line.
|
|
24
|
+
*/
|
|
25
|
+
export declare function deviceModel(platform?: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the OS-version header. `os.version()` is absent on some runtimes, so
|
|
28
|
+
* an unavailable value degrades to the kernel release rather than the literal
|
|
29
|
+
* string `"undefined"`.
|
|
30
|
+
* @returns a non-empty version descriptor.
|
|
31
|
+
*/
|
|
32
|
+
export declare function osVersion(): string;
|
|
33
|
+
/**
|
|
34
|
+
* Assemble the identity headers every Kimi Code request carries.
|
|
35
|
+
* @param deviceId - this machine's persisted 32-hex device id.
|
|
36
|
+
* @returns the `X-Msh-*` / `User-Agent` header record.
|
|
37
|
+
*/
|
|
38
|
+
export declare function kimiIdentityHeaders(deviceId: string): Record<string, string>;
|
|
39
|
+
/**
|
|
40
|
+
* Merge the Kimi client identity with the harness attribution header for one
|
|
41
|
+
* provider request.
|
|
42
|
+
*
|
|
43
|
+
* Two requirements meet here: Kimi's service admits a request by the
|
|
44
|
+
* `KimiCLI/<version>` product token, and the harness contract requires
|
|
45
|
+
* `attributionHeaders()` on every provider request. Sending both products as
|
|
46
|
+
* separate `User-Agent` fields would let a transport join them with `", "`, so
|
|
47
|
+
* they are combined into one RFC 9110 product value instead. Every other
|
|
48
|
+
* identity header is passed through untouched.
|
|
49
|
+
*
|
|
50
|
+
* @param identity - {@link kimiIdentityHeaders} output for this machine.
|
|
51
|
+
* @param extra - request-specific headers, which win on collision.
|
|
52
|
+
* @returns the header record to send.
|
|
53
|
+
*/
|
|
54
|
+
export declare function kimiRequestHeaders(identity: Record<string, string>, extra?: Record<string, string>): Record<string, string>;
|
|
55
|
+
/**
|
|
56
|
+
* Attribution headers for a provider that needs no client identity of its own.
|
|
57
|
+
*
|
|
58
|
+
* @param extra - request-specific headers, which win on collision.
|
|
59
|
+
* @returns the header record to send.
|
|
60
|
+
*/
|
|
61
|
+
export declare function attributedHeaders(extra?: Record<string, string>): Record<string, string>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `X-Msh-*` client-identity headers Kimi Code admits a request under.
|
|
3
|
+
*
|
|
4
|
+
* Kimi's coding service admits a caller through this header family rather than
|
|
5
|
+
* a bearer-token grant alone: the CLI declares its own version, its platform
|
|
6
|
+
* family, and a per-machine device id that must stay stable across restarts
|
|
7
|
+
* (the service correlates usage to it). The device model string is a single
|
|
8
|
+
* `<platform> <release> <arch>` line, not three headers.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-plugin-subscription-oauth/auth/headers
|
|
11
|
+
*/
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import { attributionHeaders, userAgent } from '@deepseek-ai/dsh-llm';
|
|
14
|
+
import { KIMI_CLI_VERSION } from '../constants.js';
|
|
15
|
+
/**
|
|
16
|
+
* The platform word Kimi's client reports. `os.platform()` yields `win32` and
|
|
17
|
+
* `darwin`, which the service does not recognise; the CLI reports the
|
|
18
|
+
* marketing name instead.
|
|
19
|
+
* @param platform - `process.platform` value.
|
|
20
|
+
* @returns the platform word used in `X-Msh-Device-Model`.
|
|
21
|
+
*/
|
|
22
|
+
export function platformWord(platform = process.platform) {
|
|
23
|
+
switch (platform) {
|
|
24
|
+
case 'win32': return 'Windows';
|
|
25
|
+
case 'darwin': return 'macOS';
|
|
26
|
+
case 'linux': return 'Linux';
|
|
27
|
+
default: return platform;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Build the device-model header value: `<platform> <release> <arch>`.
|
|
32
|
+
* @param platform - `process.platform` value.
|
|
33
|
+
* @returns one space-separated descriptor line.
|
|
34
|
+
*/
|
|
35
|
+
export function deviceModel(platform = process.platform) {
|
|
36
|
+
return `${platformWord(platform)} ${os.release()} ${os.arch()}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the OS-version header. `os.version()` is absent on some runtimes, so
|
|
40
|
+
* an unavailable value degrades to the kernel release rather than the literal
|
|
41
|
+
* string `"undefined"`.
|
|
42
|
+
* @returns a non-empty version descriptor.
|
|
43
|
+
*/
|
|
44
|
+
export function osVersion() {
|
|
45
|
+
const version = os.version;
|
|
46
|
+
if (typeof version === 'function') {
|
|
47
|
+
try {
|
|
48
|
+
const value = version();
|
|
49
|
+
if (typeof value === 'string' && value.length > 0)
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// fall through to the release string
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return os.release();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Assemble the identity headers every Kimi Code request carries.
|
|
60
|
+
* @param deviceId - this machine's persisted 32-hex device id.
|
|
61
|
+
* @returns the `X-Msh-*` / `User-Agent` header record.
|
|
62
|
+
*/
|
|
63
|
+
export function kimiIdentityHeaders(deviceId) {
|
|
64
|
+
return {
|
|
65
|
+
'User-Agent': `KimiCLI/${KIMI_CLI_VERSION}`,
|
|
66
|
+
'X-Msh-Platform': 'kimi_code_cli',
|
|
67
|
+
'X-Msh-Version': KIMI_CLI_VERSION,
|
|
68
|
+
'X-Msh-Device-Name': os.hostname(),
|
|
69
|
+
'X-Msh-Device-Model': deviceModel(),
|
|
70
|
+
'X-Msh-Os-Version': osVersion(),
|
|
71
|
+
'X-Msh-Device-Id': deviceId,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Merge the Kimi client identity with the harness attribution header for one
|
|
76
|
+
* provider request.
|
|
77
|
+
*
|
|
78
|
+
* Two requirements meet here: Kimi's service admits a request by the
|
|
79
|
+
* `KimiCLI/<version>` product token, and the harness contract requires
|
|
80
|
+
* `attributionHeaders()` on every provider request. Sending both products as
|
|
81
|
+
* separate `User-Agent` fields would let a transport join them with `", "`, so
|
|
82
|
+
* they are combined into one RFC 9110 product value instead. Every other
|
|
83
|
+
* identity header is passed through untouched.
|
|
84
|
+
*
|
|
85
|
+
* @param identity - {@link kimiIdentityHeaders} output for this machine.
|
|
86
|
+
* @param extra - request-specific headers, which win on collision.
|
|
87
|
+
* @returns the header record to send.
|
|
88
|
+
*/
|
|
89
|
+
export function kimiRequestHeaders(identity, extra = {}) {
|
|
90
|
+
const cliAgent = identity['User-Agent'] ?? identity['user-agent'];
|
|
91
|
+
const headers = { ...identity, ...attributionHeaders(), ...extra };
|
|
92
|
+
if (cliAgent !== undefined) {
|
|
93
|
+
delete headers['user-agent'];
|
|
94
|
+
headers['User-Agent'] = `${cliAgent} ${userAgent()}`;
|
|
95
|
+
}
|
|
96
|
+
return headers;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Attribution headers for a provider that needs no client identity of its own.
|
|
100
|
+
*
|
|
101
|
+
* @param extra - request-specific headers, which win on collision.
|
|
102
|
+
* @returns the header record to send.
|
|
103
|
+
*/
|
|
104
|
+
export function attributedHeaders(extra = {}) {
|
|
105
|
+
return { ...attributionHeaders(), ...extra };
|
|
106
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback OAuth: PKCE helpers plus a temporary local redirect listener.
|
|
3
|
+
*
|
|
4
|
+
* Providers that complete authorization in the browser (xAI, Google) redirect to
|
|
5
|
+
* a `127.0.0.1` URL this process owns for the duration of one attempt. The
|
|
6
|
+
* authorization code never leaves the machine, and `state` is validated before
|
|
7
|
+
* the code is accepted so a stray or replayed redirect cannot settle the login.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-plugin-subscription-oauth/auth/pkce-flow
|
|
10
|
+
*/
|
|
11
|
+
import type { AuthAttempt, AuthFlow } from './flow.js';
|
|
12
|
+
import type { SessionBase } from './store.js';
|
|
13
|
+
/** One PKCE verifier/challenge pair; the verifier stays local until exchange. */
|
|
14
|
+
export interface PkcePair {
|
|
15
|
+
verifier: string;
|
|
16
|
+
challenge: string;
|
|
17
|
+
}
|
|
18
|
+
/** Mint a fresh PKCE pair (32-byte verifier, S256 challenge). */
|
|
19
|
+
export declare function createPkce(): PkcePair;
|
|
20
|
+
/** Mint a random OAuth `state` value. */
|
|
21
|
+
export declare function randomState(): string;
|
|
22
|
+
/** Inputs an authorize-URL builder needs for one attempt. */
|
|
23
|
+
export interface LoopbackAuthorizeInput {
|
|
24
|
+
/** Loopback redirect URI pointing at the temporary listener. */
|
|
25
|
+
redirectUri: string;
|
|
26
|
+
state: string;
|
|
27
|
+
pkce: PkcePair;
|
|
28
|
+
}
|
|
29
|
+
/** Inputs a token exchange needs once the code arrives. */
|
|
30
|
+
export interface LoopbackExchangeInput<S extends SessionBase> {
|
|
31
|
+
code: string;
|
|
32
|
+
redirectUri: string;
|
|
33
|
+
pkce: PkcePair;
|
|
34
|
+
state: string;
|
|
35
|
+
/** Aborts when the attempt is cancelled or times out. */
|
|
36
|
+
signal: AbortSignal;
|
|
37
|
+
}
|
|
38
|
+
/** Static facts of one provider's loopback flow. */
|
|
39
|
+
export interface LoopbackFlowSpec<S extends SessionBase> {
|
|
40
|
+
/** Ports tried in order; the first free one wins. */
|
|
41
|
+
readonly ports: readonly number[];
|
|
42
|
+
/** Path the provider redirects to. */
|
|
43
|
+
readonly callbackPath: string;
|
|
44
|
+
/** Attempt lifetime; defaults to three minutes. */
|
|
45
|
+
readonly timeoutMs?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Resolve anything `buildAuthorizeUrl` needs but only an async step can fetch,
|
|
48
|
+
* such as OIDC discovery endpoints. Called before the listener is opened.
|
|
49
|
+
*/
|
|
50
|
+
prepare?(): Promise<void>;
|
|
51
|
+
/** Build the URL the user's browser opens. */
|
|
52
|
+
buildAuthorizeUrl(input: LoopbackAuthorizeInput): string;
|
|
53
|
+
/** Exchange the authorization code for a session. */
|
|
54
|
+
exchange(input: LoopbackExchangeInput<S>): Promise<S>;
|
|
55
|
+
}
|
|
56
|
+
/** Attempt lifetime when a spec names none. */
|
|
57
|
+
export declare const DEFAULT_FLOW_TIMEOUT_MS = 180000;
|
|
58
|
+
/** Message every cancelled attempt rejects with. */
|
|
59
|
+
export declare const LOGIN_CANCELLED_MESSAGE = "login cancelled";
|
|
60
|
+
/**
|
|
61
|
+
* Owns at most one in-flight loopback login per provider.
|
|
62
|
+
*
|
|
63
|
+
* A listener is created per attempt and closed as soon as the attempt settles,
|
|
64
|
+
* so nothing stays open on this machine between logins.
|
|
65
|
+
*/
|
|
66
|
+
export declare class LoopbackFlow<S extends SessionBase> implements AuthFlow<S> {
|
|
67
|
+
private readonly key;
|
|
68
|
+
private readonly spec;
|
|
69
|
+
private readonly host;
|
|
70
|
+
private readonly attempts;
|
|
71
|
+
/**
|
|
72
|
+
* @param key - provider route key, so one attempt runs at a time per route.
|
|
73
|
+
* @param spec - static flow facts for this provider.
|
|
74
|
+
*/
|
|
75
|
+
constructor(key: string, spec: LoopbackFlowSpec<S>, host?: string);
|
|
76
|
+
pending(): AuthAttempt<S> | undefined;
|
|
77
|
+
start(): Promise<AuthAttempt<S>>;
|
|
78
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback OAuth: PKCE helpers plus a temporary local redirect listener.
|
|
3
|
+
*
|
|
4
|
+
* Providers that complete authorization in the browser (xAI, Google) redirect to
|
|
5
|
+
* a `127.0.0.1` URL this process owns for the duration of one attempt. The
|
|
6
|
+
* authorization code never leaves the machine, and `state` is validated before
|
|
7
|
+
* the code is accepted so a stray or replayed redirect cannot settle the login.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-plugin-subscription-oauth/auth/pkce-flow
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
12
|
+
import { createServer } from 'node:http';
|
|
13
|
+
/** Mint a fresh PKCE pair (32-byte verifier, S256 challenge). */
|
|
14
|
+
export function createPkce() {
|
|
15
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
16
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
17
|
+
return { verifier, challenge };
|
|
18
|
+
}
|
|
19
|
+
/** Mint a random OAuth `state` value. */
|
|
20
|
+
export function randomState() {
|
|
21
|
+
return randomBytes(32).toString('base64url');
|
|
22
|
+
}
|
|
23
|
+
/** Attempt lifetime when a spec names none. */
|
|
24
|
+
export const DEFAULT_FLOW_TIMEOUT_MS = 180_000;
|
|
25
|
+
/** Message every cancelled attempt rejects with. */
|
|
26
|
+
export const LOGIN_CANCELLED_MESSAGE = 'login cancelled';
|
|
27
|
+
const SUCCESS_PAGE = '<!doctype html><html><head><meta charset="utf-8"><title>Login successful</title></head>'
|
|
28
|
+
+ '<body style="font-family:sans-serif"><h1>Login successful</h1>'
|
|
29
|
+
+ '<p>You can close this tab and return to DeepSeek Harness.</p></body></html>';
|
|
30
|
+
/**
|
|
31
|
+
* Addresses one listen host covers.
|
|
32
|
+
*
|
|
33
|
+
* `localhost` resolves to `::1` or `127.0.0.1` depending on the client, and a
|
|
34
|
+
* listener binds exactly one family per call — a browser picking the other gets
|
|
35
|
+
* connection-refused and the login times out, so both families must serve.
|
|
36
|
+
*/
|
|
37
|
+
function listenHosts(host) {
|
|
38
|
+
return host === 'localhost' ? ['127.0.0.1', '::1'] : [host];
|
|
39
|
+
}
|
|
40
|
+
/** True when an address family simply does not exist here (safe to skip). */
|
|
41
|
+
function familyUnavailable(error) {
|
|
42
|
+
const code = error.code;
|
|
43
|
+
return code === 'EADDRNOTAVAIL' || code === 'EPROTONOSUPPORT';
|
|
44
|
+
}
|
|
45
|
+
/** Listen on the first port free on every loopback family. */
|
|
46
|
+
async function listen(handler, host, ports) {
|
|
47
|
+
const hosts = listenHosts(host);
|
|
48
|
+
let lastError;
|
|
49
|
+
for (const candidate of ports) {
|
|
50
|
+
const servers = [];
|
|
51
|
+
let port = candidate;
|
|
52
|
+
let unusable = false;
|
|
53
|
+
for (const address of hosts) {
|
|
54
|
+
const server = createServer(handler);
|
|
55
|
+
try {
|
|
56
|
+
await new Promise((resolve, reject) => {
|
|
57
|
+
const onError = (error) => reject(error);
|
|
58
|
+
server.once('error', onError);
|
|
59
|
+
server.listen(port, address, () => {
|
|
60
|
+
server.removeListener('error', onError);
|
|
61
|
+
resolve();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
const bound = server.address();
|
|
65
|
+
if (bound === null)
|
|
66
|
+
throw new Error(`callback listener on ${address}:${String(port)} has no address`);
|
|
67
|
+
if (port === 0)
|
|
68
|
+
port = bound.port;
|
|
69
|
+
servers.push(server);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
server.close();
|
|
73
|
+
if (familyUnavailable(error))
|
|
74
|
+
continue;
|
|
75
|
+
lastError = error;
|
|
76
|
+
unusable = true;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (unusable || servers.length === 0) {
|
|
81
|
+
for (const server of servers)
|
|
82
|
+
server.close();
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
return { servers, port };
|
|
86
|
+
}
|
|
87
|
+
throw lastError instanceof Error
|
|
88
|
+
? lastError
|
|
89
|
+
: new Error(`callback listener could not bind ${host} (ports ${ports.join(', ')})`);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Owns at most one in-flight loopback login per provider.
|
|
93
|
+
*
|
|
94
|
+
* A listener is created per attempt and closed as soon as the attempt settles,
|
|
95
|
+
* so nothing stays open on this machine between logins.
|
|
96
|
+
*/
|
|
97
|
+
export class LoopbackFlow {
|
|
98
|
+
key;
|
|
99
|
+
spec;
|
|
100
|
+
host;
|
|
101
|
+
attempts = new Map();
|
|
102
|
+
/**
|
|
103
|
+
* @param key - provider route key, so one attempt runs at a time per route.
|
|
104
|
+
* @param spec - static flow facts for this provider.
|
|
105
|
+
*/
|
|
106
|
+
constructor(key, spec, host = '127.0.0.1') {
|
|
107
|
+
this.key = key;
|
|
108
|
+
this.spec = spec;
|
|
109
|
+
this.host = host;
|
|
110
|
+
}
|
|
111
|
+
pending() {
|
|
112
|
+
return this.attempts.get(this.key);
|
|
113
|
+
}
|
|
114
|
+
async start() {
|
|
115
|
+
if (this.attempts.has(this.key)) {
|
|
116
|
+
throw new Error(`a ${this.key} login attempt is already in progress`);
|
|
117
|
+
}
|
|
118
|
+
const pkce = createPkce();
|
|
119
|
+
const state = randomState();
|
|
120
|
+
const timeoutMs = this.spec.timeoutMs ?? DEFAULT_FLOW_TIMEOUT_MS;
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
// Discovery-backed specs resolve their endpoints before anything is bound.
|
|
123
|
+
await this.spec.prepare?.();
|
|
124
|
+
let settle;
|
|
125
|
+
let fail;
|
|
126
|
+
const session = new Promise((resolve, reject) => {
|
|
127
|
+
settle = resolve;
|
|
128
|
+
fail = reject;
|
|
129
|
+
});
|
|
130
|
+
session.catch(() => undefined);
|
|
131
|
+
let settled = false;
|
|
132
|
+
let timer;
|
|
133
|
+
let servers = [];
|
|
134
|
+
/** The bound port, known once the listener is up; the handler runs later. */
|
|
135
|
+
let port = 0;
|
|
136
|
+
const finish = (error, value) => {
|
|
137
|
+
if (settled)
|
|
138
|
+
return;
|
|
139
|
+
settled = true;
|
|
140
|
+
if (timer !== undefined)
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
for (const server of servers) {
|
|
143
|
+
server.close();
|
|
144
|
+
server.closeAllConnections();
|
|
145
|
+
}
|
|
146
|
+
this.attempts.delete(this.key);
|
|
147
|
+
controller.abort();
|
|
148
|
+
if (error !== undefined)
|
|
149
|
+
fail(error);
|
|
150
|
+
else if (value !== undefined)
|
|
151
|
+
settle(value);
|
|
152
|
+
};
|
|
153
|
+
const handler = (request, response) => {
|
|
154
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
155
|
+
if (url.pathname !== this.spec.callbackPath) {
|
|
156
|
+
response.writeHead(404, { 'content-type': 'text/plain' });
|
|
157
|
+
response.end('not found');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const reported = url.searchParams.get('error_description') ?? url.searchParams.get('error');
|
|
161
|
+
if (reported !== null) {
|
|
162
|
+
response.writeHead(200, { 'content-type': 'text/html' });
|
|
163
|
+
response.end('<!doctype html><html><body><h1>Login failed</h1></body></html>');
|
|
164
|
+
finish(new Error(`authorization failed: ${reported}`));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (url.searchParams.get('state') !== state) {
|
|
168
|
+
// A stray or replayed redirect must not kill the real attempt.
|
|
169
|
+
response.writeHead(400, { 'content-type': 'text/plain' });
|
|
170
|
+
response.end('state mismatch');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const code = url.searchParams.get('code');
|
|
174
|
+
if (code === null || code.length === 0) {
|
|
175
|
+
response.writeHead(400, { 'content-type': 'text/plain' });
|
|
176
|
+
response.end('missing authorization code');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
response.writeHead(200, { 'content-type': 'text/html' });
|
|
180
|
+
response.end(SUCCESS_PAGE);
|
|
181
|
+
const redirectUri = `http://${this.host}:${String(port)}${this.spec.callbackPath}`;
|
|
182
|
+
void this.spec.exchange({ code, redirectUri, pkce, state, signal: controller.signal })
|
|
183
|
+
.then((session) => { finish(undefined, session); })
|
|
184
|
+
.catch((error) => {
|
|
185
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
const bound = await listen(handler, this.host, this.spec.ports);
|
|
189
|
+
servers = bound.servers;
|
|
190
|
+
port = bound.port;
|
|
191
|
+
const redirectUri = `http://${this.host}:${String(port)}${this.spec.callbackPath}`;
|
|
192
|
+
timer = setTimeout(() => {
|
|
193
|
+
finish(new Error(`login timed out after ${String(Math.round(timeoutMs / 1000))}s`));
|
|
194
|
+
}, timeoutMs);
|
|
195
|
+
timer.unref?.();
|
|
196
|
+
const prompt = { url: this.spec.buildAuthorizeUrl({ redirectUri, state, pkce }), expiresAt: Date.now() + timeoutMs, mode: 'open-url' };
|
|
197
|
+
const attempt = {
|
|
198
|
+
prompt,
|
|
199
|
+
session,
|
|
200
|
+
cancel: () => { finish(new Error(LOGIN_CANCELLED_MESSAGE)); },
|
|
201
|
+
};
|
|
202
|
+
this.attempts.set(this.key, attempt);
|
|
203
|
+
return attempt;
|
|
204
|
+
}
|
|
205
|
+
}
|