offrouter-core 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +21 -0
- package/src/audit.test.ts +302 -0
- package/src/audit.ts +553 -0
- package/src/auth/credential-chain.test.ts +202 -0
- package/src/auth/credential-chain.ts +181 -0
- package/src/auth/index.ts +32 -0
- package/src/auth/keychain.test.ts +75 -0
- package/src/auth/keychain.ts +39 -0
- package/src/auth/oauth-pkce.test.ts +184 -0
- package/src/auth/oauth-pkce.ts +409 -0
- package/src/config.test.ts +415 -0
- package/src/config.ts +484 -0
- package/src/delegation.test.ts +368 -0
- package/src/delegation.ts +605 -0
- package/src/index.ts +261 -0
- package/src/policy.test.ts +634 -0
- package/src/policy.ts +418 -0
- package/src/protocol.ts +7 -0
- package/src/providers/adapter.test.ts +293 -0
- package/src/providers/adapter.ts +324 -0
- package/src/providers/catalog-data.test.ts +258 -0
- package/src/providers/catalog-data.ts +498 -0
- package/src/providers/catalog.test.ts +84 -0
- package/src/providers/catalog.ts +483 -0
- package/src/providers/fake.ts +312 -0
- package/src/providers/openai-compatible.test.ts +366 -0
- package/src/providers/openai-compatible.ts +554 -0
- package/src/proxy/responses-mapper.test.ts +290 -0
- package/src/proxy/responses-mapper.ts +736 -0
- package/src/proxy/responses-server.test.ts +322 -0
- package/src/proxy/responses-server.ts +469 -0
- package/src/router.test.ts +562 -0
- package/src/router.ts +323 -0
- package/src/schemas.test.ts +240 -0
- package/src/schemas.ts +203 -0
- package/src/secrets.test.ts +271 -0
- package/src/secrets.ts +461 -0
- package/src/types.ts +167 -0
- package/src/usage.test.ts +283 -0
- package/src/usage.ts +669 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for OAuth PKCE flow helpers.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
generateCodeVerifier,
|
|
7
|
+
generateCodeChallenge,
|
|
8
|
+
PKCE_CONFIGS,
|
|
9
|
+
createOAuthState,
|
|
10
|
+
isOAuthProvider,
|
|
11
|
+
} from "./oauth-pkce.js";
|
|
12
|
+
|
|
13
|
+
describe("PKCE code verifier and challenge", () => {
|
|
14
|
+
it("generates a code verifier of correct length", () => {
|
|
15
|
+
const verifier = generateCodeVerifier();
|
|
16
|
+
expect(verifier).toBeTruthy();
|
|
17
|
+
expect(verifier.length).toBeGreaterThanOrEqual(43);
|
|
18
|
+
expect(verifier.length).toBeLessThanOrEqual(128);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("generates a non-empty code challenge from verifier", async () => {
|
|
22
|
+
const verifier = generateCodeVerifier();
|
|
23
|
+
const challenge = await generateCodeChallenge(verifier);
|
|
24
|
+
expect(challenge).toBeTruthy();
|
|
25
|
+
expect(typeof challenge).toBe("string");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("generates unique verifiers on each call", () => {
|
|
29
|
+
const v1 = generateCodeVerifier();
|
|
30
|
+
const v2 = generateCodeVerifier();
|
|
31
|
+
expect(v1).not.toBe(v2);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("PKCE_CONFIGS", () => {
|
|
36
|
+
it("has config for anthropic", () => {
|
|
37
|
+
const config = PKCE_CONFIGS["anthropic"];
|
|
38
|
+
expect(config).toBeDefined();
|
|
39
|
+
expect(config.authorizeUrl).toContain("console.anthropic.com");
|
|
40
|
+
expect(config.scopes).toContain("offline_access");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("has config for openai", () => {
|
|
44
|
+
const config = PKCE_CONFIGS["openai"];
|
|
45
|
+
expect(config).toBeDefined();
|
|
46
|
+
expect(config.authorizeUrl).toContain("auth.openai.com");
|
|
47
|
+
expect(config.scopes).toContain("offline_access");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("has no config for gemini", () => {
|
|
51
|
+
expect(PKCE_CONFIGS["gemini"]).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("has no config for xai", () => {
|
|
55
|
+
expect(PKCE_CONFIGS["xai"]).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("createOAuthState", () => {
|
|
60
|
+
it("creates a state string with metadata", () => {
|
|
61
|
+
const state = createOAuthState("anthropic");
|
|
62
|
+
const parsed = JSON.parse(state);
|
|
63
|
+
expect(parsed.provider).toBe("anthropic");
|
|
64
|
+
expect(parsed.nonce).toBeTruthy();
|
|
65
|
+
expect(typeof parsed.nonce).toBe("string");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("isOAuthProvider", () => {
|
|
70
|
+
it("returns true for anthropic", () => {
|
|
71
|
+
expect(isOAuthProvider("anthropic")).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("returns true for openai", () => {
|
|
75
|
+
expect(isOAuthProvider("openai")).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns false for gemini", () => {
|
|
79
|
+
expect(isOAuthProvider("gemini")).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns false for xai", () => {
|
|
83
|
+
expect(isOAuthProvider("xai")).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("startOAuthFlow", () => {
|
|
88
|
+
it("returns an authorization URL for anthropic", async () => {
|
|
89
|
+
const { startOAuthFlow } = await import("./oauth-pkce.js");
|
|
90
|
+
const result = await startOAuthFlow("anthropic", {
|
|
91
|
+
redirectUri: "http://127.0.0.1:8765/callback",
|
|
92
|
+
clientId: "test-client-id-anthropic",
|
|
93
|
+
});
|
|
94
|
+
expect(result).toBeDefined();
|
|
95
|
+
expect(result.authorizeUrl).toContain("console.anthropic.com/oauth/authorize");
|
|
96
|
+
expect(result.authorizeUrl).toContain("response_type=code");
|
|
97
|
+
expect(result.authorizeUrl).toContain("code_challenge_method=S256");
|
|
98
|
+
expect(result.state).toBeTruthy();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("returns an authorization URL for openai", async () => {
|
|
102
|
+
const { startOAuthFlow } = await import("./oauth-pkce.js");
|
|
103
|
+
const result = await startOAuthFlow("openai", {
|
|
104
|
+
redirectUri: "http://127.0.0.1:8765/callback",
|
|
105
|
+
clientId: "test-client-id-openai",
|
|
106
|
+
});
|
|
107
|
+
expect(result).toBeDefined();
|
|
108
|
+
expect(result.authorizeUrl).toContain("auth.openai.com/oauth/authorize");
|
|
109
|
+
expect(result.authorizeUrl).toContain("response_type=code");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("throws for unsupported provider", async () => {
|
|
113
|
+
const { startOAuthFlow } = await import("./oauth-pkce.js");
|
|
114
|
+
await expect(
|
|
115
|
+
startOAuthFlow("gemini", {
|
|
116
|
+
redirectUri: "http://127.0.0.1:8765/callback",
|
|
117
|
+
}),
|
|
118
|
+
).rejects.toThrow("OAuth not supported for provider: gemini");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("OAuth session store", () => {
|
|
123
|
+
it("state string does NOT contain codeVerifier or redirectUri", async () => {
|
|
124
|
+
const { startOAuthFlow } = await import("./oauth-pkce.js");
|
|
125
|
+
const result = await startOAuthFlow("anthropic", {
|
|
126
|
+
redirectUri: "http://127.0.0.1:8765/callback",
|
|
127
|
+
clientId: "test-client-id-anthropic",
|
|
128
|
+
});
|
|
129
|
+
const parsed = JSON.parse(result.state);
|
|
130
|
+
expect(parsed).not.toHaveProperty("codeVerifier");
|
|
131
|
+
expect(parsed).not.toHaveProperty("redirectUri");
|
|
132
|
+
expect(parsed).toHaveProperty("provider", "anthropic");
|
|
133
|
+
expect(parsed).toHaveProperty("nonce");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("consumeOAuthSession is one-shot: second call returns null", async () => {
|
|
137
|
+
const { startOAuthFlow, consumeOAuthSession } = await import(
|
|
138
|
+
"./oauth-pkce.js"
|
|
139
|
+
);
|
|
140
|
+
const result = await startOAuthFlow("anthropic", {
|
|
141
|
+
redirectUri: "http://127.0.0.1:8765/callback",
|
|
142
|
+
clientId: "test-client-id-anthropic",
|
|
143
|
+
});
|
|
144
|
+
const parsed = JSON.parse(result.state);
|
|
145
|
+
|
|
146
|
+
const first = consumeOAuthSession(parsed.nonce);
|
|
147
|
+
expect(first).not.toBeNull();
|
|
148
|
+
expect(first!.codeVerifier).toBeTruthy();
|
|
149
|
+
expect(first!.redirectUri).toBe("http://127.0.0.1:8765/callback");
|
|
150
|
+
|
|
151
|
+
const second = consumeOAuthSession(parsed.nonce);
|
|
152
|
+
expect(second).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("cancelOAuthSession removes the session", async () => {
|
|
156
|
+
const { startOAuthFlow, cancelOAuthSession, consumeOAuthSession } =
|
|
157
|
+
await import("./oauth-pkce.js");
|
|
158
|
+
const result = await startOAuthFlow("anthropic", {
|
|
159
|
+
redirectUri: "http://127.0.0.1:8765/callback",
|
|
160
|
+
clientId: "test-client-id-anthropic",
|
|
161
|
+
});
|
|
162
|
+
const parsed = JSON.parse(result.state);
|
|
163
|
+
|
|
164
|
+
cancelOAuthSession(parsed.nonce);
|
|
165
|
+
const session = consumeOAuthSession(parsed.nonce);
|
|
166
|
+
expect(session).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("consumeOAuthSession returns null for unknown nonce", async () => {
|
|
170
|
+
const { consumeOAuthSession } = await import("./oauth-pkce.js");
|
|
171
|
+
const result = consumeOAuthSession("nonexistent-nonce");
|
|
172
|
+
expect(result).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("refreshTokens", () => {
|
|
177
|
+
it("throws when no store is provided", async () => {
|
|
178
|
+
const { refreshTokens } = await import("./oauth-pkce.js");
|
|
179
|
+
await expect(
|
|
180
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
181
|
+
refreshTokens("anthropic", "old-refresh-token", undefined as any),
|
|
182
|
+
).rejects.toThrow("SecretStore is required");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth PKCE flow for OffRouter.
|
|
3
|
+
*
|
|
4
|
+
* Supports Anthropic and OpenAI/Codex only (opt-in, bring-your-own-client-id).
|
|
5
|
+
* Gemini/xAI/Z.ai are API-key only and do NOT implement OAuth.
|
|
6
|
+
*
|
|
7
|
+
* Never auto-triggers OAuth. Requires explicit user configuration.
|
|
8
|
+
* Tokens are stored in the SecretStore (keychain or file fallback).
|
|
9
|
+
*/
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import type { SecretStore } from "../secrets.js";
|
|
12
|
+
import { Redacted } from "./credential-chain.js";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// PKCE code generation
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const VERIFIER_CHARS =
|
|
19
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate a random code verifier for PKCE (RFC 7636).
|
|
23
|
+
* Length is between 43 and 128 characters.
|
|
24
|
+
*/
|
|
25
|
+
export function generateCodeVerifier(): string {
|
|
26
|
+
const length = 64 + (randomBytes(1)[0]! % 32); // 64-95 chars
|
|
27
|
+
const bytes = randomBytes(length);
|
|
28
|
+
let verifier = "";
|
|
29
|
+
for (let i = 0; i < length; i++) {
|
|
30
|
+
verifier += VERIFIER_CHARS[bytes[i]! % VERIFIER_CHARS.length];
|
|
31
|
+
}
|
|
32
|
+
return verifier;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Generate a S256 code challenge from a verifier (RFC 7636).
|
|
37
|
+
* Uses Web Crypto API (available in Node >= 22, Workers, browsers).
|
|
38
|
+
*/
|
|
39
|
+
export async function generateCodeChallenge(
|
|
40
|
+
verifier: string,
|
|
41
|
+
): Promise<string> {
|
|
42
|
+
const encoder = new TextEncoder();
|
|
43
|
+
const data = encoder.encode(verifier);
|
|
44
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
45
|
+
const base64 = btoa(
|
|
46
|
+
String.fromCharCode(...new Uint8Array(digest)),
|
|
47
|
+
);
|
|
48
|
+
return base64
|
|
49
|
+
.replace(/\+/g, "-")
|
|
50
|
+
.replace(/\//g, "_")
|
|
51
|
+
.replace(/=+$/, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// OAuth provider configs
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
export interface OAuthProviderConfig {
|
|
59
|
+
authorizeUrl: string;
|
|
60
|
+
tokenUrl: string;
|
|
61
|
+
scopes: string[];
|
|
62
|
+
defaultClientId?: string;
|
|
63
|
+
clientIdEnvVar?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* OAuth PKCE configurations for supported providers.
|
|
68
|
+
* Only Anthropic and OpenAI/Codex are supported.
|
|
69
|
+
* Gemini/xAI/Z.ai are API-key only.
|
|
70
|
+
*/
|
|
71
|
+
export const PKCE_CONFIGS: Record<string, OAuthProviderConfig> = {
|
|
72
|
+
anthropic: {
|
|
73
|
+
authorizeUrl: "https://console.anthropic.com/oauth/authorize",
|
|
74
|
+
tokenUrl: "https://console.anthropic.com/oauth/token",
|
|
75
|
+
scopes: ["api", "offline_access"],
|
|
76
|
+
clientIdEnvVar: "OFFROUTER_ANTHROPIC_CLIENT_ID",
|
|
77
|
+
// No default client id; users must bring their own.
|
|
78
|
+
},
|
|
79
|
+
openai: {
|
|
80
|
+
authorizeUrl: "https://auth.openai.com/oauth/authorize",
|
|
81
|
+
tokenUrl: "https://auth.openai.com/oauth/token",
|
|
82
|
+
scopes: ["offline_access"],
|
|
83
|
+
clientIdEnvVar: "OFFROUTER_OPENAI_CLIENT_ID",
|
|
84
|
+
// Note: reuses Codex client id; documented as fragile.
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Returns true if the provider supports OAuth (opt-in).
|
|
90
|
+
*/
|
|
91
|
+
export function isOAuthProvider(providerId: string): boolean {
|
|
92
|
+
return providerId in PKCE_CONFIGS;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// OAuth session store (in-memory, out-of-band — NOT embedded in state)
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/** Time-to-live for a stored OAuth session (10 minutes). */
|
|
100
|
+
const SESSION_TTL_MS = 10 * 60 * 1000;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* In-memory session data associated with a PKCE nonce.
|
|
104
|
+
* Never serialised into the caller-visible state string.
|
|
105
|
+
*/
|
|
106
|
+
export interface OAuthSession {
|
|
107
|
+
codeVerifier: string;
|
|
108
|
+
redirectUri: string;
|
|
109
|
+
createdAt: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Module-level session store: nonce → session. */
|
|
113
|
+
const sessions = new Map<string, OAuthSession>();
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Retrieve and delete a session by nonce (one-shot consume).
|
|
117
|
+
* Returns null if the nonce is unknown, already consumed, or expired.
|
|
118
|
+
*/
|
|
119
|
+
export function consumeOAuthSession(nonce: string): OAuthSession | null {
|
|
120
|
+
const session = sessions.get(nonce);
|
|
121
|
+
if (!session) return null;
|
|
122
|
+
sessions.delete(nonce);
|
|
123
|
+
if (Date.now() - session.createdAt > SESSION_TTL_MS) {
|
|
124
|
+
return null; // expired
|
|
125
|
+
}
|
|
126
|
+
return session;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Cancel / discard a session without consuming it.
|
|
131
|
+
*/
|
|
132
|
+
export function cancelOAuthSession(nonce: string): void {
|
|
133
|
+
sessions.delete(nonce);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// OAuth flow state
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
export interface OAuthFlowState {
|
|
141
|
+
provider: string;
|
|
142
|
+
nonce: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface OAuthFlowResult {
|
|
146
|
+
authorizeUrl: string;
|
|
147
|
+
state: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface OAuthTokenResponse {
|
|
151
|
+
accessToken: Redacted<string>;
|
|
152
|
+
refreshToken?: Redacted<string>;
|
|
153
|
+
expiresIn?: number;
|
|
154
|
+
scope?: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface OAuthStartOptions {
|
|
158
|
+
redirectUri: string;
|
|
159
|
+
/** Override client ID (default from env or PKCE_CONFIGS). */
|
|
160
|
+
clientId?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface OAuthTokenOptions {
|
|
164
|
+
store: SecretStore;
|
|
165
|
+
fetch?: typeof globalThis.fetch;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Create an OAuth state string that encodes provider metadata.
|
|
170
|
+
* Used to validate the callback matches the initiated flow.
|
|
171
|
+
*/
|
|
172
|
+
export function createOAuthState(provider: string): string {
|
|
173
|
+
const nonce = randomBytes(16).toString("hex");
|
|
174
|
+
return JSON.stringify({ provider, nonce });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Start an OAuth PKCE flow for the given provider.
|
|
179
|
+
* Returns the authorization URL and state for redirect.
|
|
180
|
+
* Never auto-triggers; requires explicit user configuration.
|
|
181
|
+
*/
|
|
182
|
+
export async function startOAuthFlow(
|
|
183
|
+
provider: string,
|
|
184
|
+
options: OAuthStartOptions,
|
|
185
|
+
): Promise<OAuthFlowResult> {
|
|
186
|
+
const config = PKCE_CONFIGS[provider];
|
|
187
|
+
if (!config) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`OAuth not supported for provider: ${provider}. ` +
|
|
190
|
+
`OAuth is only available for anthropic and openai providers.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const clientId =
|
|
195
|
+
options.clientId ??
|
|
196
|
+
(config.clientIdEnvVar ? process.env[config.clientIdEnvVar] : undefined);
|
|
197
|
+
|
|
198
|
+
if (!clientId) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Client ID required for ${provider} OAuth. ` +
|
|
201
|
+
`Set the ${config.clientIdEnvVar ?? "client id"} environment variable ` +
|
|
202
|
+
`or pass it explicitly.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const codeVerifier = generateCodeVerifier();
|
|
207
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
208
|
+
const state = createOAuthState(provider);
|
|
209
|
+
|
|
210
|
+
const params = new URLSearchParams({
|
|
211
|
+
response_type: "code",
|
|
212
|
+
client_id: clientId,
|
|
213
|
+
redirect_uri: options.redirectUri,
|
|
214
|
+
code_challenge: codeChallenge,
|
|
215
|
+
code_challenge_method: "S256",
|
|
216
|
+
state,
|
|
217
|
+
scope: config.scopes.join(" "),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const authorizeUrl = `${config.authorizeUrl}?${params.toString()}`;
|
|
221
|
+
|
|
222
|
+
// Store the verifier OOB so it never appears in the caller-visible state
|
|
223
|
+
const parsed = JSON.parse(state) as { provider: string; nonce: string };
|
|
224
|
+
sessions.set(parsed.nonce, {
|
|
225
|
+
codeVerifier,
|
|
226
|
+
redirectUri: options.redirectUri,
|
|
227
|
+
createdAt: Date.now(),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
authorizeUrl,
|
|
232
|
+
state, // clean — provider + nonce only, no secrets
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Exchange an authorization code for tokens.
|
|
238
|
+
* Called after the user completes the OAuth flow in their browser.
|
|
239
|
+
*/
|
|
240
|
+
export async function exchangeCodeForTokens(
|
|
241
|
+
provider: string,
|
|
242
|
+
code: string,
|
|
243
|
+
statePayload: string,
|
|
244
|
+
options: OAuthTokenOptions,
|
|
245
|
+
): Promise<OAuthTokenResponse> {
|
|
246
|
+
const config = PKCE_CONFIGS[provider];
|
|
247
|
+
if (!config) {
|
|
248
|
+
throw new Error(`OAuth not supported for provider: ${provider}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let stateData: { provider?: string; nonce?: string };
|
|
252
|
+
try {
|
|
253
|
+
stateData = JSON.parse(statePayload) as typeof stateData;
|
|
254
|
+
} catch {
|
|
255
|
+
throw new Error("Invalid OAuth state payload");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!stateData.nonce) {
|
|
259
|
+
throw new Error("Missing nonce in OAuth state");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const session = consumeOAuthSession(stateData.nonce);
|
|
263
|
+
if (!session) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
"OAuth session expired, already consumed, or not found. Please restart the OAuth flow.",
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!session.codeVerifier) {
|
|
270
|
+
throw new Error("Missing code verifier in OAuth session");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const clientId =
|
|
274
|
+
config.clientIdEnvVar
|
|
275
|
+
? (process.env[config.clientIdEnvVar] ?? "")
|
|
276
|
+
: "";
|
|
277
|
+
|
|
278
|
+
const tokenParams = new URLSearchParams({
|
|
279
|
+
grant_type: "authorization_code",
|
|
280
|
+
code,
|
|
281
|
+
redirect_uri: session.redirectUri ?? "http://127.0.0.1:8765/callback",
|
|
282
|
+
client_id: clientId,
|
|
283
|
+
code_verifier: session.codeVerifier,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
287
|
+
const response = await fetchImpl(config.tokenUrl, {
|
|
288
|
+
method: "POST",
|
|
289
|
+
headers: {
|
|
290
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
291
|
+
},
|
|
292
|
+
body: tokenParams.toString(),
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
if (!response.ok) {
|
|
296
|
+
const body = await response.text().catch(() => "");
|
|
297
|
+
throw new Error(
|
|
298
|
+
`Token exchange failed for ${provider}: HTTP ${response.status} ${body}`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const json = (await response.json()) as {
|
|
303
|
+
access_token?: string;
|
|
304
|
+
refresh_token?: string;
|
|
305
|
+
expires_in?: number;
|
|
306
|
+
scope?: string;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
if (!json.access_token) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
`Token exchange for ${provider} returned no access_token`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Store tokens in secret store
|
|
316
|
+
if (options.store) {
|
|
317
|
+
await options.store.set(`${provider}_access_token`, json.access_token);
|
|
318
|
+
if (json.refresh_token) {
|
|
319
|
+
await options.store.set(
|
|
320
|
+
`${provider}_refresh_token`,
|
|
321
|
+
json.refresh_token,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
accessToken: new Redacted(json.access_token),
|
|
328
|
+
refreshToken: json.refresh_token
|
|
329
|
+
? new Redacted(json.refresh_token)
|
|
330
|
+
: undefined,
|
|
331
|
+
expiresIn: json.expires_in,
|
|
332
|
+
scope: json.scope,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Refresh an OAuth token using a refresh token.
|
|
338
|
+
* Updates the stored tokens on success.
|
|
339
|
+
*/
|
|
340
|
+
export async function refreshTokens(
|
|
341
|
+
provider: string,
|
|
342
|
+
refreshToken: string,
|
|
343
|
+
store: SecretStore,
|
|
344
|
+
options?: { fetch?: typeof globalThis.fetch },
|
|
345
|
+
): Promise<OAuthTokenResponse> {
|
|
346
|
+
const config = PKCE_CONFIGS[provider];
|
|
347
|
+
if (!config) {
|
|
348
|
+
throw new Error(`OAuth not supported for provider: ${provider}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!store) {
|
|
352
|
+
throw new Error("SecretStore is required for token refresh");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const clientId =
|
|
356
|
+
config.clientIdEnvVar
|
|
357
|
+
? (process.env[config.clientIdEnvVar] ?? "")
|
|
358
|
+
: "";
|
|
359
|
+
|
|
360
|
+
const params = new URLSearchParams({
|
|
361
|
+
grant_type: "refresh_token",
|
|
362
|
+
refresh_token: refreshToken,
|
|
363
|
+
client_id: clientId,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const fetchImpl = options?.fetch ?? globalThis.fetch.bind(globalThis);
|
|
367
|
+
const response = await fetchImpl(config.tokenUrl, {
|
|
368
|
+
method: "POST",
|
|
369
|
+
headers: {
|
|
370
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
371
|
+
},
|
|
372
|
+
body: params.toString(),
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
if (!response.ok) {
|
|
376
|
+
const body = await response.text().catch(() => "");
|
|
377
|
+
throw new Error(
|
|
378
|
+
`Token refresh failed for ${provider}: HTTP ${response.status} ${body}`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const json = (await response.json()) as {
|
|
383
|
+
access_token?: string;
|
|
384
|
+
refresh_token?: string;
|
|
385
|
+
expires_in?: number;
|
|
386
|
+
scope?: string;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
if (!json.access_token) {
|
|
390
|
+
throw new Error(
|
|
391
|
+
`Token refresh for ${provider} returned no access_token`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Update stored tokens
|
|
396
|
+
await store.set(`${provider}_access_token`, json.access_token);
|
|
397
|
+
if (json.refresh_token) {
|
|
398
|
+
await store.set(`${provider}_refresh_token`, json.refresh_token);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
accessToken: new Redacted(json.access_token),
|
|
403
|
+
refreshToken: json.refresh_token
|
|
404
|
+
? new Redacted(json.refresh_token)
|
|
405
|
+
: undefined,
|
|
406
|
+
expiresIn: json.expires_in,
|
|
407
|
+
scope: json.scope,
|
|
408
|
+
};
|
|
409
|
+
}
|