@oh-my-pi/pi-ai 6.7.67 → 6.8.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/README.md +31 -31
- package/package.json +2 -1
- package/src/cli.ts +114 -52
- package/src/providers/anthropic.ts +215 -166
- package/src/providers/google-gemini-cli.ts +4 -20
- package/src/providers/openai-codex/response-handler.ts +4 -43
- package/src/providers/openai-codex-responses.ts +2 -2
- package/src/storage.ts +185 -0
- package/src/utils/event-stream.ts +3 -3
- package/src/utils/oauth/anthropic.ts +70 -88
- package/src/utils/oauth/callback-server.ts +245 -0
- package/src/utils/oauth/cursor.ts +1 -5
- package/src/utils/oauth/github-copilot.ts +1 -23
- package/src/utils/oauth/google-antigravity.ts +73 -263
- package/src/utils/oauth/google-gemini-cli.ts +73 -281
- package/src/utils/oauth/oauth.html +199 -0
- package/src/utils/oauth/openai-codex.ts +131 -318
- package/src/utils/oauth/pkce.ts +1 -1
- package/src/utils/oauth/types.ts +8 -0
package/src/storage.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple auth storage for CLI using SQLite.
|
|
3
|
+
* Compatible with coding-agent's agent.db format.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Database } from "bun:sqlite";
|
|
7
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import type { OAuthCredentials } from "./utils/oauth/types";
|
|
11
|
+
|
|
12
|
+
type AuthCredential = { type: "api_key"; key: string } | ({ type: "oauth" } & OAuthCredentials);
|
|
13
|
+
|
|
14
|
+
type AuthRow = {
|
|
15
|
+
id: number;
|
|
16
|
+
provider: string;
|
|
17
|
+
credential_type: string;
|
|
18
|
+
data: string;
|
|
19
|
+
created_at: number;
|
|
20
|
+
updated_at: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get the agent config directory (e.g., ~/.omp/agent/)
|
|
25
|
+
*/
|
|
26
|
+
function getAgentDir(): string {
|
|
27
|
+
const configDir = process.env.OMP_CODING_AGENT_DIR || join(homedir(), ".omp", "agent");
|
|
28
|
+
return configDir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get path to agent.db
|
|
33
|
+
*/
|
|
34
|
+
function getAgentDbPath(): string {
|
|
35
|
+
return join(getAgentDir(), "agent.db");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function serializeCredential(credential: AuthCredential): { credentialType: string; data: string } | null {
|
|
39
|
+
if (credential.type === "api_key") {
|
|
40
|
+
return {
|
|
41
|
+
credentialType: "api_key",
|
|
42
|
+
data: JSON.stringify({ key: credential.key }),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (credential.type === "oauth") {
|
|
46
|
+
const { type: _type, ...rest } = credential;
|
|
47
|
+
return {
|
|
48
|
+
credentialType: "oauth",
|
|
49
|
+
data: JSON.stringify(rest),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function deserializeCredential(row: AuthRow): AuthCredential | null {
|
|
56
|
+
let parsed: unknown;
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(row.data);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (!parsed || typeof parsed !== "object") {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (row.credential_type === "api_key") {
|
|
67
|
+
const data = parsed as Record<string, unknown>;
|
|
68
|
+
if (typeof data.key === "string") {
|
|
69
|
+
return { type: "api_key", key: data.key };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (row.credential_type === "oauth") {
|
|
74
|
+
return { type: "oauth", ...(parsed as Record<string, unknown>) } as AuthCredential;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Simple storage class for CLI auth credentials.
|
|
82
|
+
*/
|
|
83
|
+
export class CliAuthStorage {
|
|
84
|
+
private db: Database;
|
|
85
|
+
private insertStmt: ReturnType<Database["prepare"]>;
|
|
86
|
+
private listByProviderStmt: ReturnType<Database["prepare"]>;
|
|
87
|
+
private listAllStmt: ReturnType<Database["prepare"]>;
|
|
88
|
+
private deleteByProviderStmt: ReturnType<Database["prepare"]>;
|
|
89
|
+
|
|
90
|
+
constructor(dbPath: string = getAgentDbPath()) {
|
|
91
|
+
// Ensure directory exists
|
|
92
|
+
const dir = dirname(dbPath);
|
|
93
|
+
if (!existsSync(dir)) {
|
|
94
|
+
mkdirSync(dir, { recursive: true });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.db = new Database(dbPath);
|
|
98
|
+
this.initializeSchema();
|
|
99
|
+
|
|
100
|
+
this.insertStmt = this.db.prepare(
|
|
101
|
+
"INSERT INTO auth_credentials (provider, credential_type, data) VALUES (?, ?, ?) RETURNING id",
|
|
102
|
+
);
|
|
103
|
+
this.listByProviderStmt = this.db.prepare("SELECT * FROM auth_credentials WHERE provider = ?");
|
|
104
|
+
this.listAllStmt = this.db.prepare("SELECT * FROM auth_credentials");
|
|
105
|
+
this.deleteByProviderStmt = this.db.prepare("DELETE FROM auth_credentials WHERE provider = ?");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private initializeSchema(): void {
|
|
109
|
+
this.db.exec(`
|
|
110
|
+
PRAGMA journal_mode=WAL;
|
|
111
|
+
PRAGMA synchronous=NORMAL;
|
|
112
|
+
PRAGMA busy_timeout=5000;
|
|
113
|
+
|
|
114
|
+
CREATE TABLE IF NOT EXISTS auth_credentials (
|
|
115
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
116
|
+
provider TEXT NOT NULL,
|
|
117
|
+
credential_type TEXT NOT NULL,
|
|
118
|
+
data TEXT NOT NULL,
|
|
119
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
120
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
121
|
+
);
|
|
122
|
+
CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider);
|
|
123
|
+
`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Save OAuth credentials for a provider (replaces existing).
|
|
128
|
+
*/
|
|
129
|
+
saveOAuth(provider: string, credentials: OAuthCredentials): void {
|
|
130
|
+
const credential: AuthCredential = { type: "oauth", ...credentials };
|
|
131
|
+
this.replaceForProvider(provider, credential);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get OAuth credentials for a provider.
|
|
136
|
+
*/
|
|
137
|
+
getOAuth(provider: string): OAuthCredentials | null {
|
|
138
|
+
const rows = this.listByProviderStmt.all(provider) as AuthRow[];
|
|
139
|
+
for (const row of rows) {
|
|
140
|
+
const credential = deserializeCredential(row);
|
|
141
|
+
if (credential && credential.type === "oauth") {
|
|
142
|
+
const { type: _type, ...oauth } = credential;
|
|
143
|
+
return oauth as OAuthCredentials;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* List all providers with credentials.
|
|
151
|
+
*/
|
|
152
|
+
listProviders(): string[] {
|
|
153
|
+
const rows = this.listAllStmt.all() as AuthRow[];
|
|
154
|
+
const providers = new Set<string>();
|
|
155
|
+
for (const row of rows) {
|
|
156
|
+
providers.add(row.provider);
|
|
157
|
+
}
|
|
158
|
+
return Array.from(providers);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Delete all credentials for a provider.
|
|
163
|
+
*/
|
|
164
|
+
deleteProvider(provider: string): void {
|
|
165
|
+
this.deleteByProviderStmt.run(provider);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Replace all credentials for a provider with a single credential.
|
|
170
|
+
*/
|
|
171
|
+
private replaceForProvider(provider: string, credential: AuthCredential): void {
|
|
172
|
+
const serialized = serializeCredential(credential);
|
|
173
|
+
if (!serialized) return;
|
|
174
|
+
|
|
175
|
+
const replace = this.db.transaction(() => {
|
|
176
|
+
this.deleteByProviderStmt.run(provider);
|
|
177
|
+
this.insertStmt.run(provider, serialized.credentialType, serialized.data);
|
|
178
|
+
});
|
|
179
|
+
replace();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
close(): void {
|
|
183
|
+
this.db.close();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -12,9 +12,9 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
|
12
12
|
private isComplete: (event: T) => boolean,
|
|
13
13
|
private extractResult: (event: T) => R,
|
|
14
14
|
) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
const { promise, resolve } = Promise.withResolvers<R>();
|
|
16
|
+
this.finalResultPromise = promise;
|
|
17
|
+
this.resolveFinalResult = resolve;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
push(event: T): void {
|
|
@@ -2,109 +2,91 @@
|
|
|
2
2
|
* Anthropic OAuth flow (Claude Pro/Max)
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { OAuthCallbackFlow } from "./callback-server";
|
|
5
6
|
import { generatePKCE } from "./pkce";
|
|
6
|
-
import type { OAuthCredentials } from "./types";
|
|
7
|
+
import type { OAuthController, OAuthCredentials } from "./types";
|
|
7
8
|
|
|
8
9
|
const decode = (s: string) => atob(s);
|
|
9
10
|
const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
|
|
10
11
|
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
|
11
12
|
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
|
|
12
|
-
const
|
|
13
|
+
const CALLBACK_PORT = 54545;
|
|
14
|
+
const CALLBACK_PATH = "/callback";
|
|
13
15
|
const SCOPES = "org:create_api_key user:profile user:inference";
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
try {
|
|
20
|
-
const url = new URL(trimmed);
|
|
21
|
-
const code = url.searchParams.get("code") ?? "";
|
|
22
|
-
const state = url.searchParams.get("state") ?? undefined;
|
|
23
|
-
if (code) return { code, state };
|
|
24
|
-
} catch {
|
|
25
|
-
// Ignore invalid URL parsing and fall back to manual parsing.
|
|
26
|
-
}
|
|
17
|
+
class AnthropicOAuthFlow extends OAuthCallbackFlow {
|
|
18
|
+
private verifier: string = "";
|
|
19
|
+
private challenge: string = "";
|
|
27
20
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const code = params.get("code") ?? "";
|
|
31
|
-
const state = params.get("state") ?? undefined;
|
|
32
|
-
if (code) return { code, state };
|
|
21
|
+
constructor(ctrl: OAuthController) {
|
|
22
|
+
super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
|
|
33
23
|
}
|
|
34
24
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
* @param onAuthUrl - Callback to handle the authorization URL (e.g., open browser)
|
|
43
|
-
* @param onPromptCode - Callback to prompt user for the authorization code
|
|
44
|
-
*/
|
|
45
|
-
export async function loginAnthropic(
|
|
46
|
-
onAuthUrl: (url: string) => void,
|
|
47
|
-
onPromptCode: () => Promise<string>,
|
|
48
|
-
): Promise<OAuthCredentials> {
|
|
49
|
-
const { verifier, challenge } = await generatePKCE();
|
|
50
|
-
|
|
51
|
-
// Build authorization URL
|
|
52
|
-
const authParams = new URLSearchParams({
|
|
53
|
-
code: "true",
|
|
54
|
-
client_id: CLIENT_ID,
|
|
55
|
-
response_type: "code",
|
|
56
|
-
redirect_uri: REDIRECT_URI,
|
|
57
|
-
scope: SCOPES,
|
|
58
|
-
code_challenge: challenge,
|
|
59
|
-
code_challenge_method: "S256",
|
|
60
|
-
state: verifier,
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
const authUrl = `${AUTHORIZE_URL}?${authParams.toString()}`;
|
|
25
|
+
protected async generateAuthUrl(
|
|
26
|
+
state: string,
|
|
27
|
+
redirectUri: string,
|
|
28
|
+
): Promise<{ url: string; instructions?: string }> {
|
|
29
|
+
const pkce = await generatePKCE();
|
|
30
|
+
this.verifier = pkce.verifier;
|
|
31
|
+
this.challenge = pkce.challenge;
|
|
64
32
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// Wait for user to paste authorization code (format: code#state)
|
|
69
|
-
const authCode = await onPromptCode();
|
|
70
|
-
const { code, state } = parseAuthCode(authCode);
|
|
71
|
-
|
|
72
|
-
// Exchange code for tokens
|
|
73
|
-
const tokenResponse = await fetch(TOKEN_URL, {
|
|
74
|
-
method: "POST",
|
|
75
|
-
headers: {
|
|
76
|
-
"Content-Type": "application/json",
|
|
77
|
-
},
|
|
78
|
-
body: JSON.stringify({
|
|
79
|
-
grant_type: "authorization_code",
|
|
33
|
+
const authParams = new URLSearchParams({
|
|
34
|
+
code: "true",
|
|
80
35
|
client_id: CLIENT_ID,
|
|
81
|
-
code,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
36
|
+
response_type: "code",
|
|
37
|
+
redirect_uri: redirectUri,
|
|
38
|
+
scope: SCOPES,
|
|
39
|
+
code_challenge: this.challenge,
|
|
40
|
+
code_challenge_method: "S256",
|
|
41
|
+
state,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const url = `${AUTHORIZE_URL}?${authParams.toString()}`;
|
|
45
|
+
return { url };
|
|
91
46
|
}
|
|
92
47
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
48
|
+
protected async exchangeToken(code: string, state: string, redirectUri: string): Promise<OAuthCredentials> {
|
|
49
|
+
const tokenResponse = await fetch(TOKEN_URL, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: {
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
Accept: "application/json",
|
|
54
|
+
},
|
|
55
|
+
body: JSON.stringify({
|
|
56
|
+
grant_type: "authorization_code",
|
|
57
|
+
client_id: CLIENT_ID,
|
|
58
|
+
code,
|
|
59
|
+
state,
|
|
60
|
+
redirect_uri: redirectUri,
|
|
61
|
+
code_verifier: this.verifier,
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (!tokenResponse.ok) {
|
|
66
|
+
const error = await tokenResponse.text();
|
|
67
|
+
throw new Error(`Token exchange failed: ${error}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const tokenData = (await tokenResponse.json()) as {
|
|
71
|
+
access_token: string;
|
|
72
|
+
refresh_token: string;
|
|
73
|
+
expires_in: number;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
refresh: tokenData.refresh_token,
|
|
78
|
+
access: tokenData.access_token,
|
|
79
|
+
expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
101
83
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Login with Anthropic OAuth
|
|
86
|
+
*/
|
|
87
|
+
export async function loginAnthropic(ctrl: OAuthController): Promise<OAuthCredentials> {
|
|
88
|
+
const flow = new AnthropicOAuthFlow(ctrl);
|
|
89
|
+
return flow.login();
|
|
108
90
|
}
|
|
109
91
|
|
|
110
92
|
/**
|
|
@@ -113,7 +95,7 @@ export async function loginAnthropic(
|
|
|
113
95
|
export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {
|
|
114
96
|
const response = await fetch(TOKEN_URL, {
|
|
115
97
|
method: "POST",
|
|
116
|
-
headers: { "Content-Type": "application/json" },
|
|
98
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
117
99
|
body: JSON.stringify({
|
|
118
100
|
grant_type: "refresh_token",
|
|
119
101
|
client_id: CLIENT_ID,
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract base class for OAuth flows with local callback servers.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Port allocation (tries expected port, falls back to random)
|
|
6
|
+
* - Callback server setup and request handling
|
|
7
|
+
* - Common OAuth flow logic
|
|
8
|
+
*
|
|
9
|
+
* Providers extend this and implement:
|
|
10
|
+
* - generateAuthUrl(): Build provider-specific authorization URL
|
|
11
|
+
* - exchangeToken(): Exchange authorization code for tokens
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import templateHtml from "./oauth.html" with { type: "text" };
|
|
15
|
+
import type { OAuthController, OAuthCredentials } from "./types";
|
|
16
|
+
|
|
17
|
+
const DEFAULT_TIMEOUT = 120;
|
|
18
|
+
const DEFAULT_HOSTNAME = "localhost";
|
|
19
|
+
const CALLBACK_PATH = "/callback";
|
|
20
|
+
|
|
21
|
+
export type CallbackResult = { code: string; state: string };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Abstract base class for OAuth flows with local callback servers.
|
|
25
|
+
*/
|
|
26
|
+
export abstract class OAuthCallbackFlow {
|
|
27
|
+
protected ctrl: OAuthController;
|
|
28
|
+
protected preferredPort: number;
|
|
29
|
+
protected callbackPath: string;
|
|
30
|
+
private callbackResolve?: (result: CallbackResult) => void;
|
|
31
|
+
private callbackReject?: (error: string) => void;
|
|
32
|
+
|
|
33
|
+
constructor(ctrl: OAuthController, preferredPort: number, callbackPath: string = CALLBACK_PATH) {
|
|
34
|
+
this.ctrl = ctrl;
|
|
35
|
+
this.preferredPort = preferredPort;
|
|
36
|
+
this.callbackPath = callbackPath;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Generate provider-specific authorization URL.
|
|
41
|
+
* @param state - CSRF state token
|
|
42
|
+
* @param redirectUri - The actual redirect URI to use (may differ from expected if port fallback occurred)
|
|
43
|
+
* @returns Authorization URL and optional instructions
|
|
44
|
+
*/
|
|
45
|
+
protected abstract generateAuthUrl(
|
|
46
|
+
state: string,
|
|
47
|
+
redirectUri: string,
|
|
48
|
+
): Promise<{ url: string; instructions?: string }>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Exchange authorization code for OAuth tokens.
|
|
52
|
+
* @param code - Authorization code from callback
|
|
53
|
+
* @param state - CSRF state token
|
|
54
|
+
* @param redirectUri - The actual redirect URI used (must match authorization request)
|
|
55
|
+
* @returns OAuth credentials
|
|
56
|
+
*/
|
|
57
|
+
protected abstract exchangeToken(code: string, state: string, redirectUri: string): Promise<OAuthCredentials>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Generate CSRF state token. Override if provider needs custom state generation.
|
|
61
|
+
*/
|
|
62
|
+
protected generateState(): string {
|
|
63
|
+
const bytes = new Uint8Array(16);
|
|
64
|
+
crypto.getRandomValues(bytes);
|
|
65
|
+
return Array.from(bytes)
|
|
66
|
+
.map((value) => value.toString(16).padStart(2, "0"))
|
|
67
|
+
.join("");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Execute the OAuth login flow.
|
|
72
|
+
*/
|
|
73
|
+
async login(): Promise<OAuthCredentials> {
|
|
74
|
+
const state = this.generateState();
|
|
75
|
+
|
|
76
|
+
// Start callback server first to get actual redirect URI
|
|
77
|
+
const { server, redirectUri } = await this.startCallbackServer(state);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
// Generate auth URL with the ACTUAL redirect URI (may differ from expected if port was busy)
|
|
81
|
+
const { url: authUrl, instructions } = await this.generateAuthUrl(state, redirectUri);
|
|
82
|
+
|
|
83
|
+
// Notify controller that auth is ready
|
|
84
|
+
this.ctrl.onAuth?.({ url: authUrl, instructions });
|
|
85
|
+
this.ctrl.onProgress?.("Waiting for browser authentication...");
|
|
86
|
+
|
|
87
|
+
// Wait for callback or manual input
|
|
88
|
+
const { code } = await this.waitForCallback(state);
|
|
89
|
+
|
|
90
|
+
this.ctrl.onProgress?.("Exchanging authorization code for tokens...");
|
|
91
|
+
|
|
92
|
+
return await this.exchangeToken(code, state, redirectUri);
|
|
93
|
+
} finally {
|
|
94
|
+
server.stop();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Start callback server, trying preferred port first, falling back to random.
|
|
100
|
+
*/
|
|
101
|
+
private async startCallbackServer(
|
|
102
|
+
expectedState: string,
|
|
103
|
+
): Promise<{ server: Bun.Server<unknown>; redirectUri: string }> {
|
|
104
|
+
// Try preferred port first
|
|
105
|
+
try {
|
|
106
|
+
const redirectUri = `http://${DEFAULT_HOSTNAME}:${this.preferredPort}${this.callbackPath}`;
|
|
107
|
+
const server = this.createServer(this.preferredPort, expectedState);
|
|
108
|
+
return { server, redirectUri };
|
|
109
|
+
} catch {
|
|
110
|
+
// Port busy or unavailable, try random port
|
|
111
|
+
const randomPort = 0; // Let OS assign
|
|
112
|
+
const server = this.createServer(randomPort, expectedState);
|
|
113
|
+
const actualPort = server.port;
|
|
114
|
+
const redirectUri = `http://${DEFAULT_HOSTNAME}:${actualPort}${this.callbackPath}`;
|
|
115
|
+
this.ctrl.onProgress?.(`Preferred port ${this.preferredPort} unavailable, using port ${actualPort}`);
|
|
116
|
+
return { server, redirectUri };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create HTTP server for OAuth callback.
|
|
122
|
+
*/
|
|
123
|
+
private createServer(port: number, expectedState: string): Bun.Server<unknown> {
|
|
124
|
+
return Bun.serve({
|
|
125
|
+
hostname: DEFAULT_HOSTNAME,
|
|
126
|
+
port,
|
|
127
|
+
reusePort: false,
|
|
128
|
+
fetch: (req) => this.handleCallback(req, expectedState),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Handle OAuth callback HTTP request.
|
|
134
|
+
*/
|
|
135
|
+
private handleCallback(req: Request, expectedState: string): Response {
|
|
136
|
+
const url = new URL(req.url);
|
|
137
|
+
|
|
138
|
+
if (url.pathname !== this.callbackPath) {
|
|
139
|
+
return new Response("Not Found", { status: 404 });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const code = url.searchParams.get("code");
|
|
143
|
+
const state = url.searchParams.get("state") || "";
|
|
144
|
+
const error = url.searchParams.get("error") || "";
|
|
145
|
+
const errorDescription = url.searchParams.get("error_description") || error;
|
|
146
|
+
|
|
147
|
+
type OkState = { ok: true; code: string; state: string };
|
|
148
|
+
type ErrorState = { ok?: false; error?: string };
|
|
149
|
+
let resultState: OkState | ErrorState;
|
|
150
|
+
|
|
151
|
+
if (error) {
|
|
152
|
+
resultState = { ok: false, error: `Authorization failed: ${errorDescription}` };
|
|
153
|
+
} else if (!code) {
|
|
154
|
+
resultState = { ok: false, error: "Missing authorization code" };
|
|
155
|
+
} else if (expectedState && state !== expectedState) {
|
|
156
|
+
resultState = { ok: false, error: "State mismatch - possible CSRF attack" };
|
|
157
|
+
} else {
|
|
158
|
+
resultState = { ok: true, code, state };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Signal to waitForCallback
|
|
162
|
+
queueMicrotask(() => {
|
|
163
|
+
if (resultState.ok) {
|
|
164
|
+
this.callbackResolve?.({ code: resultState.code, state: resultState.state });
|
|
165
|
+
} else {
|
|
166
|
+
this.callbackReject?.(resultState.error ?? "Unknown error");
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return new Response(
|
|
171
|
+
(templateHtml as unknown as string).replaceAll("__OAUTH_STATE__", JSON.stringify(resultState)),
|
|
172
|
+
{
|
|
173
|
+
status: resultState.ok ? 200 : 500,
|
|
174
|
+
headers: { "Content-Type": "text/html" },
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Wait for OAuth callback or manual input (whichever comes first).
|
|
181
|
+
*/
|
|
182
|
+
private waitForCallback(expectedState: string): Promise<CallbackResult> {
|
|
183
|
+
const timeoutSignal = AbortSignal.timeout(DEFAULT_TIMEOUT * 1000);
|
|
184
|
+
const signal = this.ctrl.signal ? AbortSignal.any([this.ctrl.signal, timeoutSignal]) : timeoutSignal;
|
|
185
|
+
|
|
186
|
+
const callbackPromise = new Promise<CallbackResult>((resolve, reject) => {
|
|
187
|
+
this.callbackResolve = resolve;
|
|
188
|
+
this.callbackReject = reject;
|
|
189
|
+
|
|
190
|
+
signal.addEventListener("abort", () => {
|
|
191
|
+
this.callbackResolve = undefined;
|
|
192
|
+
this.callbackReject = undefined;
|
|
193
|
+
reject(new Error(`OAuth callback cancelled: ${signal.reason}`));
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Manual input race (if supported)
|
|
198
|
+
if (this.ctrl.onManualCodeInput) {
|
|
199
|
+
const manualPromise = this.ctrl.onManualCodeInput().then((input): CallbackResult => {
|
|
200
|
+
const parsed = parseCallbackInput(input);
|
|
201
|
+
if (!parsed.code) {
|
|
202
|
+
throw new Error("No authorization code found in input");
|
|
203
|
+
}
|
|
204
|
+
if (expectedState && parsed.state && parsed.state !== expectedState) {
|
|
205
|
+
throw new Error("State mismatch - possible CSRF attack");
|
|
206
|
+
}
|
|
207
|
+
return { code: parsed.code, state: parsed.state ?? "" };
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return Promise.race([callbackPromise, manualPromise]);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return callbackPromise;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Parse a redirect URL or code string to extract code and state.
|
|
219
|
+
*/
|
|
220
|
+
export function parseCallbackInput(input: string): { code?: string; state?: string } {
|
|
221
|
+
const value = input.trim();
|
|
222
|
+
if (!value) return {};
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const url = new URL(value);
|
|
226
|
+
return {
|
|
227
|
+
code: url.searchParams.get("code") ?? undefined,
|
|
228
|
+
state: url.searchParams.get("state") ?? undefined,
|
|
229
|
+
};
|
|
230
|
+
} catch {
|
|
231
|
+
// Not a URL - check for query string format
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (value.includes("code=")) {
|
|
235
|
+
const params = new URLSearchParams(value.replace(/^[?#]/, ""));
|
|
236
|
+
return {
|
|
237
|
+
code: params.get("code") ?? undefined,
|
|
238
|
+
state: params.get("state") ?? undefined,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Assume raw code, possibly with state after #
|
|
243
|
+
const [code, state] = value.split("#", 2);
|
|
244
|
+
return { code, state };
|
|
245
|
+
}
|
|
@@ -10,10 +10,6 @@ const POLL_BASE_DELAY = 1000;
|
|
|
10
10
|
const POLL_MAX_DELAY = 10000;
|
|
11
11
|
const POLL_BACKOFF_MULTIPLIER = 1.2;
|
|
12
12
|
|
|
13
|
-
function sleep(ms: number): Promise<void> {
|
|
14
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
-
}
|
|
16
|
-
|
|
17
13
|
export interface CursorAuthParams {
|
|
18
14
|
verifier: string;
|
|
19
15
|
challenge: string;
|
|
@@ -45,7 +41,7 @@ export async function pollCursorAuth(
|
|
|
45
41
|
let consecutiveErrors = 0;
|
|
46
42
|
|
|
47
43
|
for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) {
|
|
48
|
-
await sleep(delay);
|
|
44
|
+
await Bun.sleep(delay);
|
|
49
45
|
|
|
50
46
|
try {
|
|
51
47
|
const response = await fetch(`${CURSOR_POLL_URL}?uuid=${uuid}&verifier=${verifier}`);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* GitHub Copilot OAuth flow
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { abortableSleep } from "@oh-my-pi/pi-utils";
|
|
5
6
|
import { getModels } from "../../models";
|
|
6
7
|
import type { OAuthCredentials } from "./types";
|
|
7
8
|
|
|
@@ -136,29 +137,6 @@ async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
|
|
|
136
137
|
};
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
/**
|
|
140
|
-
* Sleep that can be interrupted by an AbortSignal
|
|
141
|
-
*/
|
|
142
|
-
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
143
|
-
return new Promise((resolve, reject) => {
|
|
144
|
-
if (signal?.aborted) {
|
|
145
|
-
reject(new Error("Login cancelled"));
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const timeout = setTimeout(resolve, ms);
|
|
150
|
-
|
|
151
|
-
signal?.addEventListener(
|
|
152
|
-
"abort",
|
|
153
|
-
() => {
|
|
154
|
-
clearTimeout(timeout);
|
|
155
|
-
reject(new Error("Login cancelled"));
|
|
156
|
-
},
|
|
157
|
-
{ once: true },
|
|
158
|
-
);
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
|
|
162
140
|
async function pollForGitHubAccessToken(
|
|
163
141
|
domain: string,
|
|
164
142
|
deviceCode: string,
|