dsh-plugin-subscription-oauth 0.2.1 → 0.3.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/README.md +14 -1
- package/README.zh.md +371 -0
- package/lib/auth/ark-control.d.ts +180 -0
- package/lib/auth/ark-control.js +304 -0
- package/lib/auth/ark-flow.d.ts +134 -0
- package/lib/auth/ark-flow.js +239 -0
- package/lib/client/SubscriptionsSection.js +1 -9
- package/lib/client.js +1 -5
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +2 -2
- package/lib/index.js +15 -2
- package/lib/providers/ark-provider.d.ts +42 -0
- package/lib/providers/ark-provider.js +166 -0
- package/lib/providers/ark.d.ts +154 -0
- package/lib/providers/ark.js +412 -0
- package/lib/providers/auto.js +16 -0
- package/lib/providers/usage.js +49 -4
- package/package.json +1 -1
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Ark control plane: STS credentials, request signing, and allowance reads.
|
|
3
|
+
*
|
|
4
|
+
* Ark splits its API in two. The **data plane** (`ark.cn-beijing.volces.com`)
|
|
5
|
+
* takes a plan API key as a plain bearer. The **control plane**
|
|
6
|
+
* (`ark.cn-beijing.volcengineapi.com`) is Volcengine OpenAPI: every call is
|
|
7
|
+
* `POST /?Action=<name>&Version=<date>` signed with the temporary AK/SK the SSO
|
|
8
|
+
* identity was exchanged for, and the plan's allowance lives only there.
|
|
9
|
+
*
|
|
10
|
+
* The signing below was written from the official CLI's own algorithm and then
|
|
11
|
+
* checked against the live service with real credentials, which answered 200 with
|
|
12
|
+
* the same body the CLI reports.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-plugin-subscription-oauth/auth/ark-control
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
17
|
+
import { attributedHeaders } from './headers.js';
|
|
18
|
+
/** Control-plane host: the OpenAPI endpoint, not the data-plane one. */
|
|
19
|
+
export const ARK_CONTROL_HOST = 'ark.cn-beijing.volcengineapi.com';
|
|
20
|
+
/** Region every Ark OpenAPI action is scoped to. */
|
|
21
|
+
export const ARK_CONTROL_REGION = 'cn-beijing';
|
|
22
|
+
/** Service name in the signing scope. */
|
|
23
|
+
export const ARK_CONTROL_SERVICE = 'ark';
|
|
24
|
+
/** Version every Ark OpenAPI action takes. */
|
|
25
|
+
export const ARK_CONTROL_VERSION = '2024-01-01';
|
|
26
|
+
/** An RFC 3339 UTC instant in the compact form the signature wants. */
|
|
27
|
+
function amzDate(at) {
|
|
28
|
+
return at.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
29
|
+
}
|
|
30
|
+
/** HMAC-SHA256 of `value` under `key`. */
|
|
31
|
+
function hmac(key, value) {
|
|
32
|
+
return createHmac('sha256', key).update(value).digest();
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Sign one OpenAPI request the way Volcengine expects.
|
|
36
|
+
*
|
|
37
|
+
* The canonical request lists `content-type;host;x-content-sha256;x-date`, which is
|
|
38
|
+
* the set the official client signs for these actions.
|
|
39
|
+
*
|
|
40
|
+
* @param input - method, query, body, and the credentials to sign with.
|
|
41
|
+
* @returns the headers to send.
|
|
42
|
+
*/
|
|
43
|
+
export function signArkRequest(input) {
|
|
44
|
+
const contentType = input.contentType ?? 'application/x-www-form-urlencoded';
|
|
45
|
+
const at = input.at ?? new Date();
|
|
46
|
+
const xDate = amzDate(at);
|
|
47
|
+
const contentSha = createHash('sha256').update(input.body).digest('hex');
|
|
48
|
+
const signedHeaders = 'content-type;host;x-content-sha256;x-date';
|
|
49
|
+
const canonical = [
|
|
50
|
+
input.method,
|
|
51
|
+
'/',
|
|
52
|
+
input.query,
|
|
53
|
+
`content-type:${contentType}`,
|
|
54
|
+
`host:${ARK_CONTROL_HOST}`,
|
|
55
|
+
`x-content-sha256:${contentSha}`,
|
|
56
|
+
`x-date:${xDate}`,
|
|
57
|
+
'',
|
|
58
|
+
signedHeaders,
|
|
59
|
+
contentSha,
|
|
60
|
+
].join('\n');
|
|
61
|
+
const scope = `${xDate.slice(0, 8)}/${ARK_CONTROL_REGION}/${ARK_CONTROL_SERVICE}/request`;
|
|
62
|
+
const stringToSign = [
|
|
63
|
+
'HMAC-SHA256',
|
|
64
|
+
xDate,
|
|
65
|
+
scope,
|
|
66
|
+
createHash('sha256').update(canonical).digest('hex'),
|
|
67
|
+
].join('\n');
|
|
68
|
+
// The derived signing key is a four-step HMAC chain: date, region, service, then
|
|
69
|
+
// the literal "request".
|
|
70
|
+
const kSigning = hmac(hmac(hmac(hmac(input.credentials.secretAccessKey, xDate.slice(0, 8)), ARK_CONTROL_REGION), ARK_CONTROL_SERVICE), 'request');
|
|
71
|
+
const signature = createHmac('sha256', kSigning).update(stringToSign).digest('hex');
|
|
72
|
+
return {
|
|
73
|
+
'content-type': contentType,
|
|
74
|
+
'host': ARK_CONTROL_HOST,
|
|
75
|
+
'x-content-sha256': contentSha,
|
|
76
|
+
'x-date': xDate,
|
|
77
|
+
'x-security-token': input.credentials.sessionToken,
|
|
78
|
+
'authorization': `HMAC-SHA256 Credential=${input.credentials.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Call one control-plane action. */
|
|
82
|
+
export async function callArkControl(input) {
|
|
83
|
+
const query = `Action=${input.action}&Version=${ARK_CONTROL_VERSION}`;
|
|
84
|
+
const body = input.body ?? '';
|
|
85
|
+
const contentType = input.contentType;
|
|
86
|
+
const response = await input.fetchFn(`https://${ARK_CONTROL_HOST}/?${query}`, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: {
|
|
89
|
+
...attributedHeaders(signArkRequest({
|
|
90
|
+
method: 'POST',
|
|
91
|
+
query,
|
|
92
|
+
body,
|
|
93
|
+
credentials: input.credentials,
|
|
94
|
+
...contentType === undefined ? {} : { contentType },
|
|
95
|
+
})),
|
|
96
|
+
},
|
|
97
|
+
body,
|
|
98
|
+
...input.signal === undefined ? {} : { signal: input.signal },
|
|
99
|
+
});
|
|
100
|
+
const payload = await response.json().catch(() => undefined);
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
throw new Error(`Ark control plane refused ${input.action} (HTTP ${response.status})`);
|
|
103
|
+
}
|
|
104
|
+
return payload;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Mint one ordinary API key for the account.
|
|
108
|
+
*
|
|
109
|
+
* The data plane refuses STS credentials (`403 Unsupported sts authentication
|
|
110
|
+
* method`) and takes only an API key, so this is the step that turns a sign-in into
|
|
111
|
+
* a usable credential. The shape is not guesswork — it was learned from the
|
|
112
|
+
* service's own validation, which reports each missing parameter in turn:
|
|
113
|
+
* `Name`, then `ProjectName`, then `ResourceInstances`, then
|
|
114
|
+
* `AccessControlInfo.AllowAll`. `ResourceInstances` must be a non-empty array (an
|
|
115
|
+
* empty one is rejected as invalid, while one empty object is accepted), and the
|
|
116
|
+
* body has to be JSON, because a nested structure is not expressible in the form
|
|
117
|
+
* encoding the flat actions use.
|
|
118
|
+
*
|
|
119
|
+
* The reply carries only an `Id`; the key itself comes from {@link readArkApiKey}.
|
|
120
|
+
*
|
|
121
|
+
* @param input - action inputs plus the credentials to sign with.
|
|
122
|
+
* @returns the new key's id.
|
|
123
|
+
* @throws {Error} when the control plane refuses.
|
|
124
|
+
*/
|
|
125
|
+
export async function createArkApiKey(input) {
|
|
126
|
+
const payload = await callArkControl({
|
|
127
|
+
action: 'CreateApiKey',
|
|
128
|
+
credentials: input.credentials,
|
|
129
|
+
fetchFn: input.fetchFn,
|
|
130
|
+
contentType: 'application/json',
|
|
131
|
+
body: JSON.stringify({
|
|
132
|
+
Name: input.name,
|
|
133
|
+
ProjectName: input.project ?? 'default',
|
|
134
|
+
ResourceInstances: [{}],
|
|
135
|
+
AccessControlInfo: { AllowAll: true },
|
|
136
|
+
}),
|
|
137
|
+
...input.signal === undefined ? {} : { signal: input.signal },
|
|
138
|
+
});
|
|
139
|
+
const id = num(payload.Result?.Id);
|
|
140
|
+
if (id === undefined)
|
|
141
|
+
throw new Error('Ark CreateApiKey returned no key id');
|
|
142
|
+
return id;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Read one key's value.
|
|
146
|
+
*
|
|
147
|
+
* @param input - the key's id and the credentials to sign with.
|
|
148
|
+
* @returns the key, as the data plane expects it.
|
|
149
|
+
* @throws {Error} when the control plane refuses or states no key.
|
|
150
|
+
*/
|
|
151
|
+
export async function readArkApiKey(input) {
|
|
152
|
+
const payload = await callArkControl({
|
|
153
|
+
action: 'GetRawApiKey',
|
|
154
|
+
credentials: input.credentials,
|
|
155
|
+
fetchFn: input.fetchFn,
|
|
156
|
+
contentType: 'application/json',
|
|
157
|
+
body: JSON.stringify({ Id: input.id, ProjectName: input.project ?? 'default' }),
|
|
158
|
+
...input.signal === undefined ? {} : { signal: input.signal },
|
|
159
|
+
});
|
|
160
|
+
const key = typeof payload.Result?.ApiKey === 'string' ? payload.Result.ApiKey.trim() : '';
|
|
161
|
+
if (key === '')
|
|
162
|
+
throw new Error('Ark GetRawApiKey returned no key');
|
|
163
|
+
return key;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Every key the account already has, newest first as the service lists them.
|
|
167
|
+
*
|
|
168
|
+
* @param input - the credentials to sign with.
|
|
169
|
+
* @returns the keys' ids and names.
|
|
170
|
+
*/
|
|
171
|
+
export async function listArkApiKeys(input) {
|
|
172
|
+
const payload = await callArkControl({
|
|
173
|
+
action: 'ListApiKeys',
|
|
174
|
+
credentials: input.credentials,
|
|
175
|
+
fetchFn: input.fetchFn,
|
|
176
|
+
contentType: 'application/json',
|
|
177
|
+
body: JSON.stringify({ PageSize: 50, ProjectName: input.project ?? 'default' }),
|
|
178
|
+
...input.signal === undefined ? {} : { signal: input.signal },
|
|
179
|
+
});
|
|
180
|
+
const items = payload.Result?.Items;
|
|
181
|
+
if (!Array.isArray(items))
|
|
182
|
+
return [];
|
|
183
|
+
const out = [];
|
|
184
|
+
for (const raw of items) {
|
|
185
|
+
if (typeof raw !== 'object' || raw === null)
|
|
186
|
+
continue;
|
|
187
|
+
const record = raw;
|
|
188
|
+
const id = num(record.Id);
|
|
189
|
+
if (id === undefined)
|
|
190
|
+
continue;
|
|
191
|
+
const name = typeof record.Name === 'string' && record.Name.trim() !== '' ? record.Name.trim() : undefined;
|
|
192
|
+
const status = typeof record.Status === 'string' && record.Status.trim() !== '' ? record.Status.trim() : undefined;
|
|
193
|
+
out.push({ id, ...name === undefined ? {} : { name }, ...status === undefined ? {} : { status } });
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
/** Read a finite number from a wire field. */
|
|
198
|
+
function num(value) {
|
|
199
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
200
|
+
return value;
|
|
201
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
202
|
+
const parsed = Number(value);
|
|
203
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Read every model's free inference allowance.
|
|
209
|
+
*
|
|
210
|
+
* This is the allowance that exists without a subscription — the plan quota is a
|
|
211
|
+
* separate action that reports `Status: Reclaimed` for an account with no plan.
|
|
212
|
+
* Free usage is per model, and the free-usage field is omitted for models that have
|
|
213
|
+
* none, so those are skipped rather than reported as zero.
|
|
214
|
+
*
|
|
215
|
+
* @param payload - the decoded `ListModelChargeItems` body.
|
|
216
|
+
* @returns one entry per model that states a free allowance.
|
|
217
|
+
*/
|
|
218
|
+
export function parseArkFreeUsage(payload) {
|
|
219
|
+
if (typeof payload !== 'object' || payload === null)
|
|
220
|
+
return [];
|
|
221
|
+
const result = payload.Result;
|
|
222
|
+
if (typeof result !== 'object' || result === null)
|
|
223
|
+
return [];
|
|
224
|
+
const items = result.Items;
|
|
225
|
+
if (!Array.isArray(items))
|
|
226
|
+
return [];
|
|
227
|
+
const out = [];
|
|
228
|
+
for (const raw of items) {
|
|
229
|
+
if (typeof raw !== 'object' || raw === null)
|
|
230
|
+
continue;
|
|
231
|
+
const item = raw;
|
|
232
|
+
const model = typeof item.FoundationModelName === 'string' ? item.FoundationModelName.trim() : '';
|
|
233
|
+
const usage = item.InferenceFreeUsage;
|
|
234
|
+
if (model === '' || typeof usage !== 'object' || usage === null)
|
|
235
|
+
continue;
|
|
236
|
+
const record = usage;
|
|
237
|
+
const total = num(record.Total);
|
|
238
|
+
const consumed = num(record.Consumed) ?? 0;
|
|
239
|
+
if (total === undefined || total <= 0)
|
|
240
|
+
continue;
|
|
241
|
+
out.push({ model, consumed, total });
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
/** A reset instant from an RFC 3339 string. */
|
|
246
|
+
function instant(value) {
|
|
247
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
248
|
+
return value;
|
|
249
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
250
|
+
return undefined;
|
|
251
|
+
const parsed = Date.parse(value);
|
|
252
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Read a plan's allowance out of a `GetCodingPlanUsage` / `GetAFPUsage` body.
|
|
256
|
+
*
|
|
257
|
+
* The service nests the metered periods under `coding_usage` (Coding Plan) or
|
|
258
|
+
* `afp_usage` (Agent Plan); each period is
|
|
259
|
+
* `{ label, used, total, percent, reset_at }`, which the CLI's own help documents
|
|
260
|
+
* with a worked example. An account with no plan answers `Status: Reclaimed` and no
|
|
261
|
+
* periods, which is a state to report rather than an error.
|
|
262
|
+
*
|
|
263
|
+
* @param payload - the decoded control-plane body.
|
|
264
|
+
* @returns the plan state and its periods.
|
|
265
|
+
*/
|
|
266
|
+
export function parseArkPlanUsage(payload) {
|
|
267
|
+
if (typeof payload !== 'object' || payload === null)
|
|
268
|
+
return { subscribed: false, periods: [] };
|
|
269
|
+
const result = payload.Result;
|
|
270
|
+
if (typeof result !== 'object' || result === null)
|
|
271
|
+
return { subscribed: false, periods: [] };
|
|
272
|
+
const record = result;
|
|
273
|
+
const status = typeof record.Status === 'string' && record.Status.trim() !== '' ? record.Status.trim() : undefined;
|
|
274
|
+
// The two editions name the same structure differently; either may be present.
|
|
275
|
+
const usage = [record.coding_usage, record.afp_usage, record.QuotaUsage]
|
|
276
|
+
.find((candidate) => typeof candidate === 'object' && candidate !== null);
|
|
277
|
+
const rawPeriods = usage?.periods ?? record.periods;
|
|
278
|
+
const periods = [];
|
|
279
|
+
if (Array.isArray(rawPeriods)) {
|
|
280
|
+
for (const raw of rawPeriods) {
|
|
281
|
+
if (typeof raw !== 'object' || raw === null)
|
|
282
|
+
continue;
|
|
283
|
+
const period = raw;
|
|
284
|
+
const label = typeof period.label === 'string' && period.label.trim() !== '' ? period.label.trim() : undefined;
|
|
285
|
+
if (label === undefined)
|
|
286
|
+
continue;
|
|
287
|
+
const resetsAt = instant(period.reset_at);
|
|
288
|
+
const used = num(period.used);
|
|
289
|
+
const total = num(period.total);
|
|
290
|
+
const percent = num(period.percent);
|
|
291
|
+
periods.push({
|
|
292
|
+
label,
|
|
293
|
+
...used === undefined ? {} : { used },
|
|
294
|
+
...total === undefined ? {} : { total },
|
|
295
|
+
...percent === undefined ? {} : { percent },
|
|
296
|
+
...resetsAt === undefined ? {} : { resetsAt },
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const subscribed = typeof record.subscribed === 'boolean'
|
|
301
|
+
? record.subscribed
|
|
302
|
+
: periods.length > 0 || (status !== undefined && status.toLowerCase() !== 'reclaimed');
|
|
303
|
+
return { subscribed, ...status === undefined ? {} : { status }, periods };
|
|
304
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Volcengine Ark's `volc-sso` sign-in (China region).
|
|
3
|
+
*
|
|
4
|
+
* The contract here was read out of the official `@volcengine/ark-cli` 1.0.38
|
|
5
|
+
* binary — whose SHA-256 matches the manifest its npm package publishes — and then
|
|
6
|
+
* confirmed by a real sign-in that CLI performed, whose stored state and debug
|
|
7
|
+
* traffic are quoted below.
|
|
8
|
+
*
|
|
9
|
+
* The chain is three-legged, and only the first leg is OAuth:
|
|
10
|
+
*
|
|
11
|
+
* 1. **SSO** — `GET /authorize/oauth/authorize` with `response_type=code`, the
|
|
12
|
+
* devtools client id, a loopback `redirect_uri`, `scope=Console:All:All`,
|
|
13
|
+
* `state` and an S256 PKCE challenge; then `POST /authorize/oauth/token` with
|
|
14
|
+
* `grant_type=authorization_code` and the verifier. The reply is
|
|
15
|
+
* `{ id_token, refresh_token, client_id, timestamp_ms, expires_in: 900 }`,
|
|
16
|
+
* which is exactly what the CLI stores in `identities/<id>/token.json`.
|
|
17
|
+
* 2. **STS** — the id token is exchanged for temporary `{ ak, sk, session_token,
|
|
18
|
+
* expires_at }` credentials (`identities/<id>/sts.json`).
|
|
19
|
+
* 3. **A plan API key** — the signed control plane mints an "ordinary ARK API Key"
|
|
20
|
+
* for that identity, and *that* key is what the data plane accepts.
|
|
21
|
+
*
|
|
22
|
+
* The data plane refuses the other two outright, which is how the plugin knows the
|
|
23
|
+
* key is the credential to keep: an id token bearer answers
|
|
24
|
+
* `403 Unsupported zti authentication method`, an AK/SK signature answers
|
|
25
|
+
* `403 Unsupported aksk authentication method`, while a UUID-shaped key is looked
|
|
26
|
+
* up (`401 The API key doesn't exist`).
|
|
27
|
+
*
|
|
28
|
+
* It is a public client: the exchange is `client_secret_post` with no secret, and
|
|
29
|
+
* the callback is a loopback listener, so the plugin's existing `LoopbackFlow`
|
|
30
|
+
* serves it unchanged.
|
|
31
|
+
*
|
|
32
|
+
* @module dsh-plugin-subscription-oauth/auth/ark-flow
|
|
33
|
+
*/
|
|
34
|
+
import type { PkcePair } from './pkce-flow.js';
|
|
35
|
+
import type { ArkStsCredentials } from './ark-control.js';
|
|
36
|
+
import type { FetchFn } from '../http.js';
|
|
37
|
+
/** Identity host both the authorize and token endpoints live on. */
|
|
38
|
+
export declare const ARK_SSO_ORIGIN = "https://signin.volcengine.com";
|
|
39
|
+
/** Where the browser is sent to approve the sign-in. */
|
|
40
|
+
export declare const ARK_AUTHORIZE_URL = "https://signin.volcengine.com/authorize/oauth/authorize";
|
|
41
|
+
/** Where the authorization code is redeemed. */
|
|
42
|
+
export declare const ARK_TOKEN_URL = "https://signin.volcengine.com/authorize/oauth/token";
|
|
43
|
+
/**
|
|
44
|
+
* The CLI's devtools client for a sign-in on this machine.
|
|
45
|
+
*
|
|
46
|
+
* It is a public client identifier, not a secret: `client_secret_post` is used with
|
|
47
|
+
* no secret at all, which is what the binary shows for both devtools clients.
|
|
48
|
+
*/
|
|
49
|
+
export declare const ARK_CLIENT_ID = "trn:signin:::devtools/same-device";
|
|
50
|
+
/** Client the CLI uses when the browser is on another device. */
|
|
51
|
+
export declare const ARK_CROSS_DEVICE_CLIENT_ID = "trn:signin:::devtools/cross-device";
|
|
52
|
+
/**
|
|
53
|
+
* Scope the console client asks for.
|
|
54
|
+
*
|
|
55
|
+
* Taken from a live sign-in the official CLI performed: the authorize URL it
|
|
56
|
+
* printed carried `scope=Console:All:All`, which this module originally omitted.
|
|
57
|
+
*/
|
|
58
|
+
export declare const ARK_SCOPE = "Console:All:All";
|
|
59
|
+
/** Loopback path the identity service redirects to. */
|
|
60
|
+
export declare const ARK_CALLBACK_PATH = "/oauth/callback";
|
|
61
|
+
/**
|
|
62
|
+
* Ports tried in order for the callback listener.
|
|
63
|
+
*
|
|
64
|
+
* The CLI binds an ephemeral port and puts it in the redirect URI, so no port is
|
|
65
|
+
* privileged by the service; a short fixed list keeps the attempt predictable and
|
|
66
|
+
* stays out of the ranges the other routes already use (56121, 51121).
|
|
67
|
+
*/
|
|
68
|
+
export declare const ARK_CALLBACK_PORTS: readonly [51789, 51790, 51791, 51792];
|
|
69
|
+
/** What one completed sign-in yields. */
|
|
70
|
+
export interface ArkTokenSet {
|
|
71
|
+
/** Identity token the SSO issued. */
|
|
72
|
+
readonly idToken: string;
|
|
73
|
+
/**
|
|
74
|
+
* Temporary STS credentials, when the service returned them.
|
|
75
|
+
*
|
|
76
|
+
* The SSO endpoint's `access_token` is not an opaque bearer: it is a JSON document
|
|
77
|
+
* — `token_type: urn:ietf:params:oauth:token-type:access_token_sts` — carrying
|
|
78
|
+
* `access_key_id` / `secret_access_key` / `session_token`. Those sign the control
|
|
79
|
+
* plane, which is where the plan allowance lives, so this is the credential the
|
|
80
|
+
* quota reader needs. It is obtained from the same call as the id token; the
|
|
81
|
+
* account's broker is not involved.
|
|
82
|
+
*/
|
|
83
|
+
readonly sts?: ArkStsCredentials;
|
|
84
|
+
/** Refresh token, when the service issued one. */
|
|
85
|
+
readonly refreshToken?: string;
|
|
86
|
+
/** Epoch milliseconds the id token stops being usable. */
|
|
87
|
+
readonly expiresAt: number;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Normalise the identity service's token response.
|
|
91
|
+
*
|
|
92
|
+
* @param payload - the decoded body.
|
|
93
|
+
* @param previousRefreshToken - the refresh token to keep when the reply omits one.
|
|
94
|
+
* @returns the token set.
|
|
95
|
+
* @throws {Error} when the reply carries no identity token.
|
|
96
|
+
*/
|
|
97
|
+
export declare function parseArkTokenPayload(payload: unknown, previousRefreshToken?: string): ArkTokenSet;
|
|
98
|
+
/**
|
|
99
|
+
* Build the URL the browser is sent to.
|
|
100
|
+
*
|
|
101
|
+
* @param input - loopback redirect, state, and the PKCE pair.
|
|
102
|
+
* @returns the authorize URL.
|
|
103
|
+
*/
|
|
104
|
+
export declare function buildArkAuthorizeUrl(input: {
|
|
105
|
+
redirectUri: string;
|
|
106
|
+
state: string;
|
|
107
|
+
pkce: PkcePair;
|
|
108
|
+
clientId?: string;
|
|
109
|
+
}): string;
|
|
110
|
+
/**
|
|
111
|
+
* Redeem the authorization code.
|
|
112
|
+
*
|
|
113
|
+
* @param input - fetch, the code, and the PKCE pair it was issued against.
|
|
114
|
+
* @returns the token set.
|
|
115
|
+
* @throws {Error} when the service refuses or states no identity token.
|
|
116
|
+
*/
|
|
117
|
+
export declare function exchangeArkCode(input: {
|
|
118
|
+
fetchFn: FetchFn;
|
|
119
|
+
code: string;
|
|
120
|
+
redirectUri: string;
|
|
121
|
+
pkce: PkcePair;
|
|
122
|
+
signal: AbortSignal;
|
|
123
|
+
}): Promise<ArkTokenSet>;
|
|
124
|
+
/**
|
|
125
|
+
* Renew an identity token.
|
|
126
|
+
*
|
|
127
|
+
* @param input - fetch and the stored refresh token.
|
|
128
|
+
* @returns the renewed token set, keeping the refresh token when none is reissued.
|
|
129
|
+
* @throws {Error} when the service refuses.
|
|
130
|
+
*/
|
|
131
|
+
export declare function refreshArkToken(input: {
|
|
132
|
+
fetchFn: FetchFn;
|
|
133
|
+
refreshToken: string;
|
|
134
|
+
}): Promise<ArkTokenSet>;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Volcengine Ark's `volc-sso` sign-in (China region).
|
|
3
|
+
*
|
|
4
|
+
* The contract here was read out of the official `@volcengine/ark-cli` 1.0.38
|
|
5
|
+
* binary — whose SHA-256 matches the manifest its npm package publishes — and then
|
|
6
|
+
* confirmed by a real sign-in that CLI performed, whose stored state and debug
|
|
7
|
+
* traffic are quoted below.
|
|
8
|
+
*
|
|
9
|
+
* The chain is three-legged, and only the first leg is OAuth:
|
|
10
|
+
*
|
|
11
|
+
* 1. **SSO** — `GET /authorize/oauth/authorize` with `response_type=code`, the
|
|
12
|
+
* devtools client id, a loopback `redirect_uri`, `scope=Console:All:All`,
|
|
13
|
+
* `state` and an S256 PKCE challenge; then `POST /authorize/oauth/token` with
|
|
14
|
+
* `grant_type=authorization_code` and the verifier. The reply is
|
|
15
|
+
* `{ id_token, refresh_token, client_id, timestamp_ms, expires_in: 900 }`,
|
|
16
|
+
* which is exactly what the CLI stores in `identities/<id>/token.json`.
|
|
17
|
+
* 2. **STS** — the id token is exchanged for temporary `{ ak, sk, session_token,
|
|
18
|
+
* expires_at }` credentials (`identities/<id>/sts.json`).
|
|
19
|
+
* 3. **A plan API key** — the signed control plane mints an "ordinary ARK API Key"
|
|
20
|
+
* for that identity, and *that* key is what the data plane accepts.
|
|
21
|
+
*
|
|
22
|
+
* The data plane refuses the other two outright, which is how the plugin knows the
|
|
23
|
+
* key is the credential to keep: an id token bearer answers
|
|
24
|
+
* `403 Unsupported zti authentication method`, an AK/SK signature answers
|
|
25
|
+
* `403 Unsupported aksk authentication method`, while a UUID-shaped key is looked
|
|
26
|
+
* up (`401 The API key doesn't exist`).
|
|
27
|
+
*
|
|
28
|
+
* It is a public client: the exchange is `client_secret_post` with no secret, and
|
|
29
|
+
* the callback is a loopback listener, so the plugin's existing `LoopbackFlow`
|
|
30
|
+
* serves it unchanged.
|
|
31
|
+
*
|
|
32
|
+
* @module dsh-plugin-subscription-oauth/auth/ark-flow
|
|
33
|
+
*/
|
|
34
|
+
import { attributedHeaders } from './headers.js';
|
|
35
|
+
/** Identity host both the authorize and token endpoints live on. */
|
|
36
|
+
export const ARK_SSO_ORIGIN = 'https://signin.volcengine.com';
|
|
37
|
+
/** Where the browser is sent to approve the sign-in. */
|
|
38
|
+
export const ARK_AUTHORIZE_URL = `${ARK_SSO_ORIGIN}/authorize/oauth/authorize`;
|
|
39
|
+
/** Where the authorization code is redeemed. */
|
|
40
|
+
export const ARK_TOKEN_URL = `${ARK_SSO_ORIGIN}/authorize/oauth/token`;
|
|
41
|
+
/**
|
|
42
|
+
* The CLI's devtools client for a sign-in on this machine.
|
|
43
|
+
*
|
|
44
|
+
* It is a public client identifier, not a secret: `client_secret_post` is used with
|
|
45
|
+
* no secret at all, which is what the binary shows for both devtools clients.
|
|
46
|
+
*/
|
|
47
|
+
export const ARK_CLIENT_ID = 'trn:signin:::devtools/same-device';
|
|
48
|
+
/** Client the CLI uses when the browser is on another device. */
|
|
49
|
+
export const ARK_CROSS_DEVICE_CLIENT_ID = 'trn:signin:::devtools/cross-device';
|
|
50
|
+
/**
|
|
51
|
+
* Scope the console client asks for.
|
|
52
|
+
*
|
|
53
|
+
* Taken from a live sign-in the official CLI performed: the authorize URL it
|
|
54
|
+
* printed carried `scope=Console:All:All`, which this module originally omitted.
|
|
55
|
+
*/
|
|
56
|
+
export const ARK_SCOPE = 'Console:All:All';
|
|
57
|
+
/** Loopback path the identity service redirects to. */
|
|
58
|
+
export const ARK_CALLBACK_PATH = '/oauth/callback';
|
|
59
|
+
/**
|
|
60
|
+
* Ports tried in order for the callback listener.
|
|
61
|
+
*
|
|
62
|
+
* The CLI binds an ephemeral port and puts it in the redirect URI, so no port is
|
|
63
|
+
* privileged by the service; a short fixed list keeps the attempt predictable and
|
|
64
|
+
* stays out of the ranges the other routes already use (56121, 51121).
|
|
65
|
+
*/
|
|
66
|
+
export const ARK_CALLBACK_PORTS = [51789, 51790, 51791, 51792];
|
|
67
|
+
/** Read a non-empty trimmed string. */
|
|
68
|
+
function text(value) {
|
|
69
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
|
70
|
+
}
|
|
71
|
+
/** Read a finite number. */
|
|
72
|
+
function finite(value) {
|
|
73
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
74
|
+
}
|
|
75
|
+
/** Decode one JWT's payload without verifying it, for the expiry claim. */
|
|
76
|
+
function jwtExpiry(token) {
|
|
77
|
+
const parts = token.split('.');
|
|
78
|
+
if (parts.length < 2)
|
|
79
|
+
return undefined;
|
|
80
|
+
try {
|
|
81
|
+
const payload = JSON.parse(Buffer.from(parts[1] ?? '', 'base64url').toString('utf8'));
|
|
82
|
+
if (typeof payload !== 'object' || payload === null)
|
|
83
|
+
return undefined;
|
|
84
|
+
const exp = finite(payload.exp);
|
|
85
|
+
return exp === undefined ? undefined : exp * 1000;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** How long a token set is assumed to live when nothing states it. */
|
|
92
|
+
const ASSUMED_ID_TOKEN_TTL_MS = 60 * 60 * 1000;
|
|
93
|
+
/**
|
|
94
|
+
* Read the STS credentials the SSO endpoint hands back.
|
|
95
|
+
*
|
|
96
|
+
* The `access_token` is a JSON document rather than an opaque string, and its
|
|
97
|
+
* `token_type` says what it is. A service that answers with an ordinary opaque
|
|
98
|
+
* access token yields nothing here, which is not an error: the id token still works
|
|
99
|
+
* for the sign-in, only the quota becomes unreadable.
|
|
100
|
+
*
|
|
101
|
+
* @param payload - the decoded token response.
|
|
102
|
+
* @returns the credentials, or undefined when the reply carried none.
|
|
103
|
+
*/
|
|
104
|
+
function stsFrom(payload) {
|
|
105
|
+
const accessToken = text(payload.access_token);
|
|
106
|
+
if (accessToken === undefined)
|
|
107
|
+
return undefined;
|
|
108
|
+
let parsed;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(accessToken);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
116
|
+
return undefined;
|
|
117
|
+
const record = parsed;
|
|
118
|
+
const accessKeyId = text(record.access_key_id);
|
|
119
|
+
const secretAccessKey = text(record.secret_access_key);
|
|
120
|
+
const sessionToken = text(record.session_token);
|
|
121
|
+
if (accessKeyId === undefined || secretAccessKey === undefined || sessionToken === undefined) {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
const stated = finite(payload.expires_in);
|
|
125
|
+
return {
|
|
126
|
+
accessKeyId,
|
|
127
|
+
secretAccessKey,
|
|
128
|
+
sessionToken,
|
|
129
|
+
expiresAt: Date.now() + (stated === undefined ? 900 : stated) * 1000,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Normalise the identity service's token response.
|
|
134
|
+
*
|
|
135
|
+
* @param payload - the decoded body.
|
|
136
|
+
* @param previousRefreshToken - the refresh token to keep when the reply omits one.
|
|
137
|
+
* @returns the token set.
|
|
138
|
+
* @throws {Error} when the reply carries no identity token.
|
|
139
|
+
*/
|
|
140
|
+
export function parseArkTokenPayload(payload, previousRefreshToken) {
|
|
141
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
142
|
+
throw new Error('Ark sign-in returned no token payload');
|
|
143
|
+
}
|
|
144
|
+
const record = payload;
|
|
145
|
+
const idToken = text(record.id_token) ?? text(record.access_token);
|
|
146
|
+
if (idToken === undefined)
|
|
147
|
+
throw new Error('Ark sign-in returned an empty id_token');
|
|
148
|
+
const refreshToken = text(record.refresh_token) ?? previousRefreshToken;
|
|
149
|
+
const sts = stsFrom(record);
|
|
150
|
+
const stated = finite(record.id_token_expires_at);
|
|
151
|
+
const expiresAt = stated === undefined
|
|
152
|
+
// The JWT states its own expiry; a reply that omits both is assumed short-lived
|
|
153
|
+
// rather than trusted forever.
|
|
154
|
+
? jwtExpiry(idToken) ?? Date.now() + ASSUMED_ID_TOKEN_TTL_MS
|
|
155
|
+
: stated < 1e12 ? stated * 1000 : stated;
|
|
156
|
+
return {
|
|
157
|
+
idToken,
|
|
158
|
+
...sts === undefined ? {} : { sts },
|
|
159
|
+
...refreshToken === undefined ? {} : { refreshToken },
|
|
160
|
+
expiresAt,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** Describe a refusal from the identity service. */
|
|
164
|
+
async function failure(response, what) {
|
|
165
|
+
const payload = await response.json().catch(() => undefined);
|
|
166
|
+
const record = typeof payload === 'object' && payload !== null ? payload : {};
|
|
167
|
+
const described = text(record.error_description) ?? text(record.error) ?? text(record.message);
|
|
168
|
+
return new Error(`${what} failed (HTTP ${response.status})${described === undefined ? '' : `: ${described}`}`);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Build the URL the browser is sent to.
|
|
172
|
+
*
|
|
173
|
+
* @param input - loopback redirect, state, and the PKCE pair.
|
|
174
|
+
* @returns the authorize URL.
|
|
175
|
+
*/
|
|
176
|
+
export function buildArkAuthorizeUrl(input) {
|
|
177
|
+
const url = new URL(ARK_AUTHORIZE_URL);
|
|
178
|
+
url.searchParams.set('response_type', 'code');
|
|
179
|
+
url.searchParams.set('client_id', input.clientId ?? ARK_CLIENT_ID);
|
|
180
|
+
url.searchParams.set('redirect_uri', input.redirectUri);
|
|
181
|
+
url.searchParams.set('scope', ARK_SCOPE);
|
|
182
|
+
url.searchParams.set('state', input.state);
|
|
183
|
+
url.searchParams.set('code_challenge', input.pkce.challenge);
|
|
184
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
185
|
+
return url.toString();
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Redeem the authorization code.
|
|
189
|
+
*
|
|
190
|
+
* @param input - fetch, the code, and the PKCE pair it was issued against.
|
|
191
|
+
* @returns the token set.
|
|
192
|
+
* @throws {Error} when the service refuses or states no identity token.
|
|
193
|
+
*/
|
|
194
|
+
export async function exchangeArkCode(input) {
|
|
195
|
+
const response = await input.fetchFn(ARK_TOKEN_URL, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: {
|
|
198
|
+
...attributedHeaders(),
|
|
199
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
200
|
+
accept: 'application/json',
|
|
201
|
+
},
|
|
202
|
+
body: new URLSearchParams({
|
|
203
|
+
grant_type: 'authorization_code',
|
|
204
|
+
client_id: ARK_CLIENT_ID,
|
|
205
|
+
code: input.code,
|
|
206
|
+
redirect_uri: input.redirectUri,
|
|
207
|
+
code_verifier: input.pkce.verifier,
|
|
208
|
+
}).toString(),
|
|
209
|
+
signal: input.signal,
|
|
210
|
+
});
|
|
211
|
+
if (!response.ok)
|
|
212
|
+
throw await failure(response, 'Ark token exchange');
|
|
213
|
+
return parseArkTokenPayload(await response.json());
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Renew an identity token.
|
|
217
|
+
*
|
|
218
|
+
* @param input - fetch and the stored refresh token.
|
|
219
|
+
* @returns the renewed token set, keeping the refresh token when none is reissued.
|
|
220
|
+
* @throws {Error} when the service refuses.
|
|
221
|
+
*/
|
|
222
|
+
export async function refreshArkToken(input) {
|
|
223
|
+
const response = await input.fetchFn(ARK_TOKEN_URL, {
|
|
224
|
+
method: 'POST',
|
|
225
|
+
headers: {
|
|
226
|
+
...attributedHeaders(),
|
|
227
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
228
|
+
accept: 'application/json',
|
|
229
|
+
},
|
|
230
|
+
body: new URLSearchParams({
|
|
231
|
+
grant_type: 'refresh_token',
|
|
232
|
+
client_id: ARK_CLIENT_ID,
|
|
233
|
+
refresh_token: input.refreshToken,
|
|
234
|
+
}).toString(),
|
|
235
|
+
});
|
|
236
|
+
if (!response.ok)
|
|
237
|
+
throw await failure(response, 'Ark token refresh');
|
|
238
|
+
return parseArkTokenPayload(await response.json(), input.refreshToken);
|
|
239
|
+
}
|