mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 09:01:39 -06:00
fix(openrouter): keep OAuth loopback open after invalid callbacks (#124922)
Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
committed by
GitHub
parent
827fde2885
commit
b555a32ee9
@@ -138,7 +138,7 @@ are private-local.
|
||||
| `plugin-sdk/provider-entry` | Private-local after July 2026; `defineSingleProviderPluginEntry` |
|
||||
| `plugin-sdk/provider-setup` | Private-local after July 2026; Curated local/self-hosted provider setup helpers |
|
||||
| `plugin-sdk/cli-backend` | Private-local after July 2026; CLI backend defaults + watchdog constants |
|
||||
| `plugin-sdk/provider-auth-runtime` | Private-local after July 2026; Provider auth runtime helpers: OAuth loopback flow, token exchange, auth persistence, and API-key resolution |
|
||||
| `plugin-sdk/provider-auth-runtime` | Private-local after July 2026; provider auth runtime helpers including `startProviderOAuthLoopbackCallbackServer`, token exchange, auth persistence, and API-key resolution |
|
||||
| `plugin-sdk/provider-oauth-runtime` | Private-local after July 2026; Generic provider OAuth callback types, callback-page rendering, PKCE/state helpers, authorization-input parsing, token-expiry helpers, and abort helpers |
|
||||
| `plugin-sdk/provider-auth-api-key` | Private-local after July 2026; API-key onboarding/profile-write helpers such as `upsertApiKeyProfile` |
|
||||
| `plugin-sdk/provider-auth-result` | Private-local after July 2026; Standard OAuth auth-result builder |
|
||||
|
||||
@@ -137,15 +137,13 @@ function createOpenRouterOAuthContext(params: {
|
||||
return { ctx, progress, note, text, log, openUrl };
|
||||
}
|
||||
|
||||
async function requestLocalOpenRouterOAuthCallback(
|
||||
query: string,
|
||||
): Promise<{ callback: Promise<unknown>; response: Response; body: string }> {
|
||||
async function startLocalOpenRouterOAuthLogin() {
|
||||
let markReady = () => {};
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
markReady = resolve;
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const redirectInput = `${OPENROUTER_OAUTH_REDIRECT_URI}?${query}`;
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => jsonResponse({ key: "sk-or-v1-test" }));
|
||||
const { ctx } = createOpenRouterOAuthContext({
|
||||
isRemote: false,
|
||||
onProgress: (message) => {
|
||||
@@ -153,18 +151,17 @@ async function requestLocalOpenRouterOAuthCallback(
|
||||
markReady();
|
||||
}
|
||||
},
|
||||
redirectInput,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const callback = loginOpenRouterOAuth(ctx, {
|
||||
const login = loginOpenRouterOAuth(ctx, {
|
||||
createPkce: () => ({ verifier: "verifier-1", challenge: "challenge-1" }),
|
||||
createState: () => "state-1",
|
||||
fetchImpl: vi.fn(async () => jsonResponse({ key: "sk-or-v1-test" })),
|
||||
fetchImpl,
|
||||
});
|
||||
callback.catch(() => undefined);
|
||||
login.catch(() => undefined);
|
||||
await Promise.race([
|
||||
ready,
|
||||
callback.then(
|
||||
login.then(
|
||||
() => {
|
||||
throw new Error("OpenRouter OAuth completed before callback server started");
|
||||
},
|
||||
@@ -174,13 +171,20 @@ async function requestLocalOpenRouterOAuthCallback(
|
||||
),
|
||||
]);
|
||||
|
||||
try {
|
||||
const response = await fetch(redirectInput, { headers: { Connection: "close" } });
|
||||
return { callback, response, body: await response.text() };
|
||||
} catch (error) {
|
||||
controller.abort();
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
abort: () => controller.abort(),
|
||||
fetchImpl,
|
||||
login,
|
||||
request: async (pathOrQuery: string, init?: RequestInit) => {
|
||||
const url = pathOrQuery.startsWith("http")
|
||||
? pathOrQuery
|
||||
: `${OPENROUTER_OAUTH_REDIRECT_URI}?${pathOrQuery}`;
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Connection", "close");
|
||||
const response = await fetch(url, { ...init, headers });
|
||||
return { response, body: await response.text() };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runRemoteOpenRouterOAuthRedirect(redirectInput: string) {
|
||||
@@ -411,53 +415,102 @@ describe("OpenRouter OAuth", () => {
|
||||
expect(progress.stop).toHaveBeenCalledWith("OpenRouter OAuth complete");
|
||||
});
|
||||
|
||||
it("uses the local callback path before opening the browser locally", async () => {
|
||||
it("binds the local callback before opening the browser", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => jsonResponse({ key: "sk-or-v1-test" }));
|
||||
const waitForCallback = vi.fn(async (_params: { expectedState: string }) => ({
|
||||
const waitForCallback = vi.fn(async () => ({
|
||||
type: "authorization_code" as const,
|
||||
code: "AUTHCODE",
|
||||
state: "state-1",
|
||||
}));
|
||||
const close = vi.fn(async () => undefined);
|
||||
const startCallback = vi.fn(async () => ({ waitForCallback, close }));
|
||||
const { ctx, openUrl, text } = createOpenRouterOAuthContext({ isRemote: false });
|
||||
|
||||
await loginOpenRouterOAuth(ctx, {
|
||||
createPkce: () => ({ verifier: "verifier-1", challenge: "challenge-1" }),
|
||||
createState: () => "state-1",
|
||||
fetchImpl,
|
||||
waitForCallback,
|
||||
startCallback,
|
||||
});
|
||||
|
||||
expect(waitForCallback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expectedState: "state-1" }),
|
||||
expect(startCallback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expectedState: "state-1",
|
||||
redirectUrl: OPENROUTER_OAUTH_REDIRECT_URI,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(waitForCallback.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
expect(startCallback.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
(openUrl as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
expect(waitForCallback).toHaveBeenCalledTimes(1);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(openUrl).toHaveBeenCalledWith(expect.stringContaining("https://openrouter.ai/auth?"));
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates local callback state before surfacing OpenRouter OAuth errors", async () => {
|
||||
const denied = await requestLocalOpenRouterOAuthCallback(
|
||||
"state=state-1&error=access_denied&error_description=Denied",
|
||||
);
|
||||
expect(denied.response.status).toBe(400);
|
||||
expect(denied.body).toBe("OpenRouter authentication failed: access_denied: Denied");
|
||||
await expect(denied.callback).rejects.toThrow("OpenRouter OAuth error: access_denied: Denied");
|
||||
it("falls back to a pasted redirect when the local listener cannot start", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => jsonResponse({ key: "sk-or-v1-test" }));
|
||||
const startCallback = vi.fn(async () => {
|
||||
throw new Error("listen EADDRINUSE: address already in use localhost:3000");
|
||||
});
|
||||
const { ctx, openUrl, text } = createOpenRouterOAuthContext({ isRemote: false });
|
||||
|
||||
const missingState = await requestLocalOpenRouterOAuthCallback(
|
||||
"error=access_denied&error_description=Denied",
|
||||
);
|
||||
expect(missingState.response.status).toBe(400);
|
||||
expect(missingState.body).toBe("Invalid OAuth state");
|
||||
await expect(missingState.callback).rejects.toThrow("Missing OpenRouter OAuth state");
|
||||
await expect(
|
||||
loginOpenRouterOAuth(ctx, {
|
||||
createPkce: () => ({ verifier: "verifier-1", challenge: "challenge-1" }),
|
||||
createState: () => "state-1",
|
||||
fetchImpl,
|
||||
startCallback,
|
||||
}),
|
||||
).resolves.toMatchObject({ defaultModel: "openrouter/auto" });
|
||||
|
||||
const wrongState = await requestLocalOpenRouterOAuthCallback(
|
||||
"state=wrong&error=access_denied&error_description=Denied",
|
||||
expect(startCallback).toHaveBeenCalledTimes(1);
|
||||
expect(openUrl).toHaveBeenCalledTimes(1);
|
||||
expect(text).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Paste the OpenRouter redirect URL" }),
|
||||
);
|
||||
expect(wrongState.response.status).toBe(400);
|
||||
expect(wrongState.body).toBe("Invalid OAuth state");
|
||||
await expect(wrongState.callback).rejects.toThrow("OpenRouter OAuth state mismatch");
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps waiting after rejected callback candidates and exchanges one valid code", async () => {
|
||||
const local = await startLocalOpenRouterOAuthLogin();
|
||||
try {
|
||||
expect((await local.request("http://localhost:3000/wrong")).response.status).toBe(404);
|
||||
expect((await local.request("", { method: "POST" })).response.status).toBe(405);
|
||||
expect((await local.request("code=missing-state")).response.status).toBe(400);
|
||||
expect((await local.request("state=wrong&code=wrong-state")).response.status).toBe(400);
|
||||
expect((await local.request("state=state-1")).response.status).toBe(400);
|
||||
|
||||
const validCallbacks = await Promise.allSettled([
|
||||
local.request("state=state-1&code=AUTHCODE"),
|
||||
local.request("state=state-1&code=REPLAY"),
|
||||
]);
|
||||
const statuses = validCallbacks.flatMap((result) =>
|
||||
result.status === "fulfilled" ? [result.value.response.status] : [],
|
||||
);
|
||||
expect(statuses.filter((status) => status === 200)).toHaveLength(1);
|
||||
await expect(local.login).resolves.toMatchObject({ defaultModel: "openrouter/auto" });
|
||||
expect(local.fetchImpl).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
local.abort();
|
||||
await local.login.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("terminates a state-bound provider denial without exchanging a code", async () => {
|
||||
const local = await startLocalOpenRouterOAuthLogin();
|
||||
try {
|
||||
const denied = await local.request(
|
||||
"state=state-1&error=access_denied&error_description=Denied",
|
||||
);
|
||||
expect(denied.response.status).toBe(400);
|
||||
expect(denied.body).toBe("Authorization was not completed.");
|
||||
await expect(local.login).rejects.toThrow("OpenRouter OAuth error: access_denied: Denied");
|
||||
expect(local.fetchImpl).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
local.abort();
|
||||
await local.login.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes stable auth choice metadata", () => {
|
||||
|
||||
+61
-136
@@ -1,5 +1,4 @@
|
||||
// OpenRouter OAuth support exchanges PKCE browser login codes for API keys.
|
||||
import { createServer } from "node:http";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { ProviderAuthContext, ProviderAuthMethod } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
@@ -7,7 +6,10 @@ import {
|
||||
generatePkceVerifierChallenge,
|
||||
type ProviderAuthResult,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import { generateOAuthState } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
generateOAuthState,
|
||||
startProviderOAuthLoopbackCallbackServer,
|
||||
} from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
@@ -36,6 +38,13 @@ type OpenRouterOAuthCallbackResult = {
|
||||
state: string;
|
||||
};
|
||||
|
||||
type OpenRouterOAuthCallbackServer = Awaited<
|
||||
ReturnType<typeof startProviderOAuthLoopbackCallbackServer>
|
||||
>;
|
||||
type OpenRouterOAuthLoopbackResult = Awaited<
|
||||
ReturnType<OpenRouterOAuthCallbackServer["waitForCallback"]>
|
||||
>;
|
||||
|
||||
type OpenRouterOAuthKeyResult = {
|
||||
key: string;
|
||||
userId?: string;
|
||||
@@ -45,7 +54,7 @@ type OpenRouterOAuthLoginOptions = {
|
||||
createPkce?: () => { verifier: string; challenge: string };
|
||||
createState?: () => string;
|
||||
fetchImpl?: typeof fetch;
|
||||
waitForCallback?: typeof waitForOpenRouterOAuthCallback;
|
||||
startCallback?: typeof startProviderOAuthLoopbackCallbackServer;
|
||||
};
|
||||
|
||||
function extractOpenRouterError(value: unknown): string | undefined {
|
||||
@@ -206,125 +215,6 @@ async function exchangeOpenRouterOAuthCode(params: {
|
||||
return parseOpenRouterKeyResponse(body);
|
||||
}
|
||||
|
||||
async function waitForOpenRouterOAuthCallback(params: {
|
||||
expectedState: string;
|
||||
timeoutMs?: number;
|
||||
onProgress?: (message: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<OpenRouterOAuthCallbackResult> {
|
||||
const timeoutMs = params.timeoutMs ?? OPENROUTER_OAUTH_TIMEOUT_MS;
|
||||
return new Promise<OpenRouterOAuthCallbackResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
finish(new Error("OpenRouter OAuth callback timeout"));
|
||||
}, timeoutMs);
|
||||
const server = createServer((req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(
|
||||
req.url ?? "/",
|
||||
`http://${OPENROUTER_OAUTH_CALLBACK_HOST}:${OPENROUTER_OAUTH_CALLBACK_PORT}`,
|
||||
);
|
||||
if (requestUrl.pathname !== OPENROUTER_OAUTH_CALLBACK_PATH) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
if (req.method !== "GET") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Allow", "GET");
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Method not allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
const state = normalizeOptionalString(requestUrl.searchParams.get("state"));
|
||||
try {
|
||||
requireOpenRouterOAuthState(state, params.expectedState);
|
||||
} catch (err) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Invalid OAuth state");
|
||||
finish(err instanceof Error ? err : new Error("OpenRouter OAuth state mismatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
const error = normalizeOptionalString(requestUrl.searchParams.get("error"));
|
||||
if (error) {
|
||||
const description = normalizeOptionalString(
|
||||
requestUrl.searchParams.get("error_description"),
|
||||
);
|
||||
const detail = description ? `${error}: ${description}` : error;
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end(`OpenRouter authentication failed: ${detail}`);
|
||||
finish(new Error(`OpenRouter OAuth error: ${detail}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const code = normalizeOptionalString(requestUrl.searchParams.get("code"));
|
||||
if (!code) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Missing OAuth code");
|
||||
finish(new Error("Missing OpenRouter OAuth code"));
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(
|
||||
"<!doctype html><html><head><meta charset='utf-8'/></head>" +
|
||||
"<body><h2>OpenRouter OAuth complete</h2>" +
|
||||
"<p>You can close this window and return to OpenClaw.</p></body></html>",
|
||||
);
|
||||
finish(undefined, { code, state: params.expectedState });
|
||||
} catch (err) {
|
||||
finish(err instanceof Error ? err : new Error("OpenRouter OAuth callback failed"));
|
||||
}
|
||||
});
|
||||
|
||||
const finish = (err?: Error, result?: OpenRouterOAuthCallbackResult) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
params.signal?.removeEventListener("abort", onAbort);
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
// Best-effort cleanup after callback completion or timeout.
|
||||
}
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (result) {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
const onAbort = () => finish(new Error("OpenRouter OAuth cancelled"));
|
||||
params.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (params.signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
|
||||
server.once("error", (err) => {
|
||||
finish(err instanceof Error ? err : new Error("OpenRouter OAuth callback server error"));
|
||||
});
|
||||
server.listen(OPENROUTER_OAUTH_CALLBACK_PORT, OPENROUTER_OAUTH_CALLBACK_HOST, () => {
|
||||
params.onProgress?.(
|
||||
`Waiting for OpenRouter OAuth callback on ${OPENROUTER_OAUTH_REDIRECT_URI}...`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function promptForOpenRouterRedirect(
|
||||
ctx: ProviderAuthContext,
|
||||
expectedState: string,
|
||||
@@ -342,7 +232,7 @@ async function resolveOpenRouterOAuthCode(
|
||||
params: {
|
||||
authorizeUrl: string;
|
||||
state: string;
|
||||
waitForCallback: typeof waitForOpenRouterOAuthCallback;
|
||||
startCallback: typeof startProviderOAuthLoopbackCallbackServer;
|
||||
onProgress: (message: string) => void;
|
||||
},
|
||||
): Promise<string> {
|
||||
@@ -373,20 +263,30 @@ async function resolveOpenRouterOAuthCode(
|
||||
return await promptForOpenRouterRedirect(ctx, params.state);
|
||||
}
|
||||
|
||||
const callbackPromise = params
|
||||
.waitForCallback({
|
||||
let callback: OpenRouterOAuthCallbackServer | undefined;
|
||||
try {
|
||||
callback = await params.startCallback({
|
||||
redirectUrl: OPENROUTER_OAUTH_REDIRECT_URI,
|
||||
expectedState: params.state,
|
||||
onProgress: params.onProgress,
|
||||
timeoutMs: OPENROUTER_OAUTH_TIMEOUT_MS,
|
||||
...(ctx.signal ? { signal: ctx.signal } : {}),
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
if (ctx.signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
params.onProgress("OAuth callback not detected; waiting for redirect URL...");
|
||||
return { code: await promptForOpenRouterRedirect(ctx, params.state), state: params.state };
|
||||
renderSuccess: () => ({
|
||||
body:
|
||||
"<!doctype html><html><head><meta charset='utf-8'/></head>" +
|
||||
"<body><h2>OpenRouter OAuth complete</h2>" +
|
||||
"<p>You can close this window and return to OpenClaw.</p></body></html>",
|
||||
contentType: "text/html; charset=utf-8",
|
||||
}),
|
||||
});
|
||||
void callbackPromise.catch(() => undefined);
|
||||
params.onProgress(
|
||||
`Waiting for OpenRouter OAuth callback on ${OPENROUTER_OAUTH_REDIRECT_URI}...`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (ctx.signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
params.onProgress("OAuth callback not detected; waiting for redirect URL...");
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.openUrl(params.authorizeUrl);
|
||||
@@ -395,7 +295,32 @@ async function resolveOpenRouterOAuthCode(
|
||||
ctx.runtime.log(`Open manually: ${params.authorizeUrl}`);
|
||||
}
|
||||
|
||||
return (await callbackPromise).code;
|
||||
if (!callback) {
|
||||
return await promptForOpenRouterRedirect(ctx, params.state);
|
||||
}
|
||||
|
||||
let result: OpenRouterOAuthLoopbackResult;
|
||||
try {
|
||||
try {
|
||||
result = await callback.waitForCallback();
|
||||
} finally {
|
||||
await callback.close();
|
||||
}
|
||||
} catch (error) {
|
||||
if (ctx.signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
params.onProgress("OAuth callback not detected; waiting for redirect URL...");
|
||||
return await promptForOpenRouterRedirect(ctx, params.state);
|
||||
}
|
||||
|
||||
if (result.type === "oauth_error") {
|
||||
const detail = result.errorDescription
|
||||
? `${result.error}: ${result.errorDescription}`
|
||||
: result.error;
|
||||
throw new Error(`OpenRouter OAuth error: ${detail}`);
|
||||
}
|
||||
return result.code;
|
||||
}
|
||||
|
||||
async function loginOpenRouterOAuth(
|
||||
@@ -413,7 +338,7 @@ async function loginOpenRouterOAuth(
|
||||
const code = await resolveOpenRouterOAuthCode(ctx, {
|
||||
authorizeUrl,
|
||||
state,
|
||||
waitForCallback: options.waitForCallback ?? waitForOpenRouterOAuthCallback,
|
||||
startCallback: options.startCallback ?? startProviderOAuthLoopbackCallbackServer,
|
||||
onProgress: (message) => progress.update(message),
|
||||
});
|
||||
progress.update("Exchanging OpenRouter OAuth code...");
|
||||
|
||||
@@ -36,6 +36,37 @@ export type OAuthCallbackResult = {
|
||||
state: string;
|
||||
};
|
||||
|
||||
type ProviderOAuthLoopbackCallbackResult =
|
||||
| { type: "authorization_code"; code: string; state: string }
|
||||
| { type: "oauth_error"; error: string; errorDescription?: string };
|
||||
|
||||
type ProviderOAuthLoopbackCallbackServer = {
|
||||
waitForCallback: () => Promise<ProviderOAuthLoopbackCallbackResult>;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
type ProviderOAuthLoopbackRenderedResponse = { body: string; contentType: string };
|
||||
type ProviderOAuthLoopbackCorsOriginResolver = (
|
||||
originHeader: string | string[] | undefined,
|
||||
) => string | undefined;
|
||||
|
||||
/**
|
||||
* Binds a hardened loopback listener before returning so provider plugins can open the browser
|
||||
* only after the callback route is ready. Invalid request candidates remain nonterminal.
|
||||
*/
|
||||
export async function startProviderOAuthLoopbackCallbackServer(params: {
|
||||
redirectUrl: string | URL;
|
||||
expectedState: string;
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
bindHostname?: string;
|
||||
resolveCorsOrigin?: ProviderOAuthLoopbackCorsOriginResolver;
|
||||
renderSuccess?: () => ProviderOAuthLoopbackRenderedResponse;
|
||||
renderError?: (message: string) => ProviderOAuthLoopbackRenderedResponse;
|
||||
}): Promise<ProviderOAuthLoopbackCallbackServer> {
|
||||
return await startOAuthLoopbackCallbackServer(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-secret auth profile metadata used by provider discovery helpers.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user