openyida 2026.9.23 → 2026.9.24-beta.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 +9 -2
- package/bin/yida.js +15 -1
- package/lib/auth/oauth-device.js +311 -0
- package/lib/auth/token-auth.js +75 -22
- package/lib/core/command-manifest.js +1 -1
- package/lib/core/locales/en.js +14 -2
- package/lib/core/locales/zh.js +14 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,10 +74,17 @@ openyida login
|
|
|
74
74
|
|
|
75
75
|
OpenYida login defaults to OAuth token mode. It opens the DingTalk OAuth authorization page, receives the local loopback callback, exchanges `code` / `authCode` with the Yida server, and stores `access_token` / `refresh_token` in the user auth store when available. If the user auth store is not writable, OpenYida explicitly falls back to the current project cache and reports that narrower persistence scope.
|
|
76
76
|
|
|
77
|
+
In a sandbox, headless shell, or CI dashboard where the loopback callback cannot complete, use the device authorization flow. Open the displayed verification URL on any device and enter the user code. `--env-hint` optionally pins the intended environment profile at authorization time:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
openyida login --device
|
|
81
|
+
openyida login --device --env-hint pre
|
|
82
|
+
```
|
|
83
|
+
|
|
77
84
|
When the user names a target Yida entry URL, pass it to the login command so OpenYida can select the matching environment and auth profile. For example:
|
|
78
85
|
|
|
79
86
|
```bash
|
|
80
|
-
openyida login https
|
|
87
|
+
openyida login https://<your-yida-entry-url>/
|
|
81
88
|
openyida login --alibaba
|
|
82
89
|
```
|
|
83
90
|
|
|
@@ -383,7 +390,7 @@ Run `openyida --help` or `openyida <command> --help` for detailed usage.
|
|
|
383
390
|
|
|
384
391
|
| Command | Description |
|
|
385
392
|
|---------|-------------|
|
|
386
|
-
| `openyida login [target-url] [--env <name>\|--intl\|--overseas\|--global\|--yidaapps\|--alibaba] [--client-id <clientId>] [--endpoint <url>] [--no-browser]` | Login with OAuth token mode |
|
|
393
|
+
| `openyida login [target-url] [--env <name>\|--intl\|--overseas\|--global\|--yidaapps\|--alibaba] [--client-id <clientId>] [--endpoint <url>] [--no-browser] [--device] [--env-hint <name>]` | Login with OAuth token mode |
|
|
387
394
|
| `openyida logout` | Logout / unbind current project auth |
|
|
388
395
|
| `openyida auth <status\|login\|refresh\|logout\|profiles\|profile switch>` | Token login state and profile management |
|
|
389
396
|
| `openyida org <list\|switch> [--json] [--corp-id <corpId>]` | Organization management (list / switch existing profiles first) |
|
package/bin/yida.js
CHANGED
|
@@ -193,6 +193,7 @@ function printLoginResult(result) {
|
|
|
193
193
|
|
|
194
194
|
if (!result) {
|
|
195
195
|
console.log(JSON.stringify({
|
|
196
|
+
type: 'login_result',
|
|
196
197
|
ok: false,
|
|
197
198
|
status: 'login_failed',
|
|
198
199
|
can_auto_use: false,
|
|
@@ -205,6 +206,7 @@ function printLoginResult(result) {
|
|
|
205
206
|
? !!result.ok
|
|
206
207
|
: (result.can_auto_use !== false && !!result.access_token);
|
|
207
208
|
const summary = {
|
|
209
|
+
type: 'login_result',
|
|
208
210
|
ok: tokenUsable,
|
|
209
211
|
status: result.status || (tokenUsable ? 'ok' : 'token_not_issued'),
|
|
210
212
|
auth_mode: 'token',
|
|
@@ -279,6 +281,7 @@ function getFirstPositionalArg(cliArgs, startIndex = 0) {
|
|
|
279
281
|
'--login-url',
|
|
280
282
|
'--profile',
|
|
281
283
|
'--user-id',
|
|
284
|
+
'--env-hint',
|
|
282
285
|
]);
|
|
283
286
|
for (let index = startIndex; index < cliArgs.length; index++) {
|
|
284
287
|
const arg = cliArgs[index];
|
|
@@ -386,6 +389,7 @@ function applyLoginEnvironmentFlags(cliArgs, options = {}) {
|
|
|
386
389
|
'--client-id',
|
|
387
390
|
'--profile',
|
|
388
391
|
'--user-id',
|
|
392
|
+
'--env-hint',
|
|
389
393
|
]);
|
|
390
394
|
const targetUrlFlags = new Set([
|
|
391
395
|
'--endpoint',
|
|
@@ -537,13 +541,23 @@ function printLoginHelp() {
|
|
|
537
541
|
}
|
|
538
542
|
|
|
539
543
|
function buildTokenLoginOptions(loginArgs) {
|
|
544
|
+
const device = loginArgs.includes('--device');
|
|
540
545
|
return {
|
|
541
546
|
clientId: getArgValue(loginArgs, '--client-id'),
|
|
542
547
|
corpId: getArgValue(loginArgs, '--corp-id'),
|
|
543
548
|
userId: getArgValue(loginArgs, '--user-id'),
|
|
544
549
|
authProfile: getArgValue(loginArgs, '--profile'),
|
|
545
550
|
quiet: process.env.YIDA_QUIET === '1' || loginArgs.includes('--quiet'),
|
|
546
|
-
noBrowser: loginArgs.includes('--no-browser'),
|
|
551
|
+
noBrowser: device || loginArgs.includes('--no-browser'),
|
|
552
|
+
device,
|
|
553
|
+
envHint: getArgValue(loginArgs, '--env-hint'),
|
|
554
|
+
// Machine-readable progress events for agent runtimes (sandbox browsers,
|
|
555
|
+
// CI dashboards). Emits one JSON line per state transition.
|
|
556
|
+
onDeviceState: (state) => {
|
|
557
|
+
if (device) {
|
|
558
|
+
process.stdout.write(JSON.stringify({ type: 'device_login_state', ...state }) + '\n');
|
|
559
|
+
}
|
|
560
|
+
},
|
|
547
561
|
};
|
|
548
562
|
}
|
|
549
563
|
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oauth-device.js - Device Authorization Grant (RFC 8628) flow.
|
|
3
|
+
*
|
|
4
|
+
* Enables headless / sandbox login: the CLI requests a device code from the
|
|
5
|
+
* auth service, the user completes authorization on ANY device (phone,
|
|
6
|
+
* another browser, CI dashboard), and the CLI polls until tokens are issued.
|
|
7
|
+
*
|
|
8
|
+
* The /device/* endpoints use a unified camelCase contract (deviceCode,
|
|
9
|
+
* userCode, verificationUri, grantType, accessToken, ...) matching the
|
|
10
|
+
* tianshu CliAuthRpc device endpoints. Legacy CLI token endpoints
|
|
11
|
+
* (/dingtalk/token, /refresh, /status) keep their snake_case contract and are
|
|
12
|
+
* not handled here.
|
|
13
|
+
*
|
|
14
|
+
* Machine-facing state events (onState):
|
|
15
|
+
* every callback receives a plain object with a camelCase `state` field plus
|
|
16
|
+
* state-specific camelCase fields, so agent runtimes can parse one schema:
|
|
17
|
+
* { state: 'awaiting_verification', verificationUri, verificationUriComplete,
|
|
18
|
+
* userCode, expiresIn, interval }
|
|
19
|
+
* { state: 'pending', nextPollMs } (heartbeat, every poll cycle)
|
|
20
|
+
* { state: 'slow_down', intervalMs }
|
|
21
|
+
* { state: 'poll_retry', statusCode, body } (transient unrecognized error)
|
|
22
|
+
*
|
|
23
|
+
* Reuses the same token normalization / profile persistence pipeline as the
|
|
24
|
+
* loopback flow in oauth-loopback.js.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const { requestJson, getTokenPayload } = require('./token-auth');
|
|
28
|
+
const { redactString } = require('../core/redact');
|
|
29
|
+
const { t } = require('../core/i18n');
|
|
30
|
+
|
|
31
|
+
const DEVICE_CODE_PATH = '/device/code';
|
|
32
|
+
const DEVICE_TOKEN_PATH = '/device/token';
|
|
33
|
+
const DEFAULT_DEVICE_TIMEOUT_MS = 10 * 60 * 1000; // device codes are usually valid 10 minutes
|
|
34
|
+
const DEFAULT_POLL_INTERVAL_MS = 5 * 1000;
|
|
35
|
+
const SLOW_DOWN_EXTRA_DELAY_MS = 5 * 1000;
|
|
36
|
+
|
|
37
|
+
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
38
|
+
|
|
39
|
+
function createDeviceTimeoutError() {
|
|
40
|
+
const timeout = new Error(t('login.device_timeout'));
|
|
41
|
+
timeout.code = 'device_timeout';
|
|
42
|
+
return timeout;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function assertBeforeDeadline(deadline) {
|
|
46
|
+
if (Date.now() >= deadline) {
|
|
47
|
+
throw createDeviceTimeoutError();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function positiveNumber(value, fallback) {
|
|
52
|
+
const parsed = Number(value);
|
|
53
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function errorDescription(payload) {
|
|
57
|
+
return payload.errorDescription || payload.error_description || payload.errorMsg
|
|
58
|
+
|| payload.error_msg || payload.message || payload.raw || '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Request a device code from the auth service.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} options
|
|
65
|
+
* @param {string} options.authBaseUrl - e.g. https://<entry-host>/openapi/cli/v1/auth
|
|
66
|
+
* @param {string} options.clientId
|
|
67
|
+
* @param {string} [options.scope] - defaults handled by caller
|
|
68
|
+
* @param {string} [options.envHint] - environment name to pin the token audience
|
|
69
|
+
* @param {string} [options.timeoutMs] - overall flow timeout
|
|
70
|
+
* @returns {Promise<{deviceCode, userCode, verificationUri,
|
|
71
|
+
* verificationUriComplete, expiresIn, interval}>}
|
|
72
|
+
*/
|
|
73
|
+
async function requestDeviceCode(options = {}) {
|
|
74
|
+
const { authBaseUrl, clientId } = options;
|
|
75
|
+
if (!authBaseUrl || !clientId) {
|
|
76
|
+
throw new Error(t('login.device_missing_params'));
|
|
77
|
+
}
|
|
78
|
+
const body = { clientId };
|
|
79
|
+
if (options.scope) { body.scope = options.scope; }
|
|
80
|
+
if (options.envHint) { body.envHint = options.envHint; }
|
|
81
|
+
|
|
82
|
+
const deadline = positiveNumber(
|
|
83
|
+
options.deadline,
|
|
84
|
+
Date.now() + positiveNumber(options.timeoutMs, DEFAULT_DEVICE_TIMEOUT_MS)
|
|
85
|
+
);
|
|
86
|
+
assertBeforeDeadline(deadline);
|
|
87
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
88
|
+
let response;
|
|
89
|
+
try {
|
|
90
|
+
response = await requestJson(
|
|
91
|
+
'POST',
|
|
92
|
+
`${authBaseUrl}${DEVICE_CODE_PATH}`,
|
|
93
|
+
body,
|
|
94
|
+
{},
|
|
95
|
+
{ timeoutMs: remainingMs }
|
|
96
|
+
);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error.code === 'request_timeout') {
|
|
99
|
+
throw createDeviceTimeoutError();
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
const payload = getTokenPayload(response);
|
|
104
|
+
if (!payload.deviceCode || !payload.userCode || !payload.verificationUri) {
|
|
105
|
+
const message = payload.message || payload.errorDescription || payload.errorMsg
|
|
106
|
+
|| t('login.device_invalid_code_response');
|
|
107
|
+
const error = new Error(message);
|
|
108
|
+
error.payload = payload;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
return payload;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Poll the token endpoint until the device flow completes.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} options
|
|
118
|
+
* @param {string} options.authBaseUrl
|
|
119
|
+
* @param {string} options.clientId
|
|
120
|
+
* @param {string} options.deviceCode
|
|
121
|
+
* @param {number} [options.intervalMs] - from /device/code response
|
|
122
|
+
* @param {number} [options.timeoutMs] - overall wall clock budget
|
|
123
|
+
* @param {number} [options.deadline] - absolute wall clock deadline
|
|
124
|
+
* @param {(state: object) => void} [options.onState] - progress callback for agents
|
|
125
|
+
* @returns {Promise<object>} token payload (accessToken, refreshToken, ...)
|
|
126
|
+
*/
|
|
127
|
+
async function pollDeviceToken(options = {}) {
|
|
128
|
+
const { authBaseUrl, clientId, deviceCode } = options;
|
|
129
|
+
if (!authBaseUrl || !clientId || !deviceCode) {
|
|
130
|
+
throw new Error(t('login.device_poll_missing_params'));
|
|
131
|
+
}
|
|
132
|
+
const intervalMs = Math.max(1, positiveNumber(options.intervalMs, DEFAULT_POLL_INTERVAL_MS));
|
|
133
|
+
const deadline = positiveNumber(
|
|
134
|
+
options.deadline,
|
|
135
|
+
Date.now() + positiveNumber(options.timeoutMs, DEFAULT_DEVICE_TIMEOUT_MS)
|
|
136
|
+
);
|
|
137
|
+
let currentInterval = intervalMs;
|
|
138
|
+
|
|
139
|
+
// First poll immediately, then honor the interval.
|
|
140
|
+
for (;;) {
|
|
141
|
+
assertBeforeDeadline(deadline);
|
|
142
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
143
|
+
let response;
|
|
144
|
+
try {
|
|
145
|
+
response = await requestJson('POST', `${authBaseUrl}${DEVICE_TOKEN_PATH}`, {
|
|
146
|
+
grantType: DEVICE_GRANT_TYPE,
|
|
147
|
+
deviceCode: deviceCode,
|
|
148
|
+
clientId: clientId,
|
|
149
|
+
}, {}, { timeoutMs: remainingMs });
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error.code === 'request_timeout') {
|
|
152
|
+
throw createDeviceTimeoutError();
|
|
153
|
+
}
|
|
154
|
+
const payload = error.payload || {};
|
|
155
|
+
const errorCode = payload.error || payload.errorCode || payload.error_msg || payload.errorMsg;
|
|
156
|
+
if (errorCode === 'authorization_pending') {
|
|
157
|
+
await waitFor(deadline, currentInterval, options);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (errorCode === 'slow_down') {
|
|
161
|
+
currentInterval += SLOW_DOWN_EXTRA_DELAY_MS;
|
|
162
|
+
if (options.onState) {
|
|
163
|
+
options.onState({ state: 'slow_down', intervalMs: currentInterval });
|
|
164
|
+
}
|
|
165
|
+
await waitFor(deadline, currentInterval, options);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (errorCode === 'expired_token') {
|
|
169
|
+
const expired = new Error(t('login.device_code_expired'));
|
|
170
|
+
expired.code = 'device_code_expired';
|
|
171
|
+
throw expired;
|
|
172
|
+
}
|
|
173
|
+
if (errorCode === 'access_denied') {
|
|
174
|
+
const denied = new Error(t('login.device_access_denied'));
|
|
175
|
+
denied.code = 'device_access_denied';
|
|
176
|
+
throw denied;
|
|
177
|
+
}
|
|
178
|
+
if (errorCode) {
|
|
179
|
+
// RFC 8628 only permits polling to continue for
|
|
180
|
+
// authorization_pending and slow_down. Preserve the server's
|
|
181
|
+
// permanent OAuth error so callers can diagnose it immediately.
|
|
182
|
+
error.code = String(errorCode);
|
|
183
|
+
const description = errorDescription(payload);
|
|
184
|
+
if (description) { error.message = String(description); }
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
if (error.statusCode === 400 || error.statusCode === 429) {
|
|
188
|
+
// Unstructured 400/429 bodies can be produced by gateways. Retry
|
|
189
|
+
// those transient shapes, while the OAuth errors above remain
|
|
190
|
+
// terminal unless the RFC explicitly permits continued polling.
|
|
191
|
+
if (options.onState) {
|
|
192
|
+
options.onState({
|
|
193
|
+
state: 'poll_retry',
|
|
194
|
+
statusCode: error.statusCode,
|
|
195
|
+
body: redactString(String(errorDescription(payload)).slice(0, 200)),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
await waitFor(deadline, currentInterval, options);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const payload = getTokenPayload(response);
|
|
205
|
+
if (!payload.accessToken && !payload.access_token) {
|
|
206
|
+
// 2xx without a token is unexpected; surface raw payload for diagnosis.
|
|
207
|
+
const invalid = new Error(t('login.device_2xx_no_token'));
|
|
208
|
+
invalid.payload = payload;
|
|
209
|
+
throw invalid;
|
|
210
|
+
}
|
|
211
|
+
return payload;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function waitFor(deadline, intervalMs, options) {
|
|
216
|
+
const remaining = deadline - Date.now();
|
|
217
|
+
if (remaining <= 0) {
|
|
218
|
+
throw createDeviceTimeoutError();
|
|
219
|
+
}
|
|
220
|
+
if (options && options.onState) {
|
|
221
|
+
options.onState({ state: 'pending', nextPollMs: Math.min(intervalMs, remaining) });
|
|
222
|
+
}
|
|
223
|
+
return new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, remaining)));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Run the full device code flow and return a token payload for
|
|
228
|
+
* normalizeTokenResponse(). Designed to be called from tokenLogin().
|
|
229
|
+
*
|
|
230
|
+
* @param {object} options - same option bag as tokenLogin plus:
|
|
231
|
+
* options.authBaseUrl resolved auth base (with /openapi/cli/v1/auth prefix)
|
|
232
|
+
* options.envHint environment name to pin audience
|
|
233
|
+
* options.quiet suppress human-oriented stderr
|
|
234
|
+
* options.onState optional machine-readable progress callback
|
|
235
|
+
*/
|
|
236
|
+
async function runDeviceCodeFlow(options = {}) {
|
|
237
|
+
const flowDeadline = Date.now()
|
|
238
|
+
+ positiveNumber(options.timeoutMs, DEFAULT_DEVICE_TIMEOUT_MS);
|
|
239
|
+
const code = await requestDeviceCode({
|
|
240
|
+
...options,
|
|
241
|
+
deadline: flowDeadline,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// The server may return verification URIs as absolute paths (e.g.
|
|
245
|
+
// /openapi/cli/v1/auth/device/verify); resolve them against the business
|
|
246
|
+
// base URL so users and agent hosts get clickable links.
|
|
247
|
+
const resolveUrl = (value) => {
|
|
248
|
+
if (!value || /^https?:\/\//i.test(value)) {
|
|
249
|
+
return value;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const origin = new URL(options.baseUrl || options.authBaseUrl).origin;
|
|
253
|
+
return new URL(value, origin).toString();
|
|
254
|
+
} catch {
|
|
255
|
+
return value;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
const verificationUri = resolveUrl(code.verificationUri);
|
|
259
|
+
const verificationUriComplete = resolveUrl(code.verificationUriComplete);
|
|
260
|
+
|
|
261
|
+
const intro = [
|
|
262
|
+
'',
|
|
263
|
+
t('login.device_title'),
|
|
264
|
+
` 1. ${t('login.device_open')} ${verificationUri}`,
|
|
265
|
+
` 2. ${t('login.device_enter_code')} ${code.userCode}`,
|
|
266
|
+
'',
|
|
267
|
+
];
|
|
268
|
+
if (verificationUriComplete) {
|
|
269
|
+
intro.push(` ${t('login.device_or_direct')} ${verificationUriComplete}`, '');
|
|
270
|
+
}
|
|
271
|
+
if (!options.quiet) {
|
|
272
|
+
process.stderr.write(intro.join('\n') + '\n');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (options.onState) {
|
|
276
|
+
options.onState({
|
|
277
|
+
state: 'awaiting_verification',
|
|
278
|
+
verificationUri: verificationUri,
|
|
279
|
+
verificationUriComplete: verificationUriComplete,
|
|
280
|
+
userCode: code.userCode,
|
|
281
|
+
expiresIn: code.expiresIn,
|
|
282
|
+
interval: code.interval,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const tokenPayload = await pollDeviceToken({
|
|
287
|
+
authBaseUrl: options.authBaseUrl,
|
|
288
|
+
clientId: options.clientId,
|
|
289
|
+
deviceCode: code.deviceCode,
|
|
290
|
+
intervalMs: (code.interval || 5) * 1000,
|
|
291
|
+
deadline: Math.min(
|
|
292
|
+
flowDeadline,
|
|
293
|
+
Date.now() + positiveNumber(code.expiresIn, 600) * 1000
|
|
294
|
+
),
|
|
295
|
+
onState: options.onState,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
if (!options.quiet) {
|
|
299
|
+
process.stderr.write(t('login.device_completed') + '\n');
|
|
300
|
+
}
|
|
301
|
+
return tokenPayload;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
module.exports = {
|
|
305
|
+
runDeviceCodeFlow,
|
|
306
|
+
requestDeviceCode,
|
|
307
|
+
pollDeviceToken,
|
|
308
|
+
DEVICE_CODE_PATH,
|
|
309
|
+
DEVICE_TOKEN_PATH,
|
|
310
|
+
DEVICE_GRANT_TYPE,
|
|
311
|
+
};
|
package/lib/auth/token-auth.js
CHANGED
|
@@ -83,11 +83,27 @@ function resolveDingtalkLoginOrigin(options = {}) {
|
|
|
83
83
|
return DINGTALK_LOGIN_ORIGIN;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
function requestJson(method, url, body, headers = {}) {
|
|
86
|
+
function requestJson(method, url, body, headers = {}, options = {}) {
|
|
87
87
|
return new Promise((resolve, reject) => {
|
|
88
88
|
const parsedUrl = new URL(url);
|
|
89
89
|
const data = body === undefined || body === null ? null : Buffer.from(JSON.stringify(body));
|
|
90
90
|
const transport = parsedUrl.protocol === 'https:' ? https : http;
|
|
91
|
+
const socketTimeoutMs = positiveNumber(options.timeoutMs, 0);
|
|
92
|
+
let timeoutId = null;
|
|
93
|
+
let settled = false;
|
|
94
|
+
const finish = (callback, value) => {
|
|
95
|
+
if (settled) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
settled = true;
|
|
99
|
+
if (timeoutId) {
|
|
100
|
+
clearTimeout(timeoutId);
|
|
101
|
+
}
|
|
102
|
+
callback(value);
|
|
103
|
+
};
|
|
104
|
+
const resolveOnce = value => finish(resolve, value);
|
|
105
|
+
const rejectOnce = error => finish(reject, error);
|
|
106
|
+
|
|
91
107
|
const request = transport.request({
|
|
92
108
|
method,
|
|
93
109
|
protocol: parsedUrl.protocol,
|
|
@@ -105,6 +121,7 @@ function requestJson(method, url, body, headers = {}) {
|
|
|
105
121
|
}, (response) => {
|
|
106
122
|
const chunks = [];
|
|
107
123
|
response.on('data', (chunk) => chunks.push(chunk));
|
|
124
|
+
response.on('error', rejectOnce);
|
|
108
125
|
response.on('end', () => {
|
|
109
126
|
const text = Buffer.concat(chunks).toString('utf8');
|
|
110
127
|
let payload = null;
|
|
@@ -125,14 +142,26 @@ function requestJson(method, url, body, headers = {}) {
|
|
|
125
142
|
error.payload = payload;
|
|
126
143
|
error.headers = response.headers;
|
|
127
144
|
error.location = location;
|
|
128
|
-
|
|
145
|
+
rejectOnce(error);
|
|
129
146
|
return;
|
|
130
147
|
}
|
|
131
|
-
|
|
148
|
+
resolveOnce(payload || {});
|
|
132
149
|
});
|
|
133
150
|
});
|
|
134
151
|
|
|
135
|
-
|
|
152
|
+
if (socketTimeoutMs > 0) {
|
|
153
|
+
// Node's socket timeout is reset by network activity, so it cannot enforce
|
|
154
|
+
// the caller's wall-clock budget. Use an independent timer that also
|
|
155
|
+
// covers response bodies which trickle data without ever completing.
|
|
156
|
+
timeoutId = setTimeout(() => {
|
|
157
|
+
const error = new Error('request timed out');
|
|
158
|
+
error.code = 'request_timeout';
|
|
159
|
+
rejectOnce(error);
|
|
160
|
+
request.destroy(error);
|
|
161
|
+
}, socketTimeoutMs);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
request.on('error', rejectOnce);
|
|
136
165
|
if (data) {
|
|
137
166
|
request.write(data);
|
|
138
167
|
}
|
|
@@ -140,6 +169,11 @@ function requestJson(method, url, body, headers = {}) {
|
|
|
140
169
|
});
|
|
141
170
|
}
|
|
142
171
|
|
|
172
|
+
function positiveNumber(value, fallback) {
|
|
173
|
+
const parsed = Number(value);
|
|
174
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
175
|
+
}
|
|
176
|
+
|
|
143
177
|
function sanitizeRawTokenResponse(response) {
|
|
144
178
|
if (!response || typeof response !== 'object') {
|
|
145
179
|
return response;
|
|
@@ -302,25 +336,43 @@ async function tokenLogin(options = {}) {
|
|
|
302
336
|
const baseUrl = resolveTokenBaseUrl(options);
|
|
303
337
|
const authBaseUrl = appendPath(baseUrl, DEFAULT_AUTH_PATH_PREFIX);
|
|
304
338
|
const clientId = options.clientId || process.env.OPENYIDA_DINGTALK_CLIENT_ID || DINGTALK_OAUTH_CLIENT_ID;
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
339
|
+
const scope = options.scope || process.env.OPENYIDA_DINGTALK_SCOPE || 'openid corpid';
|
|
340
|
+
|
|
341
|
+
let response;
|
|
342
|
+
if (options.device) {
|
|
343
|
+
// Device Authorization Grant (RFC 8628): sandbox / headless login.
|
|
344
|
+
const { runDeviceCodeFlow } = require('./oauth-device');
|
|
345
|
+
response = await runDeviceCodeFlow({
|
|
346
|
+
authBaseUrl,
|
|
347
|
+
clientId,
|
|
348
|
+
scope,
|
|
349
|
+
envHint: options.envHint,
|
|
350
|
+
quiet: options.quiet,
|
|
351
|
+
onState: options.onDeviceState,
|
|
352
|
+
timeoutMs: options.timeoutMs || env.OPENYIDA_OAUTH_TIMEOUT_MS,
|
|
353
|
+
});
|
|
354
|
+
requireOkResponse(response);
|
|
355
|
+
} else {
|
|
356
|
+
const callback = await runDingtalkLoopback({
|
|
357
|
+
clientId,
|
|
358
|
+
loginOrigin: resolveDingtalkLoginOrigin(options),
|
|
359
|
+
scope,
|
|
360
|
+
prompt: options.prompt,
|
|
361
|
+
port: options.port,
|
|
362
|
+
quiet: options.quiet,
|
|
363
|
+
noBrowser: options.noBrowser,
|
|
364
|
+
timeoutMs: options.timeoutMs,
|
|
365
|
+
});
|
|
315
366
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
367
|
+
response = await requestJson('POST', appendPath(authBaseUrl, '/dingtalk/token'), {
|
|
368
|
+
code: callback.code,
|
|
369
|
+
authCode: callback.authCode,
|
|
370
|
+
redirectUri: callback.redirectUri,
|
|
371
|
+
state: callback.state,
|
|
372
|
+
clientId,
|
|
373
|
+
});
|
|
374
|
+
requireOkResponse(response);
|
|
375
|
+
}
|
|
324
376
|
|
|
325
377
|
const normalized = normalizeTokenResponse(response, baseUrl, clientId);
|
|
326
378
|
if (!normalized.access_token) {
|
|
@@ -624,4 +676,5 @@ module.exports = {
|
|
|
624
676
|
resolveTokenBaseUrl,
|
|
625
677
|
isRefreshAuthRequired,
|
|
626
678
|
createTokenRefreshFailedError,
|
|
679
|
+
getTokenPayload,
|
|
627
680
|
};
|
|
@@ -1035,7 +1035,7 @@ const COMMAND_GROUPS = [
|
|
|
1035
1035
|
id: 'auth',
|
|
1036
1036
|
titleKey: 'help.group_auth',
|
|
1037
1037
|
commands: [
|
|
1038
|
-
command('login', ['login'], 'login [target-url] [--env <name>|--intl|--overseas|--global|--yidaapps|--alibaba] [--client-id <clientId>] [--endpoint <url>] [--no-browser]', 'help.cmd_login', {
|
|
1038
|
+
command('login', ['login'], 'login [target-url] [--env <name>|--intl|--overseas|--global|--yidaapps|--alibaba] [--client-id <clientId>] [--endpoint <url>] [--no-browser] [--device] [--env-hint <name>]', 'help.cmd_login', {
|
|
1039
1039
|
requiresLogin: false,
|
|
1040
1040
|
output: 'json',
|
|
1041
1041
|
}),
|
package/lib/core/locales/en.js
CHANGED
|
@@ -382,8 +382,8 @@ Examples:
|
|
|
382
382
|
save_permission_usage: 'Usage: openyida save-permission <appType> <formUuid> [--package-uuid <packageUuid>] [--data-permission <json>] [--action-permission <json>]',
|
|
383
383
|
save_permission_example: "Example: openyida save-permission APP_XXX FORM-XXX --data-permission '{\"role\":\"DEFAULT\",\"dataRange\":\"SELF\"}'",
|
|
384
384
|
exec_failed: '\n❌ Execution failed: {0}',
|
|
385
|
-
login_usage: 'Usage: openyida login [entryUrl|--public|--alibaba|--intl] [--no-browser] [--check-only] [--json] [--client-id <clientId>]',
|
|
386
|
-
login_example: 'Examples:\n openyida login # Automatically open the browser via OAuth loopback login\n openyida login --no-browser # Let the caller handle the authorization URL\n openyida login --check-only --json # Check token auth status only\n openyida login --intl # Login against the international environment\n OPENYIDA_NO_BROWSER=1 openyida login # Suppress auto-opening the browser with an environment variable\n openyida auth login # Login alias',
|
|
385
|
+
login_usage: 'Usage: openyida login [entryUrl|--public|--alibaba|--intl] [--no-browser] [--device] [--env-hint <name>] [--check-only] [--json] [--client-id <clientId>]',
|
|
386
|
+
login_example: 'Examples:\n openyida login # Automatically open the browser via OAuth loopback login\n openyida login --device # Use device authorization in a headless environment\n openyida login --device --env-hint pre # Pin device authorization to an environment profile\n openyida login --no-browser # Let the caller handle the authorization URL\n openyida login --check-only --json # Check token auth status only\n openyida login --intl # Login against the international environment\n OPENYIDA_NO_BROWSER=1 openyida login # Suppress auto-opening the browser with an environment variable\n openyida auth login # Login alias',
|
|
387
387
|
login_unsupported_option: 'Removed legacy login option is no longer supported: {0}. Use token/OAuth login; env token mode reads only OPENYIDA_* tokens.',
|
|
388
388
|
auth_usage: 'Usage: openyida auth <status|login|refresh|logout|profiles|profile switch>',
|
|
389
389
|
auth_example: 'Examples:\n openyida auth status # View login status\n openyida auth profiles # List existing login profiles\n openyida auth profile switch <auth_profile> # Switch current project to an existing profile\n openyida auth login # Add a profile when the target does not exist\n openyida auth refresh # Refresh login session\n openyida auth logout # Unbind current project auth\n openyida auth logout --profile <auth_profile> # Delete a shared profile explicitly',
|
|
@@ -655,6 +655,18 @@ Examples:
|
|
|
655
655
|
oauth_success_message: 'Sign-in is complete. Return to the previous window to continue.',
|
|
656
656
|
oauth_closing: 'Closing this window automatically…',
|
|
657
657
|
oauth_manual_close: 'This window could not close automatically. Close it manually, then return to the previous window to continue.',
|
|
658
|
+
device_title: 'Device code login:',
|
|
659
|
+
device_open: 'Open',
|
|
660
|
+
device_enter_code: 'Enter code:',
|
|
661
|
+
device_or_direct: 'Or open directly:',
|
|
662
|
+
device_completed: 'Device authorization completed.',
|
|
663
|
+
device_timeout: 'device authorization timed out; device code may still be pending',
|
|
664
|
+
device_missing_params: 'device code flow requires authBaseUrl and clientId',
|
|
665
|
+
device_poll_missing_params: 'device token polling requires authBaseUrl, clientId and deviceCode',
|
|
666
|
+
device_invalid_code_response: 'auth service did not return a usable device code',
|
|
667
|
+
device_code_expired: 'device code expired; run login --device again',
|
|
668
|
+
device_access_denied: 'authorization was denied on the verification page',
|
|
669
|
+
device_2xx_no_token: 'auth service returned 2xx without accessToken',
|
|
658
670
|
oauth_failure_title: 'Sign-in not completed',
|
|
659
671
|
oauth_invalid_state: 'The sign-in request could not be verified. Return to the terminal and try again.',
|
|
660
672
|
oauth_missing_code: 'No authorization information was received. Return to the terminal and try again.',
|
package/lib/core/locales/zh.js
CHANGED
|
@@ -382,8 +382,8 @@ openyida - 宜搭命令行工具
|
|
|
382
382
|
save_permission_usage: '用法: openyida save-permission <appType> <formUuid> [--package-uuid <packageUuid>] [--data-permission <json>] [--action-permission <json>]',
|
|
383
383
|
save_permission_example: "示例: openyida save-permission APP_XXX FORM-XXX --data-permission '{\"role\":\"DEFAULT\",\"dataRange\":\"SELF\"}'",
|
|
384
384
|
exec_failed: '\n❌ 执行失败: {0}',
|
|
385
|
-
login_usage: '用法: openyida login [entryUrl|--public|--alibaba|--intl] [--no-browser] [--check-only] [--json] [--client-id <clientId>]',
|
|
386
|
-
login_example: '示例:\n openyida login # 通过 OAuth loopback 自动打开浏览器登录\n openyida login --no-browser # 不自动打开浏览器,由调用方接管授权链接\n openyida login --check-only --json # 只检查 token 登录态\n openyida login --intl # 使用国际站环境登录\n OPENYIDA_NO_BROWSER=1 openyida login # 通过环境变量抑制自动打开浏览器\n openyida auth login # 登录入口别名',
|
|
385
|
+
login_usage: '用法: openyida login [entryUrl|--public|--alibaba|--intl] [--no-browser] [--device] [--env-hint <name>] [--check-only] [--json] [--client-id <clientId>]',
|
|
386
|
+
login_example: '示例:\n openyida login # 通过 OAuth loopback 自动打开浏览器登录\n openyida login --device # 在无浏览器或沙箱环境使用设备授权登录\n openyida login --device --env-hint pre # 将设备授权限定到指定环境 profile\n openyida login --no-browser # 不自动打开浏览器,由调用方接管授权链接\n openyida login --check-only --json # 只检查 token 登录态\n openyida login --intl # 使用国际站环境登录\n OPENYIDA_NO_BROWSER=1 openyida login # 通过环境变量抑制自动打开浏览器\n openyida auth login # 登录入口别名',
|
|
387
387
|
login_unsupported_option: '已删除的旧登录参数不再支持: {0}。请使用 token/OAuth 登录;env token 模式下只读取 OPENYIDA_* token。',
|
|
388
388
|
first_run_title: ' 🤖 OpenYida - AI 问答模式已开启! ',
|
|
389
389
|
first_run_welcome: ' {0}欢迎首次使用 OpenYida!{1} 以下是快速上手指南:',
|
|
@@ -626,6 +626,18 @@ openyida - 宜搭命令行工具
|
|
|
626
626
|
oauth_success_message: '登录已完成,请返回刚才的窗口继续操作。',
|
|
627
627
|
oauth_closing: '正在自动关闭此窗口…',
|
|
628
628
|
oauth_manual_close: '此窗口未能自动关闭,请手动关闭后返回刚才的窗口继续操作。',
|
|
629
|
+
device_title: '设备码登录:',
|
|
630
|
+
device_open: '打开',
|
|
631
|
+
device_enter_code: '输入验证码:',
|
|
632
|
+
device_or_direct: '或直接打开:',
|
|
633
|
+
device_completed: '设备授权完成。',
|
|
634
|
+
device_timeout: '设备授权超时;设备码可能仍在等待验证',
|
|
635
|
+
device_missing_params: '设备码流程需要 authBaseUrl 和 clientId',
|
|
636
|
+
device_poll_missing_params: '设备令牌轮询需要 authBaseUrl、clientId 和 deviceCode',
|
|
637
|
+
device_invalid_code_response: '认证服务未返回可用的设备码',
|
|
638
|
+
device_code_expired: '设备码已过期;请重新执行 login --device',
|
|
639
|
+
device_access_denied: '授权已在验证页面被拒绝',
|
|
640
|
+
device_2xx_no_token: '认证服务返回 2xx 但未包含 accessToken',
|
|
629
641
|
oauth_failure_title: '登录未完成',
|
|
630
642
|
oauth_invalid_state: '登录验证信息无效,请返回终端重新发起登录。',
|
|
631
643
|
oauth_missing_code: '未收到登录授权信息,请返回终端重新发起登录。',
|