ampcode-connector 0.1.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/config.yaml +20 -0
- package/package.json +51 -0
- package/src/auth/callback-server.ts +92 -0
- package/src/auth/configs.ts +57 -0
- package/src/auth/discovery.ts +100 -0
- package/src/auth/oauth.ts +183 -0
- package/src/auth/pkce.ts +22 -0
- package/src/auth/store.ts +105 -0
- package/src/cli/ansi.ts +37 -0
- package/src/cli/setup.ts +162 -0
- package/src/cli/status.ts +52 -0
- package/src/cli/tui.ts +177 -0
- package/src/config/config.ts +108 -0
- package/src/constants.ts +64 -0
- package/src/index.ts +70 -0
- package/src/providers/anthropic.ts +65 -0
- package/src/providers/antigravity.ts +110 -0
- package/src/providers/base.ts +68 -0
- package/src/providers/codex.ts +36 -0
- package/src/providers/gemini.ts +83 -0
- package/src/proxy/rewriter.ts +69 -0
- package/src/proxy/upstream.ts +41 -0
- package/src/routing/affinity.ts +56 -0
- package/src/routing/cooldown.ts +88 -0
- package/src/routing/router.ts +201 -0
- package/src/server/server.ts +147 -0
- package/src/utils/browser.ts +19 -0
- package/src/utils/code-assist.ts +42 -0
- package/src/utils/encoding.ts +9 -0
- package/src/utils/logger.ts +80 -0
- package/src/utils/path.ts +52 -0
- package/src/utils/streaming.ts +124 -0
- package/tsconfig.json +30 -0
package/config.yaml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# ampcode-connector configuration
|
|
2
|
+
# ================================
|
|
3
|
+
|
|
4
|
+
# Port the proxy server listens on
|
|
5
|
+
port: 7860
|
|
6
|
+
|
|
7
|
+
# Amp upstream URL (fallback for non-intercepted requests)
|
|
8
|
+
# ampUpstreamUrl: https://ampcode.com
|
|
9
|
+
|
|
10
|
+
# Amp API key (optional - also reads from AMP_API_KEY env or ~/.local/share/amp/secrets.json)
|
|
11
|
+
# ampApiKey: your-amp-api-key
|
|
12
|
+
|
|
13
|
+
# Log level: debug, info, warn, error
|
|
14
|
+
logLevel: info
|
|
15
|
+
|
|
16
|
+
# Enable/disable individual providers
|
|
17
|
+
providers:
|
|
18
|
+
anthropic: true # Claude Code OAuth -> api.anthropic.com
|
|
19
|
+
codex: true # OpenAI Codex OAuth -> api.openai.com
|
|
20
|
+
google: true # Google OAuth -> Gemini CLI + Antigravity dual quota
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ampcode-connector",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Proxy Amp CLI through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/nghyane/ampcode-connector"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"amp",
|
|
12
|
+
"ampcode",
|
|
13
|
+
"proxy",
|
|
14
|
+
"claude",
|
|
15
|
+
"openai",
|
|
16
|
+
"gemini",
|
|
17
|
+
"oauth"
|
|
18
|
+
],
|
|
19
|
+
"module": "src/index.ts",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"files": [
|
|
22
|
+
"src/**/*.ts",
|
|
23
|
+
"config.yaml",
|
|
24
|
+
"tsconfig.json"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"ampcode-connector": "src/index.ts"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"start": "bun run src/index.ts",
|
|
34
|
+
"dev": "bun run --watch src/index.ts",
|
|
35
|
+
"setup": "bun run src/index.ts setup",
|
|
36
|
+
"login": "bun run src/index.ts login",
|
|
37
|
+
"test": "bun test",
|
|
38
|
+
"check": "biome check src/ tests/ && tsc --noEmit && bun test",
|
|
39
|
+
"format": "biome check --write src/ tests/"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@biomejs/biome": "^2.4.0",
|
|
43
|
+
"@types/bun": "latest"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"typescript": "^5"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@google/genai": "^1.41.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Temporary localhost HTTP server to receive OAuth callbacks. */
|
|
2
|
+
|
|
3
|
+
import { logger } from "../utils/logger.ts";
|
|
4
|
+
|
|
5
|
+
export interface CallbackResult {
|
|
6
|
+
code: string;
|
|
7
|
+
state: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_TIMEOUT = 120_000;
|
|
11
|
+
|
|
12
|
+
export async function waitForCallback(
|
|
13
|
+
preferredPort: number,
|
|
14
|
+
callbackPath: string,
|
|
15
|
+
expectedState: string,
|
|
16
|
+
timeout: number = DEFAULT_TIMEOUT,
|
|
17
|
+
): Promise<CallbackResult> {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
20
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
21
|
+
|
|
22
|
+
const cleanup = () => {
|
|
23
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
24
|
+
if (server) {
|
|
25
|
+
server.stop(true);
|
|
26
|
+
server = null;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
timeoutId = setTimeout(() => {
|
|
31
|
+
cleanup();
|
|
32
|
+
reject(new Error(`OAuth callback timed out after ${timeout}ms`));
|
|
33
|
+
}, timeout);
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
server = Bun.serve({
|
|
37
|
+
port: preferredPort,
|
|
38
|
+
hostname: "localhost",
|
|
39
|
+
fetch(req) {
|
|
40
|
+
const url = new URL(req.url);
|
|
41
|
+
if (url.pathname !== callbackPath) return new Response("Not found", { status: 404 });
|
|
42
|
+
|
|
43
|
+
const code = url.searchParams.get("code");
|
|
44
|
+
const state = url.searchParams.get("state");
|
|
45
|
+
const error = url.searchParams.get("error");
|
|
46
|
+
const errorDescription = url.searchParams.get("error_description");
|
|
47
|
+
|
|
48
|
+
if (error) {
|
|
49
|
+
cleanup();
|
|
50
|
+
reject(new Error(`OAuth error: ${error} - ${errorDescription ?? "unknown"}`));
|
|
51
|
+
return htmlPage("Authentication Failed", `Error: ${error}. ${errorDescription ?? ""}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!code || !state) {
|
|
55
|
+
cleanup();
|
|
56
|
+
reject(new Error("Missing code or state in OAuth callback"));
|
|
57
|
+
return htmlPage("Authentication Failed", "Missing authorization code.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (state !== expectedState) {
|
|
61
|
+
cleanup();
|
|
62
|
+
reject(new Error("State mismatch in OAuth callback (possible CSRF)"));
|
|
63
|
+
return htmlPage("Authentication Failed", "State validation failed.");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
cleanup();
|
|
67
|
+
resolve({ code, state });
|
|
68
|
+
return htmlPage("Authentication Successful", "You can close this window and return to the terminal.");
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
logger.debug("Callback server started", { provider: `port:${preferredPort}` });
|
|
73
|
+
} catch (err) {
|
|
74
|
+
cleanup();
|
|
75
|
+
reject(new Error(`Failed to start callback server on port ${preferredPort}: ${err}`));
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function htmlPage(title: string, message: string): Response {
|
|
81
|
+
const body = `<!DOCTYPE html>
|
|
82
|
+
<html>
|
|
83
|
+
<head><title>${title}</title></head>
|
|
84
|
+
<body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
|
|
85
|
+
<div style="text-align: center;">
|
|
86
|
+
<h1>${title}</h1>
|
|
87
|
+
<p>${message}</p>
|
|
88
|
+
</div>
|
|
89
|
+
</body>
|
|
90
|
+
</html>`;
|
|
91
|
+
return new Response(body, { headers: { "Content-Type": "text/html" } });
|
|
92
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ANTHROPIC_TOKEN_URL, GOOGLE_TOKEN_URL, OPENAI_TOKEN_URL } from "../constants.ts";
|
|
2
|
+
import { discoverAnthropic, discoverCodex, discoverGoogle } from "./discovery.ts";
|
|
3
|
+
import type { OAuthConfig } from "./oauth.ts";
|
|
4
|
+
|
|
5
|
+
export const anthropic: OAuthConfig = {
|
|
6
|
+
providerName: "anthropic",
|
|
7
|
+
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
|
8
|
+
authorizeUrl: "https://claude.ai/oauth/authorize",
|
|
9
|
+
tokenUrl: ANTHROPIC_TOKEN_URL,
|
|
10
|
+
callbackPort: 54545,
|
|
11
|
+
callbackPath: "/callback",
|
|
12
|
+
scopes: "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers",
|
|
13
|
+
bodyFormat: "json",
|
|
14
|
+
expiryBuffer: true,
|
|
15
|
+
sendStateInExchange: true,
|
|
16
|
+
authorizeExtra: { code: "true" },
|
|
17
|
+
extractIdentity: discoverAnthropic,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const codex: OAuthConfig = {
|
|
21
|
+
providerName: "codex",
|
|
22
|
+
clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
|
|
23
|
+
authorizeUrl: "https://auth.openai.com/oauth/authorize",
|
|
24
|
+
tokenUrl: OPENAI_TOKEN_URL,
|
|
25
|
+
callbackPort: 1455,
|
|
26
|
+
callbackPath: "/auth/callback",
|
|
27
|
+
scopes: "openid profile email offline_access",
|
|
28
|
+
bodyFormat: "form",
|
|
29
|
+
expiryBuffer: true,
|
|
30
|
+
authorizeExtra: {
|
|
31
|
+
id_token_add_organizations: "true",
|
|
32
|
+
codex_cli_simplified_flow: "true",
|
|
33
|
+
originator: "opencode",
|
|
34
|
+
},
|
|
35
|
+
extractIdentity: discoverCodex,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const google: OAuthConfig = {
|
|
39
|
+
providerName: "google",
|
|
40
|
+
clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
|
|
41
|
+
clientSecret: "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",
|
|
42
|
+
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
43
|
+
tokenUrl: GOOGLE_TOKEN_URL,
|
|
44
|
+
callbackPort: 51121,
|
|
45
|
+
callbackPath: "/oauth-callback",
|
|
46
|
+
scopes: [
|
|
47
|
+
"https://www.googleapis.com/auth/cloud-platform",
|
|
48
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
49
|
+
"https://www.googleapis.com/auth/userinfo.profile",
|
|
50
|
+
"https://www.googleapis.com/auth/cclog",
|
|
51
|
+
"https://www.googleapis.com/auth/experimentsandconfigs",
|
|
52
|
+
].join(" "),
|
|
53
|
+
bodyFormat: "form",
|
|
54
|
+
expiryBuffer: true,
|
|
55
|
+
authorizeExtra: { access_type: "offline", prompt: "consent" },
|
|
56
|
+
extractIdentity: discoverGoogle,
|
|
57
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ANTIGRAVITY_DAILY_ENDPOINT,
|
|
3
|
+
AUTOPUSH_ENDPOINT,
|
|
4
|
+
CODE_ASSIST_ENDPOINT,
|
|
5
|
+
DEFAULT_ANTIGRAVITY_PROJECT,
|
|
6
|
+
} from "../constants.ts";
|
|
7
|
+
import { fromBase64url } from "../utils/encoding.ts";
|
|
8
|
+
import { logger } from "../utils/logger.ts";
|
|
9
|
+
import type { Credentials } from "./store.ts";
|
|
10
|
+
|
|
11
|
+
type Raw = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
export async function discoverAnthropic(raw: Raw): Promise<Partial<Credentials>> {
|
|
14
|
+
const account = raw.account as { uuid?: string; email_address?: string } | undefined;
|
|
15
|
+
return {
|
|
16
|
+
...(account?.email_address ? { email: account.email_address } : {}),
|
|
17
|
+
...(account?.uuid ? { accountId: account.uuid } : {}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function discoverCodex(raw: Raw): Promise<Partial<Credentials>> {
|
|
22
|
+
const accessToken = raw.access_token as string;
|
|
23
|
+
const accountId = accountIdFromJWT(accessToken);
|
|
24
|
+
const email = await fetchEmail("https://api.openai.com/v1/me", accessToken);
|
|
25
|
+
return {
|
|
26
|
+
...(accountId ? { accountId } : {}),
|
|
27
|
+
...(email ? { email } : {}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function discoverGoogle(raw: Raw): Promise<Partial<Credentials>> {
|
|
32
|
+
const accessToken = raw.access_token as string;
|
|
33
|
+
const email = await fetchEmail("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", accessToken);
|
|
34
|
+
const projectId = await findProject(accessToken);
|
|
35
|
+
return { ...(email ? { email } : {}), ...(projectId ? { projectId } : {}) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function accountIdFromJWT(token: string): string | null {
|
|
39
|
+
try {
|
|
40
|
+
const parts = token.split(".");
|
|
41
|
+
if (parts.length < 2 || !parts[1]) return null;
|
|
42
|
+
const payload = JSON.parse(new TextDecoder().decode(fromBase64url(parts[1]))) as Raw;
|
|
43
|
+
const auth = payload["https://api.openai.com/auth"] as Raw | undefined;
|
|
44
|
+
return (auth?.["chatgpt_account_id"] as string) ?? null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function fetchEmail(url: string, accessToken: string): Promise<string | undefined> {
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } });
|
|
53
|
+
if (!res.ok) return undefined;
|
|
54
|
+
return ((await res.json()) as { email?: string }).email;
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const CCA_HEADERS = {
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
"User-Agent": "google-api-nodejs-client/9.15.1",
|
|
63
|
+
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
|
64
|
+
"Client-Metadata": JSON.stringify({
|
|
65
|
+
ideType: "IDE_UNSPECIFIED",
|
|
66
|
+
platform: "PLATFORM_UNSPECIFIED",
|
|
67
|
+
pluginType: "GEMINI",
|
|
68
|
+
}),
|
|
69
|
+
} as const;
|
|
70
|
+
|
|
71
|
+
const CCA_BODY = JSON.stringify({
|
|
72
|
+
metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
async function findProject(accessToken: string): Promise<string | undefined> {
|
|
76
|
+
for (const endpoint of [CODE_ASSIST_ENDPOINT, ANTIGRAVITY_DAILY_ENDPOINT, AUTOPUSH_ENDPOINT]) {
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { ...CCA_HEADERS, Authorization: `Bearer ${accessToken}` },
|
|
81
|
+
body: CCA_BODY,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
logger.debug(`loadCodeAssist ${res.status} at ${endpoint}`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const body = (await res.json()) as { cloudaicompanionProject?: string | { id?: string } };
|
|
90
|
+
const proj = body.cloudaicompanionProject;
|
|
91
|
+
const id = typeof proj === "string" ? proj : proj?.id;
|
|
92
|
+
if (id) return id;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
logger.debug(`loadCodeAssist error at ${endpoint}`, { error: String(err) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
logger.warn(`Project discovery failed, using fallback: ${DEFAULT_ANTIGRAVITY_PROJECT}`);
|
|
99
|
+
return DEFAULT_ANTIGRAVITY_PROJECT;
|
|
100
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { TOKEN_EXPIRY_BUFFER_MS } from "../constants.ts";
|
|
2
|
+
import * as browser from "../utils/browser.ts";
|
|
3
|
+
import { logger } from "../utils/logger.ts";
|
|
4
|
+
import { waitForCallback } from "./callback-server.ts";
|
|
5
|
+
import { generatePKCE, generateState } from "./pkce.ts";
|
|
6
|
+
import type { Credentials, ProviderName } from "./store.ts";
|
|
7
|
+
import * as store from "./store.ts";
|
|
8
|
+
|
|
9
|
+
export interface OAuthConfig {
|
|
10
|
+
providerName: ProviderName;
|
|
11
|
+
clientId: string;
|
|
12
|
+
clientSecret?: string;
|
|
13
|
+
authorizeUrl: string;
|
|
14
|
+
tokenUrl: string;
|
|
15
|
+
callbackPort: number;
|
|
16
|
+
callbackPath: string;
|
|
17
|
+
scopes: string;
|
|
18
|
+
bodyFormat: "json" | "form";
|
|
19
|
+
authorizeExtra?: Record<string, string>;
|
|
20
|
+
expiryBuffer?: boolean;
|
|
21
|
+
sendStateInExchange?: boolean;
|
|
22
|
+
extractIdentity?: (raw: Record<string, unknown>) => Promise<Partial<Credentials>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function token(config: OAuthConfig, account = 0): Promise<string | null> {
|
|
26
|
+
const creds = store.get(config.providerName, account);
|
|
27
|
+
|
|
28
|
+
if (!creds) {
|
|
29
|
+
try {
|
|
30
|
+
return (await serialize(config)).accessToken;
|
|
31
|
+
} catch (err) {
|
|
32
|
+
logger.error(`Auto-login failed for ${config.providerName}`, { error: String(err) });
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (store.fresh(creds)) return creds.accessToken;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
return (await refresh(config, creds.refreshToken, account)).accessToken;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
logger.error(`Token refresh failed for ${config.providerName}:${account}`, { error: String(err) });
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function tokenFromAny(config: OAuthConfig): Promise<{ accessToken: string; account: number } | null> {
|
|
48
|
+
for (const { account, credentials: c } of store.getAll(config.providerName)) {
|
|
49
|
+
if (store.fresh(c)) return { accessToken: c.accessToken, account };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const { account, credentials: c } of store.getAll(config.providerName)) {
|
|
53
|
+
if (!c.refreshToken) continue;
|
|
54
|
+
try {
|
|
55
|
+
const refreshed = await refresh(config, c.refreshToken, account);
|
|
56
|
+
return { accessToken: refreshed.accessToken, account };
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function ready(config: OAuthConfig): boolean {
|
|
66
|
+
return store.exists(config.providerName);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function accountCount(config: OAuthConfig): number {
|
|
70
|
+
return store.count(config.providerName);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function login(config: OAuthConfig): Promise<Credentials> {
|
|
74
|
+
const { verifier, challenge } = await generatePKCE();
|
|
75
|
+
const state = generateState();
|
|
76
|
+
const redirectUri = `http://localhost:${config.callbackPort}${config.callbackPath}`;
|
|
77
|
+
|
|
78
|
+
const authUrl = new URL(config.authorizeUrl);
|
|
79
|
+
const q = authUrl.searchParams;
|
|
80
|
+
q.set("client_id", config.clientId);
|
|
81
|
+
q.set("response_type", "code");
|
|
82
|
+
q.set("redirect_uri", redirectUri);
|
|
83
|
+
q.set("scope", config.scopes);
|
|
84
|
+
q.set("code_challenge", challenge);
|
|
85
|
+
q.set("code_challenge_method", "S256");
|
|
86
|
+
q.set("state", state);
|
|
87
|
+
if (config.authorizeExtra) {
|
|
88
|
+
for (const [k, v] of Object.entries(config.authorizeExtra)) q.set(k, v);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
logger.info(`Opening browser for ${config.providerName} OAuth...`);
|
|
92
|
+
if (!(await browser.open(authUrl.toString()))) {
|
|
93
|
+
logger.warn("Could not open browser. Please open this URL manually:");
|
|
94
|
+
logger.info(authUrl.toString());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const callback = await waitForCallback(config.callbackPort, config.callbackPath, state);
|
|
98
|
+
|
|
99
|
+
const exchangeParams: Record<string, string> = {
|
|
100
|
+
grant_type: "authorization_code",
|
|
101
|
+
code: callback.code,
|
|
102
|
+
redirect_uri: redirectUri,
|
|
103
|
+
code_verifier: verifier,
|
|
104
|
+
};
|
|
105
|
+
if (config.sendStateInExchange) exchangeParams["state"] = callback.state;
|
|
106
|
+
|
|
107
|
+
const raw = await exchange(config, exchangeParams);
|
|
108
|
+
let credentials = parseTokenFields(raw, config);
|
|
109
|
+
|
|
110
|
+
if (config.extractIdentity) {
|
|
111
|
+
try {
|
|
112
|
+
credentials = { ...credentials, ...(await config.extractIdentity(raw)) };
|
|
113
|
+
} catch (err) {
|
|
114
|
+
logger.error(`${config.providerName} identity extraction failed`, { error: String(err) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const existing = store.findByIdentity(config.providerName, credentials);
|
|
119
|
+
const account = existing ?? store.nextAccount(config.providerName);
|
|
120
|
+
|
|
121
|
+
if (!credentials.refreshToken) {
|
|
122
|
+
credentials.refreshToken = store.get(config.providerName, account)?.refreshToken ?? "";
|
|
123
|
+
}
|
|
124
|
+
if (!credentials.refreshToken) {
|
|
125
|
+
throw new Error(`No refresh token for ${config.providerName}. Revoke app access and try again.`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
store.save(config.providerName, credentials, account);
|
|
129
|
+
logger.info(`${config.providerName}:${account} ${existing !== null ? "updated" : "added"}`);
|
|
130
|
+
return credentials;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const loginLocks = new Map<ProviderName, Promise<Credentials>>();
|
|
134
|
+
|
|
135
|
+
function serialize(config: OAuthConfig): Promise<Credentials> {
|
|
136
|
+
const pending = loginLocks.get(config.providerName);
|
|
137
|
+
if (pending) return pending;
|
|
138
|
+
|
|
139
|
+
const promise = login(config).finally(() => loginLocks.delete(config.providerName));
|
|
140
|
+
loginLocks.set(config.providerName, promise);
|
|
141
|
+
return promise;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function refresh(config: OAuthConfig, refreshToken: string, account = 0): Promise<Credentials> {
|
|
145
|
+
const raw = await exchange(config, { grant_type: "refresh_token", refresh_token: refreshToken });
|
|
146
|
+
|
|
147
|
+
const credentials: Credentials = {
|
|
148
|
+
...store.get(config.providerName, account),
|
|
149
|
+
...parseTokenFields(raw, config),
|
|
150
|
+
refreshToken: (raw.refresh_token as string) ?? refreshToken,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
store.save(config.providerName, credentials, account);
|
|
154
|
+
return credentials;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function exchange(config: OAuthConfig, params: Record<string, string>): Promise<Record<string, unknown>> {
|
|
158
|
+
const all: Record<string, string> = { client_id: config.clientId, ...params };
|
|
159
|
+
if (config.clientSecret) all["client_secret"] = config.clientSecret;
|
|
160
|
+
|
|
161
|
+
const isJson = config.bodyFormat === "json";
|
|
162
|
+
const res = await fetch(config.tokenUrl, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: { "Content-Type": isJson ? "application/json" : "application/x-www-form-urlencoded" },
|
|
165
|
+
body: isJson ? JSON.stringify(all) : new URLSearchParams(all).toString(),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (!res.ok) {
|
|
169
|
+
const text = await res.text();
|
|
170
|
+
throw new Error(`${config.providerName} token exchange failed (${res.status}): ${text}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return (await res.json()) as Record<string, unknown>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseTokenFields(raw: Record<string, unknown>, config: OAuthConfig): Credentials {
|
|
177
|
+
const buffer = config.expiryBuffer !== false ? TOKEN_EXPIRY_BUFFER_MS : 0;
|
|
178
|
+
return {
|
|
179
|
+
accessToken: raw.access_token as string,
|
|
180
|
+
refreshToken: (raw.refresh_token as string) ?? "",
|
|
181
|
+
expiresAt: Date.now() + (raw.expires_in as number) * 1000 - buffer,
|
|
182
|
+
};
|
|
183
|
+
}
|
package/src/auth/pkce.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** PKCE (Proof Key for Code Exchange) — S256 challenge for all OAuth flows. */
|
|
2
|
+
|
|
3
|
+
import { toBase64url } from "../utils/encoding.ts";
|
|
4
|
+
|
|
5
|
+
export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
|
|
6
|
+
const verifierBytes = new Uint8Array(96);
|
|
7
|
+
crypto.getRandomValues(verifierBytes);
|
|
8
|
+
const verifier = toBase64url(verifierBytes);
|
|
9
|
+
|
|
10
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
11
|
+
const challenge = toBase64url(new Uint8Array(hashBuffer));
|
|
12
|
+
|
|
13
|
+
return { verifier, challenge };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function generateState(): string {
|
|
17
|
+
const bytes = new Uint8Array(16);
|
|
18
|
+
crypto.getRandomValues(bytes);
|
|
19
|
+
return Array.from(bytes)
|
|
20
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
21
|
+
.join("");
|
|
22
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** SQLite credential storage at ~/.ampcode-connector/credentials.db
|
|
2
|
+
* Multi-account: composite key (provider, account).
|
|
3
|
+
* Sync API via bun:sqlite — no cache needed at 0.4µs/read. */
|
|
4
|
+
|
|
5
|
+
import { Database } from "bun:sqlite";
|
|
6
|
+
import { mkdirSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
export interface Credentials {
|
|
11
|
+
accessToken: string;
|
|
12
|
+
refreshToken: string;
|
|
13
|
+
expiresAt: number;
|
|
14
|
+
projectId?: string;
|
|
15
|
+
email?: string;
|
|
16
|
+
accountId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ProviderName = "anthropic" | "codex" | "google";
|
|
20
|
+
|
|
21
|
+
const DIR = join(homedir(), ".ampcode-connector");
|
|
22
|
+
const DB_PATH = join(DIR, "credentials.db");
|
|
23
|
+
|
|
24
|
+
mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
25
|
+
|
|
26
|
+
const db = new Database(DB_PATH, { strict: true });
|
|
27
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
28
|
+
|
|
29
|
+
db.exec(`
|
|
30
|
+
CREATE TABLE IF NOT EXISTS credentials (
|
|
31
|
+
provider TEXT NOT NULL,
|
|
32
|
+
account INTEGER NOT NULL DEFAULT 0,
|
|
33
|
+
data TEXT NOT NULL,
|
|
34
|
+
PRIMARY KEY (provider, account)
|
|
35
|
+
)
|
|
36
|
+
`);
|
|
37
|
+
|
|
38
|
+
const stmtGet = db.prepare<{ data: string }, [string, number]>(
|
|
39
|
+
"SELECT data FROM credentials WHERE provider = ? AND account = ?",
|
|
40
|
+
);
|
|
41
|
+
const stmtGetAll = db.prepare<{ account: number; data: string }, [string]>(
|
|
42
|
+
"SELECT account, data FROM credentials WHERE provider = ? ORDER BY account",
|
|
43
|
+
);
|
|
44
|
+
const stmtSet = db.prepare<void, [string, number, string]>(
|
|
45
|
+
"INSERT OR REPLACE INTO credentials (provider, account, data) VALUES (?, ?, ?)",
|
|
46
|
+
);
|
|
47
|
+
const stmtDelOne = db.prepare<void, [string, number]>("DELETE FROM credentials WHERE provider = ? AND account = ?");
|
|
48
|
+
const stmtDelAll = db.prepare<void, [string]>("DELETE FROM credentials WHERE provider = ?");
|
|
49
|
+
const stmtMaxAccount = db.prepare<{ max_account: number | null }, [string]>(
|
|
50
|
+
"SELECT MAX(account) as max_account FROM credentials WHERE provider = ?",
|
|
51
|
+
);
|
|
52
|
+
const stmtCount = db.prepare<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM credentials WHERE provider = ?");
|
|
53
|
+
|
|
54
|
+
export function get(provider: ProviderName, account = 0): Credentials | undefined {
|
|
55
|
+
const row = stmtGet.get(provider, account);
|
|
56
|
+
if (!row) return undefined;
|
|
57
|
+
return JSON.parse(row.data) as Credentials;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getAll(provider: ProviderName): { account: number; credentials: Credentials }[] {
|
|
61
|
+
return stmtGetAll.all(provider).map((row) => ({
|
|
62
|
+
account: row.account,
|
|
63
|
+
credentials: JSON.parse(row.data) as Credentials,
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function save(provider: ProviderName, credentials: Credentials, account = 0): void {
|
|
68
|
+
stmtSet.run(provider, account, JSON.stringify(credentials));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function remove(provider: ProviderName, account?: number): void {
|
|
72
|
+
if (account !== undefined) {
|
|
73
|
+
stmtDelOne.run(provider, account);
|
|
74
|
+
} else {
|
|
75
|
+
stmtDelAll.run(provider);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function nextAccount(provider: ProviderName): number {
|
|
80
|
+
const row = stmtMaxAccount.get(provider);
|
|
81
|
+
return (row?.max_account ?? -1) + 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function count(provider: ProviderName): number {
|
|
85
|
+
return stmtCount.get(provider)?.cnt ?? 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Find existing account by email or accountId match. */
|
|
89
|
+
export function findByIdentity(provider: ProviderName, credentials: Credentials): number | null {
|
|
90
|
+
const all = getAll(provider);
|
|
91
|
+
for (const entry of all) {
|
|
92
|
+
if (credentials.email && entry.credentials.email === credentials.email) return entry.account;
|
|
93
|
+
if (credentials.accountId && entry.credentials.accountId === credentials.accountId) return entry.account;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function exists(provider: ProviderName): boolean {
|
|
99
|
+
const all = getAll(provider);
|
|
100
|
+
return all.some((e) => !!e.credentials.refreshToken);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function fresh(credentials: Credentials): boolean {
|
|
104
|
+
return Date.now() < credentials.expiresAt;
|
|
105
|
+
}
|
package/src/cli/ansi.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Raw ANSI escape helpers — no dependencies. */
|
|
2
|
+
|
|
3
|
+
const ESC = "\x1b[";
|
|
4
|
+
|
|
5
|
+
export const cursor = {
|
|
6
|
+
hide: () => out(`${ESC}?25l`),
|
|
7
|
+
show: () => out(`${ESC}?25h`),
|
|
8
|
+
home: () => out(`${ESC}H`),
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const screen = {
|
|
12
|
+
clear: () => out(`${ESC}2J${ESC}H`),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Erase from cursor to end of line. */
|
|
16
|
+
export const eol = `${ESC}K`;
|
|
17
|
+
|
|
18
|
+
export const s = {
|
|
19
|
+
reset: `${ESC}0m`,
|
|
20
|
+
bold: `${ESC}1m`,
|
|
21
|
+
dim: `${ESC}2m`,
|
|
22
|
+
inverse: `${ESC}7m`,
|
|
23
|
+
green: `${ESC}32m`,
|
|
24
|
+
red: `${ESC}31m`,
|
|
25
|
+
yellow: `${ESC}33m`,
|
|
26
|
+
gray: `${ESC}90m`,
|
|
27
|
+
cyan: `${ESC}36m`,
|
|
28
|
+
white: `${ESC}37m`,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function out(text: string): void {
|
|
32
|
+
process.stdout.write(text);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function line(text = ""): void {
|
|
36
|
+
process.stdout.write(`${text}${eol}\n`);
|
|
37
|
+
}
|