// Provider OAuth runtime helpers expose shared browser/OAuth flows for provider plugins. import { toErrorObject } from "@openclaw/normalization-core/error-coercion"; import { positiveSecondsToSafeMilliseconds, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs, } from "../../packages/normalization-core/src/number-coercion.js"; import type { Model } from "../llm/types.js"; // Static OpenClaw lobster mascot; keep shapes in sync with ui/public/favicon.svg. const LOGO_SVG = ``; /** Normalized OAuth credential bundle persisted by provider auth profiles. */ export type OAuthCredentials = { /** Refresh token or provider-equivalent long-lived credential. */ refresh: string; /** Access token or provider-equivalent bearer credential. */ access: string; /** Absolute epoch milliseconds when the access token should be considered expired. */ expires: number; [key: string]: unknown; }; /** Stable provider id used by OAuth credential and config routing. */ export type OAuthProviderId = string; /** @deprecated Use OAuthProviderId instead. */ export type OAuthProvider = OAuthProviderId; /** Manual input prompt shown during OAuth login flows. */ export type OAuthPrompt = { /** Prompt text shown to the operator. */ message: string; /** Optional placeholder for manual text entry. */ placeholder?: string; /** Whether empty input should be accepted instead of reprompting. */ allowEmpty?: boolean; }; /** Parsed OAuth callback/code input accepted by manual and callback-server flows. */ export type OAuthAuthorizationInput = { /** Authorization code parsed from a callback URL, query string, or pasted code. */ code?: string; /** Optional OAuth state parsed from callback URL, query string, or `code#state` input. */ state?: string; }; /** Authorization URL and optional instructions shown before OAuth completion. */ export type OAuthAuthInfo = { /** Provider authorization URL shown to the user. */ url: string; /** Optional provider-specific instruction text for manual flows. */ instructions?: string; }; /** One selectable OAuth login option. */ export type OAuthSelectOption = { /** Stable option id returned when the operator selects this entry. */ id: string; /** Human-readable option label shown in the selector. */ label: string; }; /** Selector prompt used when a provider offers multiple OAuth login choices. */ export type OAuthSelectPrompt = { /** Prompt text shown above the selectable options. */ message: string; /** Options available for the operator to choose from. */ options: OAuthSelectOption[]; }; /** UI/runtime callbacks used by provider OAuth login implementations. */ export interface OAuthLoginCallbacks { /** Emits authorization URL/instructions to the UI before waiting for completion. */ onAuth: (info: OAuthAuthInfo) => void; /** Prompts for manual input such as pasted callback URLs or authorization codes. */ onPrompt: (prompt: OAuthPrompt) => Promise; /** Reports human-readable login progress without exposing secrets. */ onProgress?: (message: string) => void; /** Optional direct manual-code entry hook used when callback-server flows cannot complete. */ onManualCodeInput?: () => Promise; /** Show an interactive selector and return the selected option id, or undefined on cancel. */ onSelect?: (prompt: OAuthSelectPrompt) => Promise; /** Cancels pending OAuth waits and prompts when aborted. */ signal?: AbortSignal; } /** Provider OAuth contract implemented by provider plugins. */ export interface OAuthProviderInterface { /** Stable provider id used for credential and config routing. */ readonly id: OAuthProviderId; /** Human-readable provider name shown in login flows. */ readonly name: string; /** Run the login flow and return credentials to persist. */ login(callbacks: OAuthLoginCallbacks): Promise; /** Whether login uses a local callback server and supports manual code input. */ usesCallbackServer?: boolean; /** Refresh expired credentials and return updated credentials to persist. */ refreshToken(credentials: OAuthCredentials): Promise; /** Convert credentials to an API key string for the provider. */ getApiKey(credentials: OAuthCredentials): string; /** Optionally adjust models for this provider, such as updating baseUrl. */ modifyModels?(models: Model[], credentials: OAuthCredentials): Model[]; } /** @deprecated Use OAuthProviderInterface instead. */ export interface OAuthProviderInfo { /** Stable provider id used for credential and config routing. */ id: OAuthProviderId; /** Human-readable provider name shown in login flows. */ name: string; /** Whether this provider can currently start OAuth login. */ available: boolean; } function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function renderOAuthPage(options: { title: string; heading: string; message: string; details?: string; }): string { const title = escapeHtml(options.title); const heading = escapeHtml(options.heading); const message = escapeHtml(options.message); const details = options.details ? escapeHtml(options.details) : undefined; return ` ${title}

${heading}

${message}

