自动同步: 2026-07-01 02:10
This commit is contained in:
+153
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* GitHub Device Flow authentication for headless environments.
|
||||
*
|
||||
* Implements RFC 8628 / GitHub's Device Flow:
|
||||
* https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow
|
||||
*
|
||||
* This allows CLI authentication without a browser redirect to localhost,
|
||||
* enabling headless agents and remote servers to authenticate.
|
||||
*/
|
||||
const DEFAULT_SCOPE = "read write";
|
||||
/**
|
||||
* Request a device code from the ClawHub device flow endpoint.
|
||||
*/
|
||||
export async function requestDeviceCode(config) {
|
||||
const url = new URL("/api/cli/device/code", config.apiUrl);
|
||||
const body = {
|
||||
scope: config.scope ?? DEFAULT_SCOPE,
|
||||
site_url: config.siteUrl,
|
||||
};
|
||||
if (config.label) {
|
||||
body.label = config.label;
|
||||
}
|
||||
if (config.clientId) {
|
||||
body.client_id = config.clientId;
|
||||
}
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`Device code request failed (${response.status}): ${text || response.statusText}`);
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!isDeviceCodeResponse(data)) {
|
||||
throw new Error("Invalid device code response from server");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
/**
|
||||
* Poll for the device flow token until the user completes authorization,
|
||||
* the code expires, or an unrecoverable error occurs.
|
||||
*/
|
||||
export async function pollForDeviceToken(config, deviceCode, options) {
|
||||
const url = new URL("/api/cli/device/token", config.apiUrl);
|
||||
const deadline = Date.now() + options.expiresIn * 1000;
|
||||
const expirationMessage = "Device code expired (timeout). Please try again.";
|
||||
let interval = options.interval * 1000;
|
||||
const body = {
|
||||
device_code: deviceCode,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
};
|
||||
if (config.clientId) {
|
||||
body.client_id = config.clientId;
|
||||
}
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(interval);
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
break;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
let timeout;
|
||||
let response;
|
||||
try {
|
||||
response = await new Promise((resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error(expirationMessage));
|
||||
}, remainingMs);
|
||||
void fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
}).then(resolve, reject);
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error(expirationMessage, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
finally {
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
// Parse JSON once to avoid "body already read" errors
|
||||
const data = await response.json().catch(() => null);
|
||||
if (response.ok) {
|
||||
if (!isDeviceTokenResponse(data)) {
|
||||
throw new Error("Invalid device token response from server");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
if (!isRecord(data)) {
|
||||
throw new Error("Invalid device token response from server");
|
||||
}
|
||||
const errorData = data;
|
||||
switch (errorData.error) {
|
||||
case "authorization_pending":
|
||||
// User hasn't completed auth yet — keep polling
|
||||
break;
|
||||
case "slow_down":
|
||||
// Server requests longer interval
|
||||
interval = (errorData.interval ?? Math.ceil(interval / 1000) + 5) * 1000;
|
||||
break;
|
||||
case "expired_token":
|
||||
throw new Error("Device code expired. Please try again.");
|
||||
case "access_denied":
|
||||
throw new Error("Authorization denied by user.");
|
||||
default:
|
||||
throw new Error(`Device flow error: ${errorData.error_description || errorData.error || "unknown error"}`);
|
||||
}
|
||||
}
|
||||
throw new Error(expirationMessage);
|
||||
}
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
function isRecord(value) {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
function isNonEmptyString(value) {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
function isPositiveFiniteNumber(value) {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
function isDeviceCodeResponse(value) {
|
||||
return (isRecord(value) &&
|
||||
isNonEmptyString(value.device_code) &&
|
||||
isNonEmptyString(value.user_code) &&
|
||||
isNonEmptyString(value.verification_uri) &&
|
||||
isPositiveFiniteNumber(value.expires_in) &&
|
||||
isPositiveFiniteNumber(value.interval));
|
||||
}
|
||||
function isDeviceTokenResponse(value) {
|
||||
return (isRecord(value) &&
|
||||
isNonEmptyString(value.access_token) &&
|
||||
isNonEmptyString(value.token_type) &&
|
||||
isNonEmptyString(value.scope));
|
||||
}
|
||||
//# sourceMappingURL=deviceAuth.js.map
|
||||
Reference in New Issue
Block a user