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
@@ -8,6 +8,7 @@ import { OrganizationService } from "../services/organization-service";
|
||||
import { ApprovalService } from "../services/approval-service";
|
||||
import { ChatService } from "../services/chat-service";
|
||||
import { ProviderService } from "../services/provider-service";
|
||||
import { ProviderOAuthService } from "../services/provider-oauth-service";
|
||||
import { SkillService } from "../services/skill-service";
|
||||
import { SocialService } from "../services/social-service";
|
||||
import { FileService } from "../services/file-service";
|
||||
@@ -34,6 +35,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
const approvals = new ApprovalService(deps.connection.db);
|
||||
const chat = new ChatService(deps.connection.db);
|
||||
const providers = new ProviderService(deps.connection.db);
|
||||
const providerOAuth = new ProviderOAuthService();
|
||||
const skills = new SkillService(deps.connection.db, deps.artifactRoot);
|
||||
const taskExecution = new TaskExecutionService(deps.connection.db, providers, skills);
|
||||
const social = new SocialService(deps.connection.db);
|
||||
@@ -128,6 +130,44 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
return fail("provider_sign_in_failed", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.startProviderOAuth, async (_event, input) => {
|
||||
const emit = (event: Record<string, unknown>) =>
|
||||
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "provider-oauth", providerKey: input.providerKey, ...event });
|
||||
try {
|
||||
const secret = await providerOAuth.run(
|
||||
{ providerKey: input.providerKey, enterpriseDomain: input.enterpriseDomain ?? null },
|
||||
{
|
||||
onAuth: ({ url, instructions }) => {
|
||||
void shell.openExternal(url);
|
||||
emit({ phase: "authorize", url, instructions });
|
||||
},
|
||||
onProgress: (message) => emit({ phase: "progress", message }),
|
||||
},
|
||||
);
|
||||
if (input.orgId && input.providerId && input.employeeId) {
|
||||
providers.saveCredential({
|
||||
orgId: input.orgId,
|
||||
providerId: input.providerId,
|
||||
secret,
|
||||
model: input.model ?? null,
|
||||
employeeId: input.employeeId,
|
||||
});
|
||||
emit({ phase: "active" });
|
||||
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
|
||||
return ok({ secret: null });
|
||||
}
|
||||
emit({ phase: "active" });
|
||||
return ok({ secret });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
emit({ phase: message === "Login cancelled" ? "cancelled" : "error", message });
|
||||
return fail("provider_oauth_failed", message);
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.cancelProviderOAuth, (_event, input) => {
|
||||
providerOAuth.cancel(input.providerKey);
|
||||
return ok(null);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.openExternalUrl, async (_event, url: string) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,6 +29,8 @@ const api: DesktopApi = {
|
||||
saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input),
|
||||
revokeProviderCredential: (input) => invoke(IPC_CHANNELS.revokeProviderCredential, input),
|
||||
launchProviderSignIn: (input) => invoke(IPC_CHANNELS.launchProviderSignIn, input),
|
||||
startProviderOAuth: (input) => invoke(IPC_CHANNELS.startProviderOAuth, input),
|
||||
cancelProviderOAuth: (input) => invoke(IPC_CHANNELS.cancelProviderOAuth, input),
|
||||
checkProviderHealth: (input) => invoke(IPC_CHANNELS.checkProviderHealth, input),
|
||||
updateProviderPolicy: (input) => invoke(IPC_CHANNELS.updateProviderPolicy, input),
|
||||
openExternalUrl: (url) => invoke(IPC_CHANNELS.openExternalUrl, url),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type ProviderCatalogEntry,
|
||||
} from "../../../shared/domain";
|
||||
import { Button, Field, Icon, IconButton, Pill } from "../shared/ui";
|
||||
import { ProviderOAuthConnect } from "../shared/ProviderOAuthConnect";
|
||||
import type { SnapshotActions } from "./useSnapshot";
|
||||
|
||||
type Step = "welcome" | "provider" | "company" | "mode" | "mission";
|
||||
@@ -244,7 +245,6 @@ function ProviderStep({
|
||||
const selected = providers.find((entry) => entry.key === draft.providerKey) ?? providers[0];
|
||||
if (!selected) throw new Error("No provider available for onboarding.");
|
||||
const isOAuthLike = selected.authType === "oauth_pkce" || selected.authType === "account_subscription";
|
||||
const secretLabel = isOAuthLike ? "Access token" : "API key";
|
||||
|
||||
return (
|
||||
<div className="form-grid">
|
||||
@@ -275,26 +275,25 @@ function ProviderStep({
|
||||
{selected.credentialHelp && (
|
||||
<div style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5 }}>{selected.credentialHelp}</div>
|
||||
)}
|
||||
{isOAuthLike && selected.signInUrl && (
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<Button
|
||||
tone="primary"
|
||||
onClick={() => void window.agentCompany.openExternalUrl(selected.signInUrl ?? "")}
|
||||
>
|
||||
Open {selected.label} sign-in
|
||||
</Button>
|
||||
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{selected.signInUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
<Field label={secretLabel}>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={draft.providerSecret}
|
||||
onChange={(event) => onChange({ providerSecret: event.target.value })}
|
||||
{isOAuthLike ? (
|
||||
<ProviderOAuthConnect
|
||||
key={selected.key}
|
||||
providerKey={selected.key}
|
||||
label={selected.label}
|
||||
buildInput={() => ({ providerKey: selected.key })}
|
||||
onComplete={(secret) => onChange({ providerSecret: secret ?? "" })}
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="API key">
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={draft.providerSecret}
|
||||
onChange={(event) => onChange({ providerSecret: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Model">
|
||||
{selected.models.length ? (
|
||||
<select
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Button, Field, Pill } from "../../shared/ui";
|
||||
import { Dialog } from "../../shared/Dialog";
|
||||
import { ProviderOAuthConnect } from "../../shared/ProviderOAuthConnect";
|
||||
import type {
|
||||
AgentRecord,
|
||||
ProviderCatalogEntry,
|
||||
@@ -41,19 +42,19 @@ type WorkspaceScope = "organization" | "workspace";
|
||||
|
||||
export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, catalog, agents, workspaces, providerRateLimits, providerRequests, actions }: ProvidersPanelProps) {
|
||||
const [active, setActive] = useState<ProviderRecord | null>(null);
|
||||
const [signInLaunched, setSignInLaunched] = useState(false);
|
||||
const connected = providers.filter((provider) => provider.enabled);
|
||||
const available = providers.filter((provider) => !provider.enabled);
|
||||
const activeCatalogEntry = active ? catalog.find((entry) => entry.key === active.providerKey) ?? null : null;
|
||||
const activeIsOAuth =
|
||||
activeCatalogEntry?.authType === "oauth_pkce" || activeCatalogEntry?.authType === "account_subscription";
|
||||
|
||||
const close = () => {
|
||||
setActive(null);
|
||||
setSignInLaunched(false);
|
||||
};
|
||||
|
||||
const save = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!active || !activeCatalogEntry) return;
|
||||
if (!active || !activeCatalogEntry || activeIsOAuth) return;
|
||||
const form = new FormData(event.currentTarget);
|
||||
const secret = composeSecret(activeCatalogEntry, form);
|
||||
void actions.run(() => window.agentCompany.saveProviderCredential({
|
||||
@@ -65,12 +66,6 @@ export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, cat
|
||||
})).then((result) => { if (result) close(); });
|
||||
};
|
||||
|
||||
const launchSignIn = () => {
|
||||
if (!active) return;
|
||||
setSignInLaunched(true);
|
||||
void actions.run(() => window.agentCompany.launchProviderSignIn({ orgId, providerId: active.id, employeeId }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Providers</h1>
|
||||
@@ -124,16 +119,22 @@ export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, cat
|
||||
title={active ? `Connect ${active.label}` : "Connect provider"}
|
||||
open={Boolean(active)}
|
||||
submitLabel="Save"
|
||||
hideSubmit={activeIsOAuth}
|
||||
onClose={close}
|
||||
onSubmit={save}
|
||||
>
|
||||
{active && activeCatalogEntry && (
|
||||
<ProviderConnectFields
|
||||
provider={active}
|
||||
entry={activeCatalogEntry}
|
||||
signInLaunched={signInLaunched}
|
||||
onLaunchSignIn={launchSignIn}
|
||||
/>
|
||||
activeIsOAuth ? (
|
||||
<ProviderOAuthFields
|
||||
orgId={orgId}
|
||||
employeeId={employeeId}
|
||||
provider={active}
|
||||
entry={activeCatalogEntry}
|
||||
onConnected={() => { void actions.reload(); close(); }}
|
||||
/>
|
||||
) : (
|
||||
<ProviderConnectFields provider={active} entry={activeCatalogEntry} />
|
||||
)
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
@@ -274,13 +275,9 @@ function ProviderPolicyRow({
|
||||
function ProviderConnectFields({
|
||||
provider,
|
||||
entry,
|
||||
signInLaunched,
|
||||
onLaunchSignIn,
|
||||
}: {
|
||||
provider: ProviderRecord;
|
||||
entry: ProviderCatalogEntry;
|
||||
signInLaunched: boolean;
|
||||
onLaunchSignIn: () => void;
|
||||
}) {
|
||||
if (entry.authType === "api_key") {
|
||||
return (
|
||||
@@ -291,24 +288,48 @@ function ProviderConnectFields({
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (entry.authType === "oauth_pkce" || entry.authType === "account_subscription") {
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<Button tone={signInLaunched ? "ghost" : "primary"} onClick={onLaunchSignIn}>
|
||||
{signInLaunched ? "Re-open sign-in window" : `Open ${entry.label} sign-in`}
|
||||
</Button>
|
||||
{entry.signInUrl && <span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{entry.signInUrl}</span>}
|
||||
</div>
|
||||
<Field label="Access token"><input className="input" name="secret" type="password" required /></Field>
|
||||
<ModelPicker entry={entry} provider={provider} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <CloudFieldsForm entry={entry} provider={provider} />;
|
||||
}
|
||||
|
||||
function ProviderOAuthFields({
|
||||
orgId,
|
||||
employeeId,
|
||||
provider,
|
||||
entry,
|
||||
onConnected,
|
||||
}: {
|
||||
orgId: string;
|
||||
employeeId: string;
|
||||
provider: ProviderRecord;
|
||||
entry: ProviderCatalogEntry;
|
||||
onConnected: () => void;
|
||||
}) {
|
||||
const [model, setModel] = useState(provider.model ?? entry.defaultModel ?? entry.models[0]?.id ?? "");
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
|
||||
<Field label="Default model">
|
||||
{entry.models.length ? (
|
||||
<select className="input" value={model} onChange={(event) => setModel(event.target.value)}>
|
||||
{entry.models.map((option) => (
|
||||
<option key={option.id} value={option.id}>{option.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input className="input" value={model} onChange={(event) => setModel(event.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
<ProviderOAuthConnect
|
||||
key={entry.key}
|
||||
providerKey={entry.key}
|
||||
label={entry.label}
|
||||
buildInput={() => ({ providerKey: entry.key, orgId, providerId: provider.id, employeeId, model })}
|
||||
onComplete={() => onConnected()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelPicker({ entry, provider }: { entry: ProviderCatalogEntry; provider: ProviderRecord }) {
|
||||
const defaultValue = provider.model ?? entry.defaultModel ?? entry.models[0]?.id ?? "";
|
||||
if (entry.models.length === 0) {
|
||||
|
||||
@@ -8,6 +8,7 @@ export function Dialog({
|
||||
submitLabel,
|
||||
onClose,
|
||||
onSubmit,
|
||||
hideSubmit = false,
|
||||
}: {
|
||||
title: string;
|
||||
open: boolean;
|
||||
@@ -15,6 +16,7 @@ export function Dialog({
|
||||
submitLabel: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
hideSubmit?: boolean;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
@@ -23,8 +25,8 @@ export function Dialog({
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<div style={{ display: "grid", gap: 12 }}>{children}</div>
|
||||
<div className="modal-actions">
|
||||
<Button tone="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button tone="primary" type="submit">{submitLabel}</Button>
|
||||
<Button tone="ghost" onClick={onClose}>{hideSubmit ? "Close" : "Cancel"}</Button>
|
||||
{!hideSubmit && <Button tone="primary" type="submit">{submitLabel}</Button>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { StartProviderOAuthInput } from "../../../shared/commands";
|
||||
import { Button, Icon } from "./ui";
|
||||
|
||||
type Phase = "idle" | "running" | "done" | "error";
|
||||
|
||||
/**
|
||||
* Runs a provider's native OAuth login (device-code / PKCE) and reports the
|
||||
* outcome. The browser opens automatically; for the device flow the user code
|
||||
* is shown here, which is the only interactive instruction required.
|
||||
*/
|
||||
export function ProviderOAuthConnect({
|
||||
providerKey,
|
||||
label,
|
||||
buildInput,
|
||||
onComplete,
|
||||
}: {
|
||||
providerKey: string;
|
||||
label: string;
|
||||
buildInput: () => StartProviderOAuthInput;
|
||||
onComplete: (secret: string | null) => void;
|
||||
}) {
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [instruction, setInstruction] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
window.agentCompany.subscribe((event) => {
|
||||
if (event.type !== "provider-oauth" || event.providerKey !== providerKey) return;
|
||||
if (event.phase === "authorize" && event.instructions) setInstruction(event.instructions);
|
||||
}),
|
||||
[providerKey],
|
||||
);
|
||||
|
||||
const run = () => {
|
||||
setPhase("running");
|
||||
setInstruction(null);
|
||||
setError(null);
|
||||
void window.agentCompany.startProviderOAuth(buildInput()).then((result) => {
|
||||
if (result.ok) {
|
||||
setPhase("done");
|
||||
onComplete(result.data.secret);
|
||||
} else {
|
||||
setPhase("error");
|
||||
setError(result.error.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const cancel = () => void window.agentCompany.cancelProviderOAuth({ providerKey });
|
||||
|
||||
if (phase === "running") {
|
||||
return (
|
||||
<div className="oauth-connect">
|
||||
<div className="oauth-connect-row">
|
||||
<span className="spinner" aria-hidden />
|
||||
<span>Waiting for {label} sign-in</span>
|
||||
<button type="button" className="link-button" onClick={cancel}>Cancel</button>
|
||||
</div>
|
||||
{instruction && <div className="oauth-connect-code">{instruction}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "done") {
|
||||
return (
|
||||
<div className="oauth-connect-row done">
|
||||
<Icon name="check" size={14} color="var(--green)" />
|
||||
<span>Signed in to {label}</span>
|
||||
<button type="button" className="link-button" onClick={run}>Redo</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="oauth-connect">
|
||||
<Button tone="primary" onClick={run}>Sign in with {label}</Button>
|
||||
{error && <div className="oauth-connect-error">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -180,3 +180,28 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
|
||||
color: var(--text-3);
|
||||
}
|
||||
.link-button:hover { color: var(--text-1); }
|
||||
|
||||
.oauth-connect { display: flex; flex-direction: column; gap: 8px; }
|
||||
.oauth-connect-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-2); }
|
||||
.oauth-connect-row.done { color: var(--text-1); }
|
||||
.oauth-connect-row .link-button { margin-left: auto; }
|
||||
.oauth-connect-code {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-1);
|
||||
background: var(--panel-3);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 8px 10px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.oauth-connect-error { font-size: 12px; color: var(--red); }
|
||||
.spinner {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border-strong);
|
||||
border-top-color: var(--text-2);
|
||||
animation: spinner-rotate 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes spinner-rotate { to { transform: rotate(360deg); } }
|
||||
@@ -184,6 +184,27 @@ export interface LaunchProviderSignInInput {
|
||||
employeeId: string;
|
||||
}
|
||||
|
||||
export interface StartProviderOAuthInput {
|
||||
providerKey: string;
|
||||
enterpriseDomain?: string | null;
|
||||
/** Present when connecting an existing org's provider (Settings); absent
|
||||
* during onboarding, where the obtained credential is returned to the caller. */
|
||||
orgId?: string;
|
||||
providerId?: string;
|
||||
employeeId?: string;
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
export interface StartProviderOAuthResult {
|
||||
/** The obtained credential JSON when no org context was supplied, else null
|
||||
* (the credential was already stored). */
|
||||
secret: string | null;
|
||||
}
|
||||
|
||||
export interface CancelProviderOAuthInput {
|
||||
providerKey: string;
|
||||
}
|
||||
|
||||
export interface CheckProviderHealthInput {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
|
||||
@@ -14,6 +14,9 @@ import type {
|
||||
CreateLegalHoldInput,
|
||||
CreateMigrationReportInput,
|
||||
CheckProviderHealthInput,
|
||||
StartProviderOAuthInput,
|
||||
StartProviderOAuthResult,
|
||||
CancelProviderOAuthInput,
|
||||
MarkChatSpaceReadInput,
|
||||
CreateOrganizationInput,
|
||||
CreateProviderResponseInput,
|
||||
@@ -58,9 +61,21 @@ import type {
|
||||
} from "./commands";
|
||||
import type { ProviderStreamRendererEvent } from "./provider-request-domain";
|
||||
|
||||
export type ProviderOAuthPhase = "authorize" | "progress" | "active" | "error" | "cancelled";
|
||||
|
||||
export interface ProviderOAuthEvent {
|
||||
type: "provider-oauth";
|
||||
providerKey: string;
|
||||
phase: ProviderOAuthPhase;
|
||||
url?: string;
|
||||
instructions?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type RendererEvent =
|
||||
| { type: "snapshot-changed" }
|
||||
| { type: "provider-response-stream"; event: ProviderStreamRendererEvent };
|
||||
| { type: "provider-response-stream"; event: ProviderStreamRendererEvent }
|
||||
| ProviderOAuthEvent;
|
||||
|
||||
export interface DesktopApi {
|
||||
loadSnapshot(): Promise<DesktopResult<AppSnapshot>>;
|
||||
@@ -85,6 +100,8 @@ export interface DesktopApi {
|
||||
saveProviderCredential(input: SaveProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
revokeProviderCredential(input: RevokeProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
launchProviderSignIn(input: LaunchProviderSignInInput): Promise<DesktopResult<null>>;
|
||||
startProviderOAuth(input: StartProviderOAuthInput): Promise<DesktopResult<StartProviderOAuthResult>>;
|
||||
cancelProviderOAuth(input: CancelProviderOAuthInput): Promise<DesktopResult<null>>;
|
||||
checkProviderHealth(input: CheckProviderHealthInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
updateProviderPolicy(input: UpdateProviderPolicyInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
openExternalUrl(url: string): Promise<DesktopResult<null>>;
|
||||
|
||||
@@ -21,6 +21,8 @@ export const IPC_CHANNELS = {
|
||||
saveProviderCredential: "agentcompany:save-provider-credential",
|
||||
revokeProviderCredential: "agentcompany:revoke-provider-credential",
|
||||
launchProviderSignIn: "agentcompany:launch-provider-sign-in",
|
||||
startProviderOAuth: "agentcompany:start-provider-oauth",
|
||||
cancelProviderOAuth: "agentcompany:cancel-provider-oauth",
|
||||
checkProviderHealth: "agentcompany:check-provider-health",
|
||||
updateProviderPolicy: "agentcompany:update-provider-policy",
|
||||
openExternalUrl: "agentcompany:open-external-url",
|
||||
|
||||
@@ -44,8 +44,8 @@ const RAW_CATALOG: Omit<ProviderCatalogEntry, "models">[] = [
|
||||
authType: PROVIDER_AUTH_TYPE.OAUTH_PKCE,
|
||||
defaultModel: "claude-opus-4-5",
|
||||
capabilityTags: ["account", "coding"],
|
||||
signInUrl: "https://github.com/login",
|
||||
credentialHelp: "Sign in, then copy the personal access token (scope copilot) from github.com → Settings → Developer settings.",
|
||||
signInUrl: "https://github.com/login/device",
|
||||
credentialHelp: "Sign in with the GitHub device code shown here — no token to copy.",
|
||||
},
|
||||
{
|
||||
key: "google-antigravity",
|
||||
@@ -54,7 +54,7 @@ const RAW_CATALOG: Omit<ProviderCatalogEntry, "models">[] = [
|
||||
defaultModel: "claude-sonnet-4-5",
|
||||
capabilityTags: ["account", "coding"],
|
||||
signInUrl: "https://antigravity.google/",
|
||||
credentialHelp: "Sign in to Antigravity, then paste the session token shown in the dashboard.",
|
||||
credentialHelp: "Sign in with your Google account in the browser; AgentCompany finishes the handshake.",
|
||||
},
|
||||
{
|
||||
key: "gpt-account",
|
||||
@@ -63,7 +63,7 @@ const RAW_CATALOG: Omit<ProviderCatalogEntry, "models">[] = [
|
||||
defaultModel: "gpt-5.3-codex",
|
||||
capabilityTags: ["codex", "subscription"],
|
||||
signInUrl: "https://chatgpt.com/codex",
|
||||
credentialHelp: "Sign in to ChatGPT Plus/Pro, then paste the session access token from chatgpt.com/codex/settings.",
|
||||
credentialHelp: "Sign in to ChatGPT Plus/Pro in the browser; AgentCompany finishes the handshake.",
|
||||
},
|
||||
{
|
||||
key: "azure-openai",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { interpretPollResponse } from "../../src/main/services/oauth/github-copilot-flow";
|
||||
import { extractAccountId } from "../../src/main/services/oauth/openai-codex-flow";
|
||||
import { readProjectId } from "../../src/main/services/oauth/google-antigravity-flow";
|
||||
import { generatePkce } from "../../src/main/services/oauth/pkce";
|
||||
import { parseAccessToken, parseAccountToken } from "../../src/main/services/provider-auth-profiles";
|
||||
|
||||
function makeJwt(claims: Record<string, unknown>): string {
|
||||
const part = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
return `${part({ alg: "none" })}.${part(claims)}.signature`;
|
||||
}
|
||||
|
||||
describe("github copilot device-code poll interpreter", () => {
|
||||
test("returns the token when present", () => {
|
||||
expect(interpretPollResponse({ access_token: "ghu_token" })).toEqual({ kind: "token", accessToken: "ghu_token" });
|
||||
});
|
||||
|
||||
test("keeps polling while authorization is pending", () => {
|
||||
expect(interpretPollResponse({ error: "authorization_pending" })).toEqual({ kind: "pending" });
|
||||
expect(interpretPollResponse({})).toEqual({ kind: "pending" });
|
||||
});
|
||||
|
||||
test("backs off on slow_down with the provided interval", () => {
|
||||
expect(interpretPollResponse({ error: "slow_down", interval: 9 })).toEqual({ kind: "slow_down", intervalSeconds: 9 });
|
||||
expect(interpretPollResponse({ error: "slow_down" })).toEqual({ kind: "slow_down", intervalSeconds: null });
|
||||
});
|
||||
|
||||
test("fails on terminal errors", () => {
|
||||
const decision = interpretPollResponse({ error: "access_denied", error_description: "user declined" });
|
||||
expect(decision.kind).toBe("error");
|
||||
if (decision.kind === "error") expect(decision.message).toContain("access_denied: user declined");
|
||||
});
|
||||
});
|
||||
|
||||
describe("openai codex account id extraction", () => {
|
||||
test("reads the chatgpt account id from the token claims", () => {
|
||||
const token = makeJwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct_42" } });
|
||||
expect(extractAccountId(token)).toBe("acct_42");
|
||||
});
|
||||
|
||||
test("returns null when the claim is absent or malformed", () => {
|
||||
expect(extractAccountId(makeJwt({ sub: "user" }))).toBeNull();
|
||||
expect(extractAccountId("not-a-jwt")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("antigravity project discovery", () => {
|
||||
test("reads a string or object project id", () => {
|
||||
expect(readProjectId({ cloudaicompanionProject: "proj-a" })).toBe("proj-a");
|
||||
expect(readProjectId({ cloudaicompanionProject: { id: "proj-b" } })).toBe("proj-b");
|
||||
});
|
||||
|
||||
test("returns null when no project is present", () => {
|
||||
expect(readProjectId({})).toBeNull();
|
||||
expect(readProjectId({ cloudaicompanionProject: {} })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pkce generation", () => {
|
||||
test("produces an S256 challenge for the verifier", () => {
|
||||
const { verifier, challenge } = generatePkce();
|
||||
expect(verifier).not.toBe("");
|
||||
expect(verifier).not.toBe(challenge);
|
||||
expect(challenge).toBe(createHash("sha256").update(verifier).digest("base64url"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauth credential parsing", () => {
|
||||
test("accepts a raw token", () => {
|
||||
expect(parseAccessToken("ghu_raw")).toEqual({ type: "access_token", accessToken: "ghu_raw" });
|
||||
expect(parseAccountToken("chatgpt_raw")).toEqual({ type: "account_token", accessToken: "chatgpt_raw" });
|
||||
});
|
||||
|
||||
test("extracts the access token from native OAuth credential JSON", () => {
|
||||
const secret = JSON.stringify({ access: "live_token", refresh: "r", expires: 123 });
|
||||
expect(parseAccessToken(secret)).toEqual({ type: "access_token", accessToken: "live_token" });
|
||||
expect(parseAccountToken(secret)).toEqual({ type: "account_token", accessToken: "live_token" });
|
||||
});
|
||||
|
||||
test("rejects credential JSON without an access token", () => {
|
||||
expect(() => parseAccessToken(JSON.stringify({ refresh: "r" }))).toThrow("missing an access token");
|
||||
});
|
||||
});
|
||||
@@ -197,7 +197,7 @@ describe("provider service", () => {
|
||||
expect(launched).toMatchObject({
|
||||
providerId: copilotProviderId,
|
||||
label: "GitHub Copilot",
|
||||
signInUrl: "https://github.com/login",
|
||||
signInUrl: "https://github.com/login/device",
|
||||
partition: `persist:agentcompany-provider-${copilotProviderId}`,
|
||||
});
|
||||
expect(state.providerAuthSessions[0]).toMatchObject({
|
||||
|
||||
Reference in new issue
Block a user