${details ? `
${details}
` : ""}
`; } /** * Renders the local OAuth callback success page after provider authentication completes. */ export function oauthSuccessHtml( /** Success message rendered in the local OAuth completion page. */ message: string, ): string { return renderOAuthPage({ title: "Authentication successful", heading: "Authentication successful", message, }); } /** * Renders the local OAuth callback error page without exposing raw credential material. */ export function oauthErrorHtml( /** Error message rendered in the local OAuth completion page. */ message: string, /** Optional provider-specific error details rendered below the message. */ details?: string, ): string { return renderOAuthPage({ title: "Authentication failed", heading: "Authentication failed", message, details, }); } function base64urlEncode(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) { binary += String.fromCharCode(byte); } return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/[=]/g, ""); } /** Generates an OAuth PKCE verifier and SHA-256 challenge using base64url encoding. */ export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> { const verifierBytes = new Uint8Array(32); crypto.getRandomValues(verifierBytes); const verifier = base64urlEncode(verifierBytes); const encoder = new TextEncoder(); const data = encoder.encode(verifier); const hashBuffer = await crypto.subtle.digest("SHA-256", data); const challenge = base64urlEncode(new Uint8Array(hashBuffer)); return { verifier, challenge }; } /** Generates a random base64url OAuth state value for CSRF protection. */ export function generateOAuthState(): string { const stateBytes = new Uint8Array(32); crypto.getRandomValues(stateBytes); return base64urlEncode(stateBytes); } /** * Parses callback URLs, raw query strings, `code#state`, or plain pasted codes. * Empty input returns an empty object so callers can keep prompting. */ export function parseOAuthAuthorizationInput( /** Raw callback URL, query string, `code#state`, or pasted code. */ input: string, ): OAuthAuthorizationInput { const value = input.trim(); if (!value) { return {}; } try { const url = new URL(value); return { code: url.searchParams.get("code") ?? undefined, state: url.searchParams.get("state") ?? undefined, }; } catch { // Plain pasted code or query-string input. } if (value.includes("#")) { const [code, state] = value.split("#", 2); return { code, state }; } if (value.includes("code=")) { const params = new URLSearchParams(value); return { code: params.get("code") ?? undefined, state: params.get("state") ?? undefined, }; } return { code: value }; } /** Converts provider `expires_in` seconds into safe positive milliseconds. */ export function resolveOAuthTokenLifetimeMs( /** Provider `expires_in` value in seconds. */ value: unknown, ): number | undefined { return positiveSecondsToSafeMilliseconds(value); } /** Resolves provider token lifetime into an absolute expiry timestamp with optional refresh skew. */ export function resolveOAuthTokenExpiresAt( /** Provider `expires_in` value in seconds. */ value: unknown, options: { /** Current timestamp override for deterministic expiry calculations. */ nowMs?: number; /** Milliseconds to subtract so refresh happens before provider expiry. */ refreshSkewMs?: number; } = {}, ): number | undefined { const lifetimeMs = resolveOAuthTokenLifetimeMs(value); return lifetimeMs === undefined ? undefined : resolveExpiresAtMsFromDurationMs(lifetimeMs, { nowMs: options.nowMs, bufferMs: options.refreshSkewMs, }); } /** * Creates the shared cancellation error used by abortable OAuth login flows. */ export function createOAuthLoginCancelledError(): Error { return new Error("Login cancelled"); } /** Throws the shared OAuth cancellation error when a login signal is already aborted. */ export function throwIfOAuthLoginAborted( /** Abort signal attached to the OAuth login flow. */ signal?: AbortSignal, ): void { if (signal?.aborted) { throw createOAuthLoginCancelledError(); } } /** Races a pending OAuth login step against the login abort signal and normalizes rejections. */ export function withOAuthLoginAbort( /** Pending OAuth login operation to race against abort. */ promise: Promise, /** Abort signal attached to the OAuth login flow. */ signal?: AbortSignal, /** Optional cleanup hook called when the login is aborted. */ onAbort?: () => void, ): Promise { if (!signal) { return promise; } return new Promise((resolve, reject) => { const cleanup = () => { signal.removeEventListener("abort", abort); }; const abort = () => { cleanup(); onAbort?.(); reject(createOAuthLoginCancelledError()); }; if (signal.aborted) { abort(); return; } signal.addEventListener("abort", abort, { once: true }); promise.then( (value) => { // The login step won the race; remove abort listeners so long-lived prompts do not leak. cleanup(); resolve(value); }, (error: unknown) => { // Preserve Error rejections but wrap non-Error provider/prompt values for lint-safe callers. cleanup(); reject(toErrorObject(error, "Non-Error rejection")); }, ); }); } /** Combines a caller abort signal with a bounded timeout signal for OAuth HTTP requests. */ export function buildOAuthRequestSignal(options: { /** Optional caller-provided signal to combine with the timeout signal. */ signal?: AbortSignal; /** Request timeout in milliseconds before the generated signal aborts. */ timeoutMs: number; }): AbortSignal { const timeoutSignal = AbortSignal.timeout(resolveTimerTimeoutMs(options.timeoutMs, 0, 0)); if (!options.signal) { return timeoutSignal; } return AbortSignal.any([options.signal, timeoutSignal]); }