Replace token-paste with native device-code/PKCE provider sign-in
Implement the official OAuth login flows natively (ported from pi-ai's reference): GitHub Copilot device-code, GPT account (ChatGPT Codex) and Google Antigravity authorization-code + PKCE via a loopback callback server. Obtained credentials are stored as JSON through the existing secret vault. Onboarding and Settings now run the real sign-in instead of pasting an access token.
This commit is contained in:
1 parent
d5bf6c6b5f
commit
49a9f16221
24 files changed
+1057
-64
No files matched your search
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* GitHub Copilot OAuth — device-code flow.
|
||||
*
|
||||
* Ported from the reference implementation in @mariozechner/pi-ai. The device
|
||||
* flow obtains a GitHub access token, then exchanges it for a Copilot token.
|
||||
*/
|
||||
import { listProviderModels } from "../../../shared/provider-models";
|
||||
import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types";
|
||||
|
||||
// Decoded from the published GitHub Copilot client id used by the official CLI.
|
||||
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
||||
const COPILOT_HEADERS = {
|
||||
"User-Agent": "GitHubCopilotChat/0.35.0",
|
||||
"Editor-Version": "vscode/1.107.0",
|
||||
"Editor-Plugin-Version": "copilot-chat/0.35.0",
|
||||
"Copilot-Integration-Id": "vscode-chat",
|
||||
} as const;
|
||||
const INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2;
|
||||
const SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4;
|
||||
|
||||
interface CopilotUrls {
|
||||
deviceCodeUrl: string;
|
||||
accessTokenUrl: string;
|
||||
copilotTokenUrl: string;
|
||||
}
|
||||
|
||||
function normalizeDomain(input: string | null | undefined): string | null {
|
||||
const trimmed = (input ?? "").trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`);
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getUrls(domain: string): CopilotUrls {
|
||||
return {
|
||||
deviceCodeUrl: `https://${domain}/login/device/code`,
|
||||
accessTokenUrl: `https://${domain}/login/oauth/access_token`,
|
||||
copilotTokenUrl: `https://api.${domain}/copilot_internal/v2/token`,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, init: RequestInit): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`${response.status} ${response.statusText}: ${text}`);
|
||||
}
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DeviceCode {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
interval: number;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
async function startDeviceFlow(domain: string): Promise<DeviceCode> {
|
||||
const data = await fetchJson(getUrls(domain).deviceCodeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": COPILOT_HEADERS["User-Agent"],
|
||||
},
|
||||
body: new URLSearchParams({ client_id: CLIENT_ID, scope: "read:user" }),
|
||||
});
|
||||
const deviceCode = data.device_code;
|
||||
const userCode = data.user_code;
|
||||
const verificationUri = data.verification_uri;
|
||||
const interval = data.interval;
|
||||
const expiresIn = data.expires_in;
|
||||
if (
|
||||
typeof deviceCode !== "string" ||
|
||||
typeof userCode !== "string" ||
|
||||
typeof verificationUri !== "string" ||
|
||||
typeof interval !== "number" ||
|
||||
typeof expiresIn !== "number"
|
||||
) {
|
||||
throw new Error("Invalid device code response from GitHub.");
|
||||
}
|
||||
return { device_code: deviceCode, user_code: userCode, verification_uri: verificationUri, interval, expires_in: expiresIn };
|
||||
}
|
||||
|
||||
export type PollDecision =
|
||||
| { kind: "token"; accessToken: string }
|
||||
| { kind: "pending" }
|
||||
| { kind: "slow_down"; intervalSeconds: number | null }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
/** Pure interpreter of a device-token poll response (unit-tested). */
|
||||
export function interpretPollResponse(raw: Record<string, unknown>): PollDecision {
|
||||
if (typeof raw.access_token === "string") return { kind: "token", accessToken: raw.access_token };
|
||||
if (typeof raw.error === "string") {
|
||||
if (raw.error === "authorization_pending") return { kind: "pending" };
|
||||
if (raw.error === "slow_down") {
|
||||
return { kind: "slow_down", intervalSeconds: typeof raw.interval === "number" ? raw.interval : null };
|
||||
}
|
||||
const description = typeof raw.error_description === "string" ? `: ${raw.error_description}` : "";
|
||||
return { kind: "error", message: `Device flow failed: ${raw.error}${description}` };
|
||||
}
|
||||
return { kind: "pending" };
|
||||
}
|
||||
|
||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Login cancelled"));
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Login cancelled"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function pollForGitHubAccessToken(domain: string, device: DeviceCode, signal?: AbortSignal): Promise<string> {
|
||||
const { accessTokenUrl } = getUrls(domain);
|
||||
const deadline = Date.now() + device.expires_in * 1000;
|
||||
let intervalMs = Math.max(1000, Math.floor(device.interval * 1000));
|
||||
let intervalMultiplier = INITIAL_POLL_INTERVAL_MULTIPLIER;
|
||||
while (Date.now() < deadline) {
|
||||
if (signal?.aborted) throw new Error("Login cancelled");
|
||||
const remainingMs = deadline - Date.now();
|
||||
const waitMs = Math.min(Math.ceil(intervalMs * intervalMultiplier), remainingMs);
|
||||
await abortableSleep(waitMs, signal);
|
||||
const raw = await fetchJson(accessTokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": COPILOT_HEADERS["User-Agent"],
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
device_code: device.device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}),
|
||||
});
|
||||
const decision = interpretPollResponse(raw);
|
||||
if (decision.kind === "token") return decision.accessToken;
|
||||
if (decision.kind === "error") throw new Error(decision.message);
|
||||
if (decision.kind === "slow_down") {
|
||||
intervalMs = decision.intervalSeconds && decision.intervalSeconds > 0 ? decision.intervalSeconds * 1000 : Math.max(1000, intervalMs + 5000);
|
||||
intervalMultiplier = SLOW_DOWN_POLL_INTERVAL_MULTIPLIER;
|
||||
}
|
||||
}
|
||||
throw new Error("Device flow timed out. Please try signing in again.");
|
||||
}
|
||||
|
||||
export async function refreshGitHubCopilotToken(githubToken: string, enterpriseDomain?: string | null): Promise<OAuthCredentials> {
|
||||
const domain = enterpriseDomain || "github.com";
|
||||
const raw = await fetchJson(getUrls(domain).copilotTokenUrl, {
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${githubToken}`, ...COPILOT_HEADERS },
|
||||
});
|
||||
const token = raw.token;
|
||||
const expiresAt = raw.expires_at;
|
||||
if (typeof token !== "string" || typeof expiresAt !== "number") {
|
||||
throw new Error("Invalid Copilot token response.");
|
||||
}
|
||||
return {
|
||||
access: token,
|
||||
refresh: githubToken,
|
||||
expires: expiresAt * 1000 - 5 * 60 * 1000,
|
||||
enterpriseUrl: enterpriseDomain ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function copilotBaseUrl(token: string, enterpriseDomain?: string | null): string {
|
||||
const match = token.match(/proxy-ep=([^;]+)/);
|
||||
if (match) return `https://${match[1].replace(/^proxy\./, "api.")}`;
|
||||
if (enterpriseDomain) return `https://copilot-api.${enterpriseDomain}`;
|
||||
return "https://api.individual.githubcopilot.com";
|
||||
}
|
||||
|
||||
/** Best-effort model policy enablement, matching the official client behaviour. */
|
||||
async function enableCopilotModels(token: string, enterpriseDomain?: string | null): Promise<void> {
|
||||
const baseUrl = copilotBaseUrl(token, enterpriseDomain);
|
||||
await Promise.all(
|
||||
listProviderModels("github-copilot").map(async (model) => {
|
||||
try {
|
||||
await fetch(`${baseUrl}/models/${model.id}/policy`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...COPILOT_HEADERS,
|
||||
"openai-intent": "chat-policy",
|
||||
"x-interaction-type": "chat-policy",
|
||||
},
|
||||
body: JSON.stringify({ state: "enabled" }),
|
||||
});
|
||||
} catch {
|
||||
// Per-model enablement is advisory; the model may already be enabled.
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export const githubCopilotFlow: OAuthFlow = {
|
||||
providerKey: "github-copilot",
|
||||
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||
const enterpriseDomain = normalizeDomain(callbacks.enterpriseDomain);
|
||||
const domain = enterpriseDomain ?? "github.com";
|
||||
const device = await startDeviceFlow(domain);
|
||||
callbacks.onAuth({
|
||||
url: device.verification_uri,
|
||||
instructions: `Enter code ${device.user_code} at ${device.verification_uri}`,
|
||||
});
|
||||
const githubToken = await pollForGitHubAccessToken(domain, device, callbacks.signal);
|
||||
const credentials = await refreshGitHubCopilotToken(githubToken, enterpriseDomain ?? undefined);
|
||||
callbacks.onProgress?.("Enabling Copilot models");
|
||||
await enableCopilotModels(credentials.access, enterpriseDomain ?? undefined);
|
||||
return credentials;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Google Antigravity (Gemini 3 / Claude / GPT-OSS via Google Cloud) —
|
||||
* authorization-code + PKCE flow with project discovery.
|
||||
*
|
||||
* Ported from the reference implementation in @mariozechner/pi-ai.
|
||||
*/
|
||||
import { startOAuthCallbackServer } from "./oauth-callback-server";
|
||||
import { generatePkce } from "./pkce";
|
||||
import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types";
|
||||
|
||||
const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
|
||||
const CLIENT_SECRET = "***REMOVED***";
|
||||
const CALLBACK_PORT = 51121;
|
||||
const CALLBACK_PATH = "/oauth-callback";
|
||||
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
||||
const SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
];
|
||||
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
|
||||
const CODE_ASSIST_ENDPOINTS = ["https://cloudcode-pa.googleapis.com", "https://daily-cloudcode-pa.sandbox.googleapis.com"];
|
||||
|
||||
function buildAuthorizeUrl(challenge: string, state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPES.join(" "),
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
return `${AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function exchangeCode(code: string, verifier: string): Promise<{ access: string; refresh: string; expires: number }> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`Token exchange failed: ${response.status} ${text}`);
|
||||
}
|
||||
const json = (await response.json()) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
|
||||
if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") {
|
||||
throw new Error("Token response was missing required fields.");
|
||||
}
|
||||
return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 - 5 * 60 * 1000 };
|
||||
}
|
||||
|
||||
/** Extract a Code Assist project id from a loadCodeAssist response (unit-tested). */
|
||||
export function readProjectId(payload: Record<string, unknown>): string | null {
|
||||
const project = payload.cloudaicompanionProject;
|
||||
if (typeof project === "string" && project) return project;
|
||||
if (project && typeof project === "object" && "id" in project) {
|
||||
const id = (project as { id?: unknown }).id;
|
||||
if (typeof id === "string" && id) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function discoverProject(accessToken: string): Promise<string> {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
};
|
||||
for (const endpoint of CODE_ASSIST_ENDPOINTS) {
|
||||
try {
|
||||
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" } }),
|
||||
});
|
||||
if (response.ok) {
|
||||
const projectId = readProjectId((await response.json()) as Record<string, unknown>);
|
||||
if (projectId) return projectId;
|
||||
}
|
||||
} catch {
|
||||
// Try the next endpoint.
|
||||
}
|
||||
}
|
||||
return DEFAULT_PROJECT_ID;
|
||||
}
|
||||
|
||||
export const antigravityFlow: OAuthFlow = {
|
||||
providerKey: "google-antigravity",
|
||||
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||
const { verifier, challenge } = generatePkce();
|
||||
const server = await startOAuthCallbackServer({ port: CALLBACK_PORT, path: CALLBACK_PATH, signal: callbacks.signal });
|
||||
try {
|
||||
callbacks.onAuth({ url: buildAuthorizeUrl(challenge, verifier), instructions: "Complete the Google sign-in in your browser." });
|
||||
const result = await server.waitForCode();
|
||||
if (result.state !== verifier) throw new Error("OAuth state mismatch.");
|
||||
const tokens = await exchangeCode(result.code, verifier);
|
||||
callbacks.onProgress?.("Resolving project");
|
||||
const projectId = await discoverProject(tokens.access);
|
||||
return { access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, projectId };
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createServer } from "node:http";
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page";
|
||||
|
||||
const HTML_HEADERS = { "Content-Type": "text/html; charset=utf-8" } as const;
|
||||
|
||||
export interface OAuthCallbackResult {
|
||||
code: string;
|
||||
state: string | null;
|
||||
}
|
||||
|
||||
export interface OAuthCallbackServer {
|
||||
/** Resolves when the provider redirects with a code; rejects on provider
|
||||
* error or cancellation. */
|
||||
waitForCode(): Promise<OAuthCallbackResult>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a loopback HTTP server that captures the OAuth authorization-code
|
||||
* redirect. The provider's OAuth client is registered with the exact loopback
|
||||
* redirect URI, so the port and path must match the flow's REDIRECT_URI.
|
||||
*/
|
||||
export function startOAuthCallbackServer(options: {
|
||||
port: number;
|
||||
path: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<OAuthCallbackServer> {
|
||||
const { port, path, signal } = options;
|
||||
return new Promise<OAuthCallbackServer>((resolve, reject) => {
|
||||
let settle: ((value: OAuthCallbackResult) => void) | null = null;
|
||||
let failWait: ((error: Error) => void) | null = null;
|
||||
const codePromise = new Promise<OAuthCallbackResult>((resolveCode, rejectCode) => {
|
||||
settle = resolveCode;
|
||||
failWait = rejectCode;
|
||||
});
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "", `http://127.0.0.1:${port}`);
|
||||
if (url.pathname !== path) {
|
||||
res.writeHead(404, HTML_HEADERS);
|
||||
res.end(oauthErrorHtml("Callback route not found."));
|
||||
return;
|
||||
}
|
||||
const error = url.searchParams.get("error");
|
||||
if (error) {
|
||||
res.writeHead(400, HTML_HEADERS);
|
||||
res.end(oauthErrorHtml("Sign-in did not complete.", error));
|
||||
failWait?.(new Error(`Sign-in failed: ${error}`));
|
||||
return;
|
||||
}
|
||||
const code = url.searchParams.get("code");
|
||||
if (!code) {
|
||||
res.writeHead(400, HTML_HEADERS);
|
||||
res.end(oauthErrorHtml("Missing authorization code."));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, HTML_HEADERS);
|
||||
res.end(oauthSuccessHtml("Sign-in complete. You can close this window and return to AgentCompany."));
|
||||
settle?.({ code, state: url.searchParams.get("state") });
|
||||
});
|
||||
|
||||
const onAbort = () => failWait?.(new Error("Login cancelled"));
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
server.on("error", (err) => reject(err));
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
resolve({
|
||||
waitForCode: () => codePromise,
|
||||
close: () => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
server.close();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { githubCopilotFlow } from "./github-copilot-flow";
|
||||
import { openaiCodexFlow } from "./openai-codex-flow";
|
||||
import { antigravityFlow } from "./google-antigravity-flow";
|
||||
import type { OAuthFlow } from "./oauth-types";
|
||||
|
||||
/** Provider keys (from the catalog) mapped to their native OAuth login flow. */
|
||||
const OAUTH_FLOWS: Record<string, OAuthFlow> = {
|
||||
"github-copilot": githubCopilotFlow,
|
||||
"gpt-account": openaiCodexFlow,
|
||||
"google-antigravity": antigravityFlow,
|
||||
};
|
||||
|
||||
export function hasOAuthFlow(providerKey: string): boolean {
|
||||
return providerKey in OAUTH_FLOWS;
|
||||
}
|
||||
|
||||
export function getOAuthFlow(providerKey: string): OAuthFlow {
|
||||
const flow = OAUTH_FLOWS[providerKey];
|
||||
if (!flow) throw new Error(`${providerKey} does not support interactive sign-in.`);
|
||||
return flow;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/** Minimal HTML shown in the user's browser after the OAuth redirect. */
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function renderPage(heading: string, message: string, details?: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeHtml(heading)}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html { color-scheme: dark; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px; background: #09090b; color: #fafafa; text-align: center;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
main { width: 100%; max-width: 520px; }
|
||||
h1 { margin: 0 0 10px; font-size: 26px; font-weight: 650; }
|
||||
p { margin: 0; line-height: 1.7; color: #a1a1aa; font-size: 15px; }
|
||||
.details { margin-top: 14px; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; color: #a1a1aa; word-break: break-word; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>${escapeHtml(heading)}</h1>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
${details ? `<div class="details">${escapeHtml(details)}</div>` : ""}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function oauthSuccessHtml(message: string): string {
|
||||
return renderPage("Authentication successful", message);
|
||||
}
|
||||
|
||||
export function oauthErrorHtml(message: string, details?: string): string {
|
||||
return renderPage("Authentication failed", message, details);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Shared types for the native provider OAuth login flows.
|
||||
*
|
||||
* These flows obtain real provider credentials through the providers' official
|
||||
* device-code / authorization-code (PKCE) processes. The resulting credentials
|
||||
* are stored as JSON via the existing secret vault so a token can be extracted
|
||||
* for requests and a refresh token retained for future renewal.
|
||||
*/
|
||||
|
||||
export interface OAuthCredentials {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OAuthLoginCallbacks {
|
||||
/** Called once the authorization URL is ready. `instructions` carries the
|
||||
* device user-code (device flow) or a short note (authorization-code flow). */
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onProgress?: (message: string) => void;
|
||||
signal?: AbortSignal;
|
||||
/** GitHub Enterprise domain for Copilot; null/empty means github.com. */
|
||||
enterpriseDomain?: string | null;
|
||||
}
|
||||
|
||||
export interface OAuthFlow {
|
||||
providerKey: string;
|
||||
login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* OpenAI Codex (ChatGPT Plus/Pro subscription) — authorization-code + PKCE flow.
|
||||
*
|
||||
* Ported from the reference implementation in @mariozechner/pi-ai. Uses a
|
||||
* loopback callback server on the registered redirect URI.
|
||||
*/
|
||||
import { startOAuthCallbackServer } from "./oauth-callback-server";
|
||||
import { generatePkce } from "./pkce";
|
||||
import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types";
|
||||
|
||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
||||
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
||||
const CALLBACK_PORT = 1455;
|
||||
const CALLBACK_PATH = "/auth/callback";
|
||||
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
||||
const SCOPE = "openid profile email offline_access";
|
||||
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
|
||||
|
||||
function buildAuthorizeUrl(challenge: string, state: string): string {
|
||||
const url = new URL(AUTHORIZE_URL);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", CLIENT_ID);
|
||||
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
url.searchParams.set("scope", SCOPE);
|
||||
url.searchParams.set("code_challenge", challenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("id_token_add_organizations", "true");
|
||||
url.searchParams.set("codex_cli_simplified_flow", "true");
|
||||
url.searchParams.set("originator", "agentcompany");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function decodeJwtClaims(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract the ChatGPT account id embedded in the access token (unit-tested). */
|
||||
export function extractAccountId(accessToken: string): string | null {
|
||||
const claims = decodeJwtClaims(accessToken);
|
||||
const auth = claims?.[JWT_CLAIM_PATH];
|
||||
if (auth && typeof auth === "object" && "chatgpt_account_id" in auth) {
|
||||
const accountId = (auth as { chatgpt_account_id?: unknown }).chatgpt_account_id;
|
||||
if (typeof accountId === "string" && accountId.length > 0) return accountId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function exchangeCode(code: string, verifier: string): Promise<{ access: string; refresh: string; expires: number }> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`Token exchange failed: ${response.status} ${text}`);
|
||||
}
|
||||
const json = (await response.json()) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
|
||||
if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") {
|
||||
throw new Error("Token response was missing required fields.");
|
||||
}
|
||||
return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 };
|
||||
}
|
||||
|
||||
export const openaiCodexFlow: OAuthFlow = {
|
||||
providerKey: "gpt-account",
|
||||
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||
const { verifier, challenge } = generatePkce();
|
||||
const state = generatePkce().verifier;
|
||||
const server = await startOAuthCallbackServer({ port: CALLBACK_PORT, path: CALLBACK_PATH, signal: callbacks.signal });
|
||||
try {
|
||||
callbacks.onAuth({ url: buildAuthorizeUrl(challenge, state), instructions: "Complete the ChatGPT sign-in in your browser." });
|
||||
const result = await server.waitForCode();
|
||||
if (result.state && result.state !== state) throw new Error("OAuth state mismatch.");
|
||||
const tokens = await exchangeCode(result.code, verifier);
|
||||
const accountId = extractAccountId(tokens.access);
|
||||
if (!accountId) throw new Error("Could not read the ChatGPT account id from the token.");
|
||||
return { access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, accountId };
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
export interface PkcePair {
|
||||
verifier: string;
|
||||
challenge: string;
|
||||
}
|
||||
|
||||
/** Generate a PKCE verifier and its S256 challenge (RFC 7636). */
|
||||
export function generatePkce(): PkcePair {
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
@@ -14,11 +14,30 @@ export function parseApiKey(secret: string): ProviderAuthProfile {
|
||||
}
|
||||
|
||||
export function parseAccessToken(secret: string): ProviderAuthProfile {
|
||||
return { type: "access_token", accessToken: nonEmptySecret(secret, "Access token") };
|
||||
return { type: "access_token", accessToken: oauthAccessToken(secret, "Access token") };
|
||||
}
|
||||
|
||||
export function parseAccountToken(secret: string): ProviderAuthProfile {
|
||||
return { type: "account_token", accessToken: nonEmptySecret(secret, "Account token") };
|
||||
return { type: "account_token", accessToken: oauthAccessToken(secret, "Account token") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts either a raw token string or the JSON credential object produced by
|
||||
* the native OAuth login flow, returning the usable access token.
|
||||
*/
|
||||
function oauthAccessToken(secret: string, label: string): string {
|
||||
const value = nonEmptySecret(secret, label);
|
||||
if (!value.startsWith("{")) return value;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
throw new Error(`${label} credential must be a token or JSON.`);
|
||||
}
|
||||
if (isRecord(parsed) && typeof parsed.access === "string" && parsed.access.trim()) {
|
||||
return parsed.access.trim();
|
||||
}
|
||||
throw new Error(`${label} credential is missing an access token.`);
|
||||
}
|
||||
|
||||
export function parseAzureProfile(secret: string): ProviderAuthProfile {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getOAuthFlow } from "./oauth/oauth-flows";
|
||||
|
||||
export interface ProviderOAuthHooks {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onProgress?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a provider's native OAuth login and returns the obtained credentials
|
||||
* as a JSON string, ready to be stored through the existing secret vault. The
|
||||
* service is decoupled from Electron: opening the browser and emitting UI
|
||||
* events are supplied by the caller via {@link ProviderOAuthHooks}.
|
||||
*/
|
||||
export class ProviderOAuthService {
|
||||
private readonly controllers = new Map<string, AbortController>();
|
||||
|
||||
async run(input: { providerKey: string; enterpriseDomain?: string | null }, hooks: ProviderOAuthHooks): Promise<string> {
|
||||
const flow = getOAuthFlow(input.providerKey);
|
||||
if (this.controllers.has(input.providerKey)) {
|
||||
throw new Error("A sign-in for this provider is already in progress.");
|
||||
}
|
||||
const controller = new AbortController();
|
||||
this.controllers.set(input.providerKey, controller);
|
||||
try {
|
||||
const credentials = await flow.login({
|
||||
onAuth: hooks.onAuth,
|
||||
onProgress: hooks.onProgress,
|
||||
signal: controller.signal,
|
||||
enterpriseDomain: input.enterpriseDomain ?? null,
|
||||
});
|
||||
return JSON.stringify(credentials);
|
||||
} finally {
|
||||
this.controllers.delete(input.providerKey);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(providerKey: string): void {
|
||||
this.controllers.get(providerKey)?.abort();
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,7 @@ export class ProviderService {
|
||||
providerKey: provider.provider_key,
|
||||
authType: provider.auth_type,
|
||||
employeeId: input.employeeId ?? null,
|
||||
secret,
|
||||
});
|
||||
const authProfile = this.authBroker.createProviderProfile({
|
||||
orgId: input.orgId,
|
||||
@@ -263,10 +264,16 @@ export class ProviderService {
|
||||
providerKey: string;
|
||||
authType: ProviderAuthType;
|
||||
employeeId: string | null;
|
||||
secret: string;
|
||||
}): { sourceType: string; sourceRef: string | null } {
|
||||
if (input.authType !== PROVIDER_AUTH_TYPE.OAUTH_PKCE && input.authType !== PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION) {
|
||||
return { sourceType: "manual_secret", sourceRef: null };
|
||||
}
|
||||
// Credentials obtained through the native OAuth login arrive as a JSON
|
||||
// object; they are authenticated by the flow itself and need no paste session.
|
||||
if (input.secret.trim().startsWith("{")) {
|
||||
return { sourceType: "provider_oauth_login", sourceRef: null };
|
||||
}
|
||||
if (!input.employeeId) throw new Error("Provider sign-in employee is required.");
|
||||
const session = this.db.prepare(`
|
||||
select id
|
||||
|
||||
Reference in new issue
Block a user