Skip to content

Commit 75de853

Browse files
committed
refactor: share provider OAuth runtime helpers
1 parent b3b962a commit 75de853

6 files changed

Lines changed: 116 additions & 98 deletions

File tree

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
49a138a9743063067b983c4dd27d047572aef0764c0e5f87a98d91f43d4f8213 plugin-sdk-api-baseline.json
2-
cd7ea2f2b4c1d1d073c3077410d44270244e778f33197567f4127a946cc0f7f7 plugin-sdk-api-baseline.jsonl
1+
6e4aa968ce2734e34e375a70101f7d94abbef3d44a8f7ab515d86e33b7e7fd46 plugin-sdk-api-baseline.json
2+
d7adacb07286c73b0286afb674a639567c5e795de40d4f763ccfa2a1bcd7300e plugin-sdk-api-baseline.jsonl

docs/plugins/sdk-subpaths.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ and pairing-path families.
144144
| `plugin-sdk/self-hosted-provider-setup` | Focused OpenAI-compatible self-hosted provider setup helpers |
145145
| `plugin-sdk/cli-backend` | CLI backend defaults + watchdog constants |
146146
| `plugin-sdk/provider-auth-runtime` | Runtime API-key resolution helpers for provider plugins |
147-
| `plugin-sdk/provider-oauth-runtime` | Generic provider OAuth callback types, callback-page rendering, PKCE/state helpers, and abort helpers |
147+
| `plugin-sdk/provider-oauth-runtime` | Generic provider OAuth callback types, callback-page rendering, PKCE/state helpers, authorization-input parsing, token-expiry helpers, and abort helpers |
148148
| `plugin-sdk/provider-auth-api-key` | API-key onboarding/profile-write helpers such as `upsertApiKeyProfile` |
149149
| `plugin-sdk/provider-auth-result` | Standard OAuth auth-result builder |
150150
| `plugin-sdk/provider-env-vars` | Provider auth env-var lookup helpers |

extensions/openai/openai-codex-oauth-flow.runtime.ts

Lines changed: 11 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
* It is only intended for CLI use, not browser environments.
66
*/
77

8-
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
8+
import {
9+
parseOAuthAuthorizationInput,
10+
resolveOAuthTokenExpiresAt,
11+
resolveOAuthTokenLifetimeMs,
12+
} from "openclaw/plugin-sdk/provider-oauth-runtime";
913
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
1014
import { resolveCodexAuthIdentity } from "./openai-codex-auth-identity.js";
1115
import {
@@ -113,46 +117,14 @@ function waitForManualPromptFallback(signal?: AbortSignal): Promise<null> {
113117
});
114118
}
115119

