mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 21:30:49 +08:00
237 lines
7.4 KiB
JavaScript
237 lines
7.4 KiB
JavaScript
const DEFAULT_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com';
|
|
const REGISTRATION_SOURCE = 'DING_DWS_CLAW';
|
|
|
|
function cleanString(value) {
|
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function positiveNumber(value, fallback) {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) && number > 0 ? number : fallback;
|
|
}
|
|
|
|
function normalizeBaseUrl(value) {
|
|
let url;
|
|
try {
|
|
url = new URL(cleanString(value) ?? DEFAULT_REGISTRATION_BASE_URL);
|
|
} catch {
|
|
throw new TypeError('DingTalk registration base URL must be a valid HTTPS URL');
|
|
}
|
|
const isDingtalkHost = url.hostname === 'dingtalk.com' || url.hostname.endsWith('.dingtalk.com');
|
|
if (url.protocol !== 'https:'
|
|
|| url.port
|
|
|| !isDingtalkHost
|
|
|| url.username
|
|
|| url.password
|
|
|| url.search
|
|
|| url.hash) {
|
|
throw new TypeError('DingTalk registration base URL must be a valid HTTPS URL');
|
|
}
|
|
url.pathname = url.pathname.replace(/\/+$/, '');
|
|
return url.href.replace(/\/$/, '');
|
|
}
|
|
|
|
function readNow(clock) {
|
|
const value = typeof clock?.now === 'function' ? clock.now() : clock();
|
|
if (!Number.isFinite(value)) throw new TypeError('clock must return a finite timestamp');
|
|
return value;
|
|
}
|
|
|
|
function assertRecord(value, action) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new DingtalkDeviceAuthError(
|
|
'invalid-response',
|
|
`DingTalk ${action} returned an invalid response`,
|
|
action,
|
|
);
|
|
}
|
|
if (Number(value.errcode) !== 0) {
|
|
throw new DingtalkDeviceAuthError(
|
|
'api-error',
|
|
`DingTalk ${action} request was rejected`,
|
|
action,
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** A sanitized DingTalk device-registration failure. */
|
|
export class DingtalkDeviceAuthError extends Error {
|
|
/**
|
|
* @param {string} code Stable failure code.
|
|
* @param {string} message Safe diagnostic that does not include response credentials.
|
|
* @param {string} action Registration stage that failed.
|
|
* @param {{cause?: unknown}} [options] Optional underlying error.
|
|
*/
|
|
constructor(code, message, action, options = {}) {
|
|
super(message, options);
|
|
this.name = 'DingtalkDeviceAuthError';
|
|
this.code = code;
|
|
this.action = action;
|
|
}
|
|
}
|
|
|
|
/** Host-only client for DingTalk's QR device-registration flow. */
|
|
export class DingtalkDeviceAuth {
|
|
#fetch;
|
|
#clock;
|
|
#baseUrl;
|
|
#timeoutMs;
|
|
|
|
/**
|
|
* @param {{fetch?: typeof globalThis.fetch, clock?: {now(): number}|(()=>number), baseUrl?: string, timeoutMs?: number}} [options]
|
|
* Device-registration dependencies.
|
|
*/
|
|
constructor({
|
|
fetch = globalThis.fetch,
|
|
clock = Date,
|
|
baseUrl = DEFAULT_REGISTRATION_BASE_URL,
|
|
timeoutMs = 15_000,
|
|
} = {}) {
|
|
if (typeof fetch !== 'function') throw new TypeError('fetch is required');
|
|
if (typeof clock !== 'function' && typeof clock?.now !== 'function') {
|
|
throw new TypeError('clock must be a function or expose now()');
|
|
}
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
throw new TypeError('timeoutMs must be a positive number');
|
|
}
|
|
this.#fetch = fetch;
|
|
this.#clock = clock;
|
|
this.#baseUrl = normalizeBaseUrl(baseUrl);
|
|
this.#timeoutMs = timeoutMs;
|
|
}
|
|
|
|
/**
|
|
* Starts a QR registration and returns the host-only device code with QR metadata.
|
|
* @param {{signal?: AbortSignal}} [options] Optional cancellation signal.
|
|
* @returns {Promise<object>} Device registration details.
|
|
*/
|
|
async start({ signal } = {}) {
|
|
const initialized = await this.#post(
|
|
'/app/registration/init',
|
|
{ source: REGISTRATION_SOURCE },
|
|
'initialization',
|
|
signal,
|
|
);
|
|
const nonce = cleanString(initialized.nonce);
|
|
if (!nonce) {
|
|
throw new DingtalkDeviceAuthError(
|
|
'missing-nonce',
|
|
'DingTalk registration initialization did not return a nonce',
|
|
'initialization',
|
|
);
|
|
}
|
|
|
|
const begun = await this.#post(
|
|
'/app/registration/begin',
|
|
{ nonce },
|
|
'begin',
|
|
signal,
|
|
);
|
|
const deviceCode = cleanString(begun.device_code);
|
|
const verificationUrl = cleanString(begun.verification_uri_complete);
|
|
if (!deviceCode || !verificationUrl) {
|
|
throw new DingtalkDeviceAuthError(
|
|
'incomplete-registration',
|
|
'DingTalk registration did not return complete QR metadata',
|
|
'begin',
|
|
);
|
|
}
|
|
|
|
const expiresInSeconds = positiveNumber(begun.expires_in, 7_200);
|
|
const pollIntervalMs = positiveNumber(begun.interval, 5) * 1_000;
|
|
return Object.freeze({
|
|
deviceCode,
|
|
verificationUrl,
|
|
verificationUri: cleanString(begun.verification_uri),
|
|
userCode: cleanString(begun.user_code),
|
|
expiresAt: readNow(this.#clock) + expiresInSeconds * 1_000,
|
|
pollIntervalMs,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Polls one registration attempt.
|
|
* @param {{deviceCode: string, signal?: AbortSignal}|string} request Host-only device code.
|
|
* @returns {Promise<object>} Normalized registration state and credentials on success.
|
|
*/
|
|
async poll(request) {
|
|
const deviceCode = cleanString(typeof request === 'string' ? request : request?.deviceCode);
|
|
const signal = typeof request === 'object' ? request?.signal : undefined;
|
|
if (!deviceCode) throw new TypeError('deviceCode is required');
|
|
const response = await this.#post(
|
|
'/app/registration/poll',
|
|
{ device_code: deviceCode },
|
|
'poll',
|
|
signal,
|
|
);
|
|
const rawStatus = cleanString(response.status)?.toUpperCase();
|
|
const status = ['WAITING', 'SUCCESS', 'FAIL', 'EXPIRED'].includes(rawStatus)
|
|
? rawStatus
|
|
: 'UNKNOWN';
|
|
return Object.freeze({
|
|
status,
|
|
clientId: cleanString(response.client_id),
|
|
clientSecret: cleanString(response.client_secret),
|
|
failReason: cleanString(response.fail_reason),
|
|
});
|
|
}
|
|
|
|
async #post(path, body, action, signal) {
|
|
let response;
|
|
const timeoutSignal = AbortSignal.timeout(this.#timeoutMs);
|
|
const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
try {
|
|
response = await this.#fetch(`${this.#baseUrl}${path}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
accept: 'application/json',
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify(body),
|
|
redirect: 'error',
|
|
signal: requestSignal,
|
|
});
|
|
} catch (error) {
|
|
if (signal?.aborted) throw signal.reason ?? error;
|
|
if (timeoutSignal.aborted) {
|
|
throw new DingtalkDeviceAuthError(
|
|
'timeout',
|
|
`DingTalk ${action} request timed out`,
|
|
action,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
if (error?.name === 'AbortError') throw error;
|
|
throw new DingtalkDeviceAuthError(
|
|
'network-error',
|
|
`DingTalk ${action} request could not be completed`,
|
|
action,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
if (!response || response.ok === false || typeof response.json !== 'function') {
|
|
throw new DingtalkDeviceAuthError(
|
|
'http-error',
|
|
`DingTalk ${action} request failed`,
|
|
action,
|
|
);
|
|
}
|
|
let value;
|
|
try {
|
|
value = await response.json();
|
|
} catch (error) {
|
|
throw new DingtalkDeviceAuthError(
|
|
'invalid-json',
|
|
`DingTalk ${action} returned invalid JSON`,
|
|
action,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
return assertRecord(value, action);
|
|
}
|
|
}
|
|
|
|
export { DEFAULT_REGISTRATION_BASE_URL, REGISTRATION_SOURCE };
|
|
export { DingtalkDeviceAuth as DingTalkDeviceAuth };
|
|
export { DingtalkDeviceAuthError as DingTalkDeviceAuthError };
|