dsh-plugin-subscription-oauth 0.2.2 → 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.
@@ -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
+ }
package/lib/index.d.ts CHANGED
@@ -16,9 +16,9 @@ export declare const name = "dsh-plugin-subscription-oauth";
16
16
  /** The provider routes need the LLM runtime; everything else is optional. */
17
17
  export declare const inject: string[];
18
18
  /** Every route key this plugin accepts in configuration. */
19
- export declare const PROVIDER_IDS: readonly ["kimi", "grok", "antigravity", "glm"];
19
+ export declare const PROVIDER_IDS: readonly ["kimi", "grok", "antigravity", "glm", "ark"];
20
20
  /** The routes this build can actually register. */
21
- export declare const IMPLEMENTED_PROVIDER_IDS: readonly ["kimi", "grok", "antigravity", "glm"];
21
+ export declare const IMPLEMENTED_PROVIDER_IDS: readonly ["kimi", "grok", "antigravity", "glm", "ark"];
22
22
  /** One route key. */
23
23
  export type ProviderId = typeof PROVIDER_IDS[number];
24
24
  /** Plugin configuration, as accepted from `cordis.patch.yml`. */