116-
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
117-
const value = input.trim();
118-
if (!value) {
119-
return {};
120-
}
121-
122-
try {
123-
const url = new URL(value);
124-
return {
125-
code: url.searchParams.get("code") ?? undefined,
126-
state: url.searchParams.get("state") ?? undefined,
127-
};
128-
} catch {
129-
// not a URL
130-
}
131-
132-
if (value.includes("#")) {
133-
const [code, state] = value.split("#", 2);
134-
return { code, state };
135-
}
136-
137-
if (value.includes("code=")) {
138-
const params = new URLSearchParams(value);
139-
return {
140-
code: params.get("code") ?? undefined,
141-
state: params.get("state") ?? undefined,
142-
};
143-
}
144-
145-
return { code: value };
146-
}
147-
148120
async function promptForAuthorizationCode(
149121
onPrompt: (prompt: OAuthPrompt) => Promise<string>,
150122
state: string,
151123
): Promise<string | undefined> {
152124
const input = await onPrompt({
153125
message: "Paste the authorization code (or full redirect URL):",
154126
});
155-
const parsed = parseAuthorizationInput(input);
127+
const parsed = parseOAuthAuthorizationInput(input);
156128
if (parsed.state && parsed.state !== state) {
157129
throw new Error("State mismatch");
158130
}
@@ -167,17 +139,12 @@ function formatMissingTokenResponseFields(json: TokenResponseJson): string {
167139
if (!json.refresh_token) {
168140
missing.push("refresh_token");
169141
}
170-
if (parseStrictPositiveInteger(json.expires_in) === undefined) {
142+
if (resolveOAuthTokenLifetimeMs(json.expires_in) === undefined) {
171143
missing.push("expires_in");
172144
}
173145
return missing.join(", ");
174146
}
175147

176-
function resolveTokenExpiresAt(expiresIn: unknown, nowMs = Date.now()): number | undefined {
177-
const seconds = parseStrictPositiveInteger(expiresIn);
178-
return seconds === undefined ? undefined : nowMs + seconds * 1000;
179-
}
180-
181148
function formatTokenRequestError(
182149
operation: "exchange" | "refresh",
183150
error: unknown,
@@ -259,7 +226,7 @@ async function exchangeAuthorizationCode(
259226

260227
const json = (await response.json()) as TokenResponseJson;
261228

262-
const expires = resolveTokenExpiresAt(json.expires_in);
229+
const expires = resolveOAuthTokenExpiresAt(json.expires_in);
263230
if (!json.access_token || !json.refresh_token || expires === undefined) {
264231
return {
265232
type: "failed",
@@ -301,7 +268,7 @@ async function refreshAccessToken(
301268

302269
const json = (await response.json()) as TokenResponseJson;
303270

304-
const expires = resolveTokenExpiresAt(json.expires_in);
271+
const expires = resolveOAuthTokenExpiresAt(json.expires_in);
305272
if (!json.access_token || !json.refresh_token || expires === undefined) {
306273
return {
307274
type: "failed",
@@ -502,7 +469,7 @@ export async function loginOpenAICodex(options: {
502469
code = result.code;
503470
} else if (manualCode) {
504471
// Manual input won (or callback timed out and user had entered code)
505-
const parsed = parseAuthorizationInput(manualCode);
472+
const parsed = parseOAuthAuthorizationInput(manualCode);
506473
if (parsed.state && parsed.state !== state) {
507474
throw new Error("State mismatch");
508475
}
@@ -516,7 +483,7 @@ export async function loginOpenAICodex(options: {
516483
throw manualError;
517484
}
518485
if (manualCode) {
519-
const parsed = parseAuthorizationInput(manualCode);
486+
const parsed = parseOAuthAuthorizationInput(manualCode);
520487
if (parsed.state && parsed.state !== state) {
521488
throw new Error("State mismatch");
522489
}

src/llm/utils/oauth/anthropic.ts

Lines changed: 8 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
*/
77

88
import type { Server } from "node:http";
9-
import { parseStrictPositiveInteger } from "../../../infra/parse-finite-number.js";
9+
import {
10+
parseOAuthAuthorizationInput,
11+
resolveOAuthTokenExpiresAt,
12+
} from "../../../plugin-sdk/provider-oauth-runtime.js";
1013
import {
1114
buildOAuthRequestSignal,
1215
createOAuthLoginCancelledError,
@@ -62,38 +65,6 @@ async function getNodeApis(): Promise<NodeApis> {
6265
return nodeApis;
6366
}
6467

65-
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
66-
const value = input.trim();
67-
if (!value) {
68-
return {};
69-
}
70-
71-
try {
72-
const url = new URL(value);
73-
return {
74-
code: url.searchParams.get("code") ?? undefined,
75-
state: url.searchParams.get("state") ?? undefined,
76-
};
77-
} catch {
78-
// not a URL
79-
}
80-
81-
if (value.includes("#")) {
82-
const [code, state] = value.split("#", 2);
83-
return { code, state };
84-
}
85-
86-
if (value.includes("code=")) {
87-
const params = new URLSearchParams(value);
88-
return {
89-
code: params.get("code") ?? undefined,
90-
state: params.get("state") ?? undefined,
91-
};
92-
}
93-
94-
return { code: value };
95-
}
96-
9768
function formatErrorDetails(error: unknown): string {
9869
if (error instanceof Error) {
9970
const details: string[] = [`${error.name}: ${error.message}`];
@@ -123,19 +94,6 @@ function formatTokenResponseParseContext(responseBody: string): string {
12394
return `bodyBytes=${Buffer.byteLength(responseBody, "utf8")}`;
12495
}
12596

126-
function resolveTokenExpiresAt(value: unknown): number | undefined {
127-
const expiresInSeconds = parseStrictPositiveInteger(value);
128-
if (expiresInSeconds === undefined) {
129-
return undefined;
130-
}
131-
132-
const lifetimeMs = expiresInSeconds * 1000;
133-
const expiresAt = Date.now() + lifetimeMs - 5 * 60 * 1000;
134-
return Number.isSafeInteger(lifetimeMs) && Number.isSafeInteger(expiresAt)
135-
? expiresAt
136-
: undefined;
137-
}
138-
13997
function parseTokenCredentials(
14098
responseBody: string,
14199
options: {
@@ -160,7 +118,7 @@ function parseTokenCredentials(
160118
}
161119

162120
const record = data as Record<string, unknown>;
163-
const expires = resolveTokenExpiresAt(record.expires_in);
121+
const expires = resolveOAuthTokenExpiresAt(record.expires_in, { refreshSkewMs: 5 * 60 * 1000 });
164122
if (
165123
typeof record.access_token !== "string" ||
166124
!record.access_token ||
@@ -388,7 +346,7 @@ export async function loginAnthropic(options: {
388346
state = result.state;
389347
redirectUriForExchange = REDIRECT_URI;
390348
} else if (manualInput) {
391-
const parsed = parseAuthorizationInput(manualInput);
349+
const parsed = parseOAuthAuthorizationInput(manualInput);
392350
if (parsed.state && parsed.state !== expectedState) {
393351
throw new Error("OAuth state mismatch");
394352
}
@@ -402,7 +360,7 @@ export async function loginAnthropic(options: {
402360
throw manualError;
403361
}
404362
if (manualInput) {
405-
const parsed = parseAuthorizationInput(manualInput);
363+
const parsed = parseOAuthAuthorizationInput(manualInput);
406364
if (parsed.state && parsed.state !== expectedState) {
407365
throw new Error("OAuth state mismatch");
408366
}
@@ -432,7 +390,7 @@ export async function loginAnthropic(options: {
432390
options.signal,
433391
server.cancelWait,
434392
);
435-
const parsed = parseAuthorizationInput(input);
393+
const parsed = parseOAuthAuthorizationInput(input);
436394
if (parsed.state && parsed.state !== expectedState) {
437395
throw new Error("OAuth state mismatch");
438396
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
parseOAuthAuthorizationInput,
4+
resolveOAuthTokenExpiresAt,
5+
resolveOAuthTokenLifetimeMs,
6+
} from "./provider-oauth-runtime.js";
7+
8+
describe("provider OAuth runtime", () => {
9+
it("parses authorization code input from redirect URLs, query strings, and raw codes", () => {
10+
expect(
11+
parseOAuthAuthorizationInput("http://localhost/callback?code=oauth-code&state=oauth-state"),
12+
).toEqual({ code: "oauth-code", state: "oauth-state" });
13+
expect(parseOAuthAuthorizationInput("code=oauth-code&state=oauth-state")).toEqual({
14+
code: "oauth-code",
15+
state: "oauth-state",
16+
});
17+
expect(parseOAuthAuthorizationInput("oauth-code#oauth-state")).toEqual({
18+
code: "oauth-code",
19+
state: "oauth-state",
20+
});
21+
expect(parseOAuthAuthorizationInput(" oauth-code ")).toEqual({ code: "oauth-code" });
22+
expect(parseOAuthAuthorizationInput(" ")).toEqual({});
23+
});
24+
25+
it("resolves safe OAuth token lifetimes and expiry timestamps", () => {
26+
expect(resolveOAuthTokenLifetimeMs("30")).toBe(30_000);
27+
expect(resolveOAuthTokenExpiresAt(30, { nowMs: 1_000, refreshSkewMs: 5_000 })).toBe(26_000);
28+
});
29+
30+
it("rejects invalid OAuth token lifetimes", () => {
31+
expect(resolveOAuthTokenLifetimeMs(0)).toBeUndefined();
32+
expect(resolveOAuthTokenLifetimeMs(1.5)).toBeUndefined();
33+
expect(resolveOAuthTokenLifetimeMs(Number.MAX_SAFE_INTEGER)).toBeUndefined();
34+
expect(resolveOAuthTokenExpiresAt(Number.MAX_SAFE_INTEGER, { nowMs: 1_000 })).toBeUndefined();
35+
});
36+
});

src/plugin-sdk/provider-oauth-runtime.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import type { Model } from "../llm/types.js";
2-
import { resolveTimerTimeoutMs } from "../shared/number-coercion.js";
2+
import {
3+
positiveSecondsToSafeMilliseconds,
4+
resolveTimerTimeoutMs,
5+
} from "../shared/number-coercion.js";
36

47
const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" aria-hidden="true"><path fill="#fff" fill-rule="evenodd" d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"/><path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/></svg>`;
58

@@ -21,6 +24,11 @@ export type OAuthPrompt = {
2124
allowEmpty?: boolean;
2225
};
2326

27+
export type OAuthAuthorizationInput = {
28+
code?: string;
29+
state?: string;
30+
};
31+
2432
export type OAuthAuthInfo = {
2533
url: string;
2634
instructions?: string;
@@ -213,6 +221,55 @@ export function generateOAuthState(): string {
213221
return base64urlEncode(stateBytes);
214222
}
215223

224+
export function parseOAuthAuthorizationInput(input: string): OAuthAuthorizationInput {
225+
const value = input.trim();
226+
if (!value) {
227+
return {};
228+
}
229+
230+
try {
231+
const url = new URL(value);
232+
return {
233+
code: url.searchParams.get("code") ?? undefined,
234+
state: url.searchParams.get("state") ?? undefined,
235+
};
236+
} catch {
237+
// Plain pasted code or query-string input.
238+
}
239+
240+
if (value.includes("#")) {
241+
const [code, state] = value.split("#", 2);
242+
return { code, state };
243+
}
244+
245+
if (value.includes("code=")) {
246+
const params = new URLSearchParams(value);
247+
return {
248+
code: params.get("code") ?? undefined,
249+
state: params.get("state") ?? undefined,
250+
};
251+
}
252+
253+
return { code: value };
254+
}
255+
256+
export function resolveOAuthTokenLifetimeMs(value: unknown): number | undefined {
257+
return positiveSecondsToSafeMilliseconds(value);
258+
}
259+
260+
export function resolveOAuthTokenExpiresAt(
261+
value: unknown,
262+
options: { nowMs?: number; refreshSkewMs?: number } = {},
263+
): number | undefined {
264+
const lifetimeMs = resolveOAuthTokenLifetimeMs(value);
265+
if (lifetimeMs === undefined) {
266+
return undefined;
267+
}
268+
269+
const expiresAt = (options.nowMs ?? Date.now()) + lifetimeMs - (options.refreshSkewMs ?? 0);
270+
return Number.isSafeInteger(expiresAt) ? expiresAt : undefined;
271+
}
272+
216273
export function createOAuthLoginCancelledError(): Error {
217274
return new Error("Login cancelled");
218275
}

0 commit comments

Comments
 (0)