diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c34d55fa..3a7308b28f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ _In development — bullets added per PR; finalized at release._ ### ✨ New Features +- **feat(oauth): remote Antigravity login via local helper + paste-credentials** — Antigravity (and other Google "native/desktop" OAuth providers) use Google's `firstparty/nativeapp` consent, which only releases the auth code when the loopback redirect (`127.0.0.1:`) is reachable from the approving browser. On a remote VPS install that loopback lives on the server, so the consent hangs forever and never emits a code — the "paste the callback URL" fallback has nothing to paste (a Google-side constraint, identical in upstream 9router). A new `omniroute login antigravity` CLI helper runs the OAuth on the user's **own** machine (where 127.0.0.1 works), exchanges the code, and prints a single-line `omniroute-cred-v1.…` credential blob; the dashboard's Antigravity Connect → Step 2 field now accepts that blob (alongside callback URLs) and persists the connection via a new `paste-credentials` action (server-side onboarding, provider-allowlisted, with the blob's embedded provider required to match the route). The SSH local-forward tunnel is documented as a zero-tooling alternative. See [`docs/guides/REMOTE-MODE.md`](docs/guides/REMOTE-MODE.md). ([#5203](https://github.com/diegosouzapw/OmniRoute/pull/5203)) - **feat(agent-bridge): graceful cert-install fallback for containers / headless** — when the MITM root CA can't be installed into the system trust store automatically (Docker / headless / no sudo / read-only trust store), the Agent Bridge no longer hard-fails on start with a generic "Certificate install failed". It now starts in skip mode and the dashboard surfaces a platform-specific **manual-install guide** (plus a CA download link) so the operator can trust the certificate by hand. The trust-cert endpoints return a structured `{ skippable, manualGuide }` response (HTTP 200) for environment failures instead of a 500; an explicit user cancellation is still reported distinctly. ([#4546](https://github.com/diegosouzapw/OmniRoute/issues/4546) — thanks @phuchptty) ### 🔧 Bug Fixes diff --git a/bin/cli/commands/login.mjs b/bin/cli/commands/login.mjs new file mode 100644 index 0000000000..506f4e28f9 --- /dev/null +++ b/bin/cli/commands/login.mjs @@ -0,0 +1,192 @@ +import { createServer } from "node:http"; +import { randomUUID } from "node:crypto"; + +/** + * `omniroute login antigravity` — local OAuth helper for remote installs. + * + * Why this exists: Google's `firstparty/nativeapp` consent for the embedded + * Antigravity desktop client only releases the authorization code when the + * loopback redirect (127.0.0.1:) is REACHABLE. On a remote VPS install the + * loopback is unreachable, so the consent hangs forever and never emits a code — + * the dashboard's "paste the callback URL" fallback has nothing to paste. (The + * same flow works locally and over an SSH tunnel, where the loopback IS reachable.) + * + * This command runs the OAuth on the user's OWN machine — where 127.0.0.1 works — + * captures the code on a local loopback server, exchanges it for tokens, and + * prints a single-line credential blob. The user pastes that blob into the remote + * dashboard (Antigravity → "Paste credentials"), which decodes it, finalizes the + * onboarding server-side, and persists the connection. + * + * It talks ONLY to Google (no OmniRoute server needed locally), so it works even + * if the remote VPS is firewalled from the user's machine. + */ + +const PROVIDER = "antigravity"; + +/** Open the system browser; no-op if the optional `open` dependency is missing. */ +async function defaultOpenBrowser(url) { + try { + const { default: open } = await import("open"); + await open(url); + } catch { + // `open` not available — the caller already printed the URL to paste manually. + } +} + +/** + * Start a loopback HTTP server bound to 127.0.0.1 (NOT 0.0.0.0 — we never want to + * expose the callback to the LAN). Resolves to { port, waitForCallback, close }. + */ +function defaultStartServer(preferredPort) { + return new Promise((resolve, reject) => { + let resolveCallback; + const callbackPromise = new Promise((r) => { + resolveCallback = r; + }); + + const server = createServer((req, res) => { + const url = new URL(req.url, "http://127.0.0.1"); + if (url.pathname !== "/callback" && url.pathname !== "/auth/callback") { + res.writeHead(404).end(); + return; + } + const params = Object.fromEntries(url.searchParams.entries()); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end( + "OmniRoute" + + "" + + "

✅ Authorization received

" + + "

Return to your terminal — you can close this tab.

" + ); + resolveCallback(params); + }); + + server.on("error", reject); + server.listen(preferredPort || 0, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ + port, + waitForCallback: () => callbackPromise, + close: () => new Promise((r) => server.close(() => r())), + }); + }); + }); +} + +/** Lazy-load the antigravity provider + blob codec (TS source via tsx). */ +async function loadDeps() { + const { antigravity } = await import("../../../src/lib/oauth/providers/antigravity.ts"); + const { encodeCredentialBlob } = await import("../../../src/lib/oauth/credentialBlob.ts"); + return { antigravity, encodeCredentialBlob }; +} + +/** + * Build the Google authorization request for a given loopback port. Uses a plain + * authorization_code grant (NO PKCE code_challenge) — matching the working flow: + * a code_challenge here would force the exchange to require a code_verifier. + */ +export async function buildAntigravityAuthRequest(port, makeState = randomUUID) { + const { antigravity } = await loadDeps(); + const redirectUri = `http://127.0.0.1:${port}/callback`; + const state = makeState(); + const authUrl = antigravity.buildAuthUrl(antigravity.config, redirectUri, state); + return { redirectUri, state, authUrl }; +} + +/** Exchange the captured code for raw Google tokens (no code_verifier — no PKCE). */ +export async function exchangeAntigravityCode(code, redirectUri) { + const { antigravity } = await loadDeps(); + return antigravity.exchangeToken(antigravity.config, code, redirectUri); +} + +/** + * Orchestrate the local login. Dependencies are injectable for testing; the real + * path uses a 127.0.0.1 loopback server, the system browser, and a live token + * exchange against Google. Returns the credential blob string. + */ +export async function runAntigravityLogin(opts = {}, deps = {}) { + const startServer = deps.startServer ?? defaultStartServer; + const openBrowser = deps.openBrowser ?? defaultOpenBrowser; + const exchange = deps.exchange ?? exchangeAntigravityCode; + const makeState = deps.makeState ?? randomUUID; + const print = deps.print ?? ((s) => process.stdout.write(s)); + const log = deps.log ?? ((s) => process.stderr.write(s)); + const { encodeCredentialBlob } = await loadDeps(); + + const server = await startServer(opts.port); + const { redirectUri, state, authUrl } = await buildAntigravityAuthRequest(server.port, makeState); + + log(`\nOpen this URL to authorize Antigravity (it will open automatically):\n\n ${authUrl}\n\n`); + if (opts.browser !== false) await openBrowser(authUrl); + log("Waiting for Google to redirect back to the local loopback...\n"); + + const timeoutMs = opts.timeout ?? 300000; + let timer; + let params; + try { + params = await Promise.race([ + server.waitForCallback(), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("Timed out waiting for the OAuth callback")), + timeoutMs + ); + // Don't keep the event loop alive solely for this timer. + if (typeof timer.unref === "function") timer.unref(); + }), + ]); + } finally { + clearTimeout(timer); + await server.close(); + } + + if (params.error) { + throw new Error(`Authorization failed: ${params.error_description || params.error}`); + } + if (params.state !== state) { + throw new Error("State mismatch — aborting (possible CSRF). Please retry the login."); + } + if (!params.code) { + throw new Error("No authorization code returned by Google."); + } + + const tokens = await exchange(params.code, redirectUri); + const blob = encodeCredentialBlob({ provider: PROVIDER, tokens }); + + print( + "\n" + + "Antigravity authorized. Copy the line below and paste it into your remote\n" + + "OmniRoute dashboard: Providers → Antigravity → Connect → \"Paste credentials\".\n" + + "(This contains a refresh token — treat it like a password.)\n\n" + + blob + + "\n\n" + ); + return blob; +} + +async function runLoginAntigravity(opts) { + try { + await runAntigravityLogin({ + browser: opts.browser, + timeout: opts.timeout, + port: opts.port, + }); + } catch (err) { + process.stderr.write(`\nLogin failed: ${err?.message || err}\n`); + process.exit(1); + } +} + +export function registerLogin(program) { + const login = program + .command("login") + .description("Local OAuth helpers for remote OmniRoute installs (run on your own machine)"); + + login + .command("antigravity") + .description("Authorize Antigravity locally and print a credential blob to paste remotely") + .option("--no-browser", "Do not auto-open the browser; print the URL instead") + .option("--port ", "Fixed loopback port (default: OS-assigned)", (v) => parseInt(v, 10)) + .option("--timeout ", "How long to wait for the callback", (v) => parseInt(v, 10), 300000) + .action(runLoginAntigravity); +} diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index d8292d5f76..c06827219b 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -2,6 +2,7 @@ import { registerMemory } from "./memory.mjs"; import { registerSkills } from "./skills.mjs"; import { registerAudit } from "./audit.mjs"; import { registerOAuth } from "./oauth.mjs"; +import { registerLogin } from "./login.mjs"; import { registerCloud } from "./cloud.mjs"; import { registerEval } from "./eval.mjs"; import { registerWebhooks } from "./webhooks.mjs"; @@ -83,6 +84,7 @@ export function registerCommands(program) { registerSkills(program); registerAudit(program); registerOAuth(program); + registerLogin(program); registerCloud(program); registerEval(program); registerWebhooks(program); diff --git a/docs/guides/REMOTE-MODE.md b/docs/guides/REMOTE-MODE.md index 63f0bb952e..222c2a89d5 100644 --- a/docs/guides/REMOTE-MODE.md +++ b/docs/guides/REMOTE-MODE.md @@ -100,6 +100,68 @@ A token with insufficient scope gets `403` with a clear message. --- +## Connecting Antigravity on a remote install + +Antigravity (and other Google "native/desktop" OAuth providers such as +`gemini-cli`) use Google's `firstparty/nativeapp` consent screen. Google only +releases the authorization code when the **loopback redirect** +(`http://127.0.0.1:/callback`) is **reachable from the browser that +approves the sign-in**. On a remote VPS install that loopback lives on the +server, not on your machine, so the consent screen **hangs forever and never +emits a code** — the normal "paste the callback URL" fallback has nothing to +paste. (This is a Google-side constraint: the same hang happens in any proxy +that uses the bundled Antigravity desktop client, not just OmniRoute.) + +There are two supported ways to connect Antigravity to a remote OmniRoute. + +### Option A — local login helper (recommended) + +Run the OAuth on **your own computer**, where `127.0.0.1` is reachable, and paste +the result into the remote dashboard. The helper talks only to Google — it does +**not** need network access to your VPS, so it works even behind firewalls. + +```bash +# On your LOCAL machine (needs Node.js + a browser): +npx omniroute login antigravity +# ↳ opens the Google consent in your browser, captures the callback on a local +# loopback port, exchanges it, and prints a one-line credential blob: +# +# omniroute-cred-v1.eyJ2IjoxLCJ... +``` + +Then, in the **remote** dashboard: **Providers → Antigravity → Connect**, and +paste the `omniroute-cred-v1.…` blob into the **Step 2** field (it accepts either +a callback URL or a credential blob). OmniRoute decodes it, runs the Cloud Code +onboarding server-side, and persists the connection. + +> The blob contains a refresh token — treat it like a password. It is sent once +> over your dashboard connection and stored encrypted at rest. + +Flags: `--no-browser` (print the URL instead of auto-opening), `--port ` +(pin the loopback port), `--timeout `. + +### Option B — SSH local-forward tunnel + +If you have SSH access to the VPS, forward the dashboard port so that the +loopback callback resolves back to the server through the tunnel: + +```bash +# On your LOCAL machine: +ssh -L 20128:localhost:20128 user@your-vps +# then open http://localhost:20128 in your LOCAL browser and connect Antigravity +# normally — the 127.0.0.1:20128/callback redirect now reaches the VPS via SSH. +``` + +Because you reach the dashboard as `localhost:20128`, the Google consent +completes and the callback is delivered to the server through the same tunnel — +no blob needed. Keep the tunnel open until the connection shows as active. + +> A fully headless alternative (no helper, no tunnel) is to configure your **own** +> Google OAuth web credentials + a public base URL; see the provider's OAuth +> environment variables. The two options above need no extra Google setup. + +--- + ## Managing tokens ```bash diff --git a/src/app/api/oauth/[provider]/paste-credentials/route.ts b/src/app/api/oauth/[provider]/paste-credentials/route.ts new file mode 100644 index 0000000000..026d6f3ac3 --- /dev/null +++ b/src/app/api/oauth/[provider]/paste-credentials/route.ts @@ -0,0 +1,90 @@ +import { NextResponse } from "next/server"; +import { finalizeTokens } from "@/lib/oauth/providers"; +import { persistOAuthConnection } from "@/lib/oauth/connectionPersistence"; +import { parsePastedCredentials } from "@/lib/oauth/pasteCredentials"; +import { oauthPasteCredentialsSchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +/** + * POST /api/oauth/[provider]/paste-credentials + * + * Persist credentials produced by the local remote-login helper + * (`omniroute login antigravity`). Google's `firstparty/nativeapp` consent only + * releases the auth code when the loopback redirect is reachable, which never + * happens on a remote VPS — so the helper runs the OAuth on the user's own + * machine and prints a single-line credential blob. The dashboard pastes that + * blob here; we decode + validate it (provider allowlist + match), finalize the + * tokens (the Cloud Code onboarding runs here on the server, which CAN reach + * Google's APIs), and persist the connection. Same finalize path as the + * `device-complete` action. See src/lib/oauth/credentialBlob.ts. + * + * This lives in its own static route segment (not the dynamic `[action]` route) + * so Next.js routes `/paste-credentials` here; static segments win over `[action]`. + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ provider: string }> } +) { + // Creating a connection is owner-only — gate behind dashboard auth. + if ((await isAuthRequired(request)) && !(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { provider } = await params; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const validation = validateBody(oauthPasteCredentialsSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const { blob, connectionId } = validation.data; + + // Decode + gate (allowlist + blob-provider must match the route provider). + let pasted; + try { + pasted = parsePastedCredentials(provider, blob); + } catch (gateErr: any) { + return NextResponse.json( + { success: false, error: sanitizeErrorMessage(gateErr?.message) || "Invalid credentials" }, + { status: 400 } + ); + } + + let tokenData: any; + try { + tokenData = await finalizeTokens(provider, pasted.tokens); + } catch (finalizeErr: any) { + return NextResponse.json( + { + success: false, + error: sanitizeErrorMessage(finalizeErr?.message) || "Failed to finalize tokens", + }, + { status: 500 } + ); + } + + const connection = await persistOAuthConnection(provider, tokenData, connectionId); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + displayName: connection.displayName, + }, + }); + } catch (error) { + console.error("OAuth paste-credentials error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index d8cd7c401d..d8a48c8a31 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -7749,7 +7749,7 @@ "deviceCodeVerificationUrl": "Verification URL", "deviceCodeYourCode": "Your code", "deviceCodeWaiting": "Waiting for authorization...", - "googleOAuthWarning": "Remote access + Google OAuth: bundled credentials only accept loopback redirects like 127.0.0.1. The browser that approves Google must be able to reach OmniRoute on that local port, usually by opening OmniRoute locally or using an SSH/local-forward tunnel. For fully remote use without this local callback, configure your own OAuth credentials.", + "googleOAuthWarning": "Remote access + Google OAuth: bundled credentials only accept loopback redirects like 127.0.0.1. The browser that approves Google must be able to reach OmniRoute on that local port, usually by opening OmniRoute locally or using an SSH/local-forward tunnel. Recommended for remote installs: on your own computer run npx omniroute login antigravity and paste the credential blob it prints into the field below. For fully remote use without this local callback, configure your own OAuth credentials.", "remoteAccessInfo": "Remote access: Since you're accessing OmniRoute remotely, after authorization you'll see an error page (localhost not found). This is normal — just copy the full URL from your browser address bar and paste it below.", "step1OpenUrl": "Step 1: Open this URL in your browser", "copy": "Copy", diff --git a/src/lib/oauth/credentialBlob.ts b/src/lib/oauth/credentialBlob.ts new file mode 100644 index 0000000000..a747c7c400 --- /dev/null +++ b/src/lib/oauth/credentialBlob.ts @@ -0,0 +1,105 @@ +/** + * Paste-safe credential blob codec for the remote OAuth login helper. + * + * Why this exists: Google's `firstparty/nativeapp` consent for embedded desktop + * clients (Antigravity, gemini-cli) only releases the authorization code when the + * loopback redirect (127.0.0.1:) is reachable. On a remote VPS install the + * loopback is unreachable, so the consent hangs and never emits a code — there is + * nothing for the user to paste back. (The same flow works locally and over an SSH + * tunnel because then the loopback IS reachable.) + * + * The workaround: a local helper (`omniroute login antigravity`) runs the OAuth on + * the user's own machine (loopback reachable → consent completes → tokens), then + * encodes the raw token response into a single-line blob with this codec. The user + * pastes the blob into the remote dashboard, which decodes it and persists the + * connection (running the provider's post-exchange/onboarding from the server, + * which CAN reach Google's Cloud Code APIs). + * + * Format: `` — a recognizable prefix so humans and the + * decoder can identify it, followed by a URL/shell-safe base64url payload (no + * `+`, `/`, `=`, or whitespace) so it survives copy-paste through terminals. + */ + +/** Human-recognizable, copy-paste-safe prefix. The decoder requires it. */ +export const CREDENTIAL_BLOB_PREFIX = "omniroute-cred-v1."; + +/** Current blob schema version (embedded in the payload as `v`). */ +const CREDENTIAL_BLOB_VERSION = 1; + +export interface CredentialBlobTokens { + access_token?: string; + refresh_token?: string; + id_token?: string; + expires_in?: number; + scope?: string; + [key: string]: unknown; +} + +export interface CredentialBlob { + provider: string; + tokens: CredentialBlobTokens; +} + +/** + * Encode a provider + raw OAuth token response into a single-line blob. + * Throws if `provider` is missing — a blob with no provider cannot be routed. + */ +export function encodeCredentialBlob(input: CredentialBlob): string { + if (!input || typeof input.provider !== "string" || !input.provider.trim()) { + throw new Error("encodeCredentialBlob: a non-empty provider is required"); + } + if (!input.tokens || typeof input.tokens !== "object") { + throw new Error("encodeCredentialBlob: tokens object is required"); + } + const payload = { + v: CREDENTIAL_BLOB_VERSION, + provider: input.provider.trim(), + tokens: input.tokens, + }; + const json = JSON.stringify(payload); + const b64 = Buffer.from(json, "utf8").toString("base64url"); + return `${CREDENTIAL_BLOB_PREFIX}${b64}`; +} + +/** + * Decode a credential blob produced by {@link encodeCredentialBlob}. + * Validates the prefix, version, JSON shape, and the presence of an access_token. + * Throws a descriptive error on any malformed/tampered/unsupported input. + */ +export function decodeCredentialBlob(blob: string): CredentialBlob { + if (typeof blob !== "string" || !blob.startsWith(CREDENTIAL_BLOB_PREFIX)) { + throw new Error( + `decodeCredentialBlob: invalid format — must start with "${CREDENTIAL_BLOB_PREFIX}"` + ); + } + const b64 = blob.slice(CREDENTIAL_BLOB_PREFIX.length).trim(); + if (!/^[A-Za-z0-9_-]+$/.test(b64)) { + throw new Error("decodeCredentialBlob: invalid payload — not base64url"); + } + + let parsed: { v?: unknown; provider?: unknown; tokens?: unknown }; + try { + const json = Buffer.from(b64, "base64url").toString("utf8"); + parsed = JSON.parse(json); + } catch { + throw new Error("decodeCredentialBlob: invalid payload — could not parse JSON"); + } + + if (parsed.v !== CREDENTIAL_BLOB_VERSION) { + throw new Error( + `decodeCredentialBlob: unsupported blob version ${String(parsed.v)} (expected ${CREDENTIAL_BLOB_VERSION})` + ); + } + if (typeof parsed.provider !== "string" || !parsed.provider.trim()) { + throw new Error("decodeCredentialBlob: invalid payload — missing provider"); + } + const tokens = parsed.tokens as CredentialBlobTokens | undefined; + if (!tokens || typeof tokens !== "object") { + throw new Error("decodeCredentialBlob: invalid payload — missing tokens"); + } + if (typeof tokens.access_token !== "string" || !tokens.access_token) { + throw new Error("decodeCredentialBlob: invalid payload — missing access_token"); + } + + return { provider: parsed.provider.trim(), tokens }; +} diff --git a/src/lib/oauth/pasteCredentials.ts b/src/lib/oauth/pasteCredentials.ts new file mode 100644 index 0000000000..611a7886dd --- /dev/null +++ b/src/lib/oauth/pasteCredentials.ts @@ -0,0 +1,51 @@ +/** + * Server-side gate for the remote login helper's "paste credentials" flow. + * + * Google's `firstparty/nativeapp` consent for embedded desktop clients only + * releases the authorization code when the loopback redirect is reachable, which + * never happens on a remote VPS install. The remote login helper runs the OAuth + * locally and emits a credential blob (see ./credentialBlob.ts); the dashboard + * POSTs that blob to /api/oauth//paste-credentials, which decodes it + * and persists the connection via the same finalize path as `device-complete`. + * + * This module holds the pure, security-relevant gate: which providers may use + * the paste path, and the requirement that the blob's embedded provider matches + * the route provider (so a blob minted for one provider cannot be replayed + * against another). + */ + +import { decodeCredentialBlob, type CredentialBlob } from "./credentialBlob"; + +/** + * Providers eligible for the paste-credentials flow: Google native-loopback + * clients whose consent cannot complete on a headless/remote host. `agy` is the + * Antigravity alias. Codex is intentionally excluded — it has its own browser + * device flow (`device-complete`) that works remotely. + */ +export const PASTE_CREDENTIAL_PROVIDERS = new Set(["antigravity", "agy", "gemini-cli"]); + +/** + * Validate + decode a pasted credential blob for a given route provider. + * Throws a descriptive error if the provider is not allowlisted, if the blob's + * embedded provider does not match, or if the blob itself is malformed. + */ +export function parsePastedCredentials(routeProvider: string, blob: string): CredentialBlob { + if (!PASTE_CREDENTIAL_PROVIDERS.has(routeProvider)) { + throw new Error( + `paste-credentials not supported for provider: ${routeProvider}. ` + + `Supported: ${[...PASTE_CREDENTIAL_PROVIDERS].join(", ")}` + ); + } + + // decodeCredentialBlob validates prefix/version/JSON shape + access_token. + const decoded = decodeCredentialBlob(blob); + + if (decoded.provider !== routeProvider) { + throw new Error( + `Pasted credential provider mismatch: blob is for "${decoded.provider}" ` + + `but the route provider is "${routeProvider}"` + ); + } + + return decoded; +} diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 455938aaa8..21335de90a 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -7,6 +7,7 @@ import Button from "./Button"; import Input from "./Input"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { parseResponseBody, getErrorMessage } from "@/shared/utils/api"; +import { isCredentialBlob, submitCredentialBlob } from "@/shared/components/oauthBlobSubmit"; const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "agy", "gemini-cli"]); @@ -653,7 +654,10 @@ export default function OAuthModal({ const handleManualSubmit = async () => { try { setError(null); - + if (isCredentialBlob(callbackUrl)) { + await submitCredentialBlob(provider, callbackUrl, reauthConnection, setStep, onSuccess); + return; + } if (!authData) { throw new Error( "OAuth session not initialized. Restart the connection flow and try again." @@ -908,7 +912,7 @@ export default function OAuthModal({ diff --git a/src/shared/components/oauthBlobSubmit.ts b/src/shared/components/oauthBlobSubmit.ts new file mode 100644 index 0000000000..ba975337dd --- /dev/null +++ b/src/shared/components/oauthBlobSubmit.ts @@ -0,0 +1,42 @@ +import { parseResponseBody, getErrorMessage } from "@/shared/utils/api"; +import { CREDENTIAL_BLOB_PREFIX } from "@/lib/oauth/credentialBlob"; + +/** + * Helpers for the remote-login "paste credentials" path in OAuthModal. + * + * Google's native-loopback consent can't complete on a remote install, so the + * user runs `omniroute login antigravity` locally and pastes the credential blob + * it prints into the modal's Step 2 field. Extracted from OAuthModal to keep that + * (god-file) component within its frozen size budget. + */ + +/** True if the pasted text is a credential blob (vs a callback URL / auth code). */ +export function isCredentialBlob(value: string): boolean { + return typeof value === "string" && value.trim().startsWith(CREDENTIAL_BLOB_PREFIX); +} + +/** + * POST a pasted credential blob to the paste-credentials endpoint. On success it + * advances the modal to the success step and fires onSuccess; on failure it + * THROWS so the caller's existing try/catch surfaces the error (keeps the modal + * call site to two lines). Decoding + finalize + persist happen server-side. + */ +export async function submitCredentialBlob( + provider: string, + blob: string, + reauthConnection: { id?: string } | null | undefined, + setStep: (s: string) => void, + onSuccess?: () => void +): Promise { + const res = await fetch(`/api/oauth/${provider}/paste-credentials`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ blob: blob.trim(), connectionId: reauthConnection?.id }), + }); + const data = (await parseResponseBody(res)) as Record; + if (!res.ok) { + throw new Error(getErrorMessage(data, res.status, "Failed to import credentials")); + } + setStep("success"); + onSuccess?.(); +} diff --git a/src/shared/validation/schemas/auth.ts b/src/shared/validation/schemas/auth.ts index 69a83b2398..626db61731 100644 --- a/src/shared/validation/schemas/auth.ts +++ b/src/shared/validation/schemas/auth.ts @@ -185,6 +185,19 @@ export const oauthDeviceCompleteSchema = z.object({ connectionId: z.string().optional(), }); +/** + * Persist credentials obtained by the local remote-login helper. Google's + * `firstparty/nativeapp` consent only releases the auth code when the loopback + * redirect is reachable, which never happens on a remote VPS install — so the + * helper (`omniroute login antigravity`) runs the OAuth on the user's own machine + * and emits a single-line credential blob. The dashboard pastes that blob here; + * the server decodes + finalizes + persists. See src/lib/oauth/credentialBlob.ts. + */ +export const oauthPasteCredentialsSchema = z.object({ + blob: z.string().trim().min(1, "credential blob is required"), + connectionId: z.string().optional(), +}); + export const cursorImportSchema = z.object({ accessToken: z.string().trim().min(1, "Access token is required"), machineId: z.string().trim().optional(), diff --git a/tests/unit/cli-login-antigravity.test.ts b/tests/unit/cli-login-antigravity.test.ts new file mode 100644 index 0000000000..0f9182e336 --- /dev/null +++ b/tests/unit/cli-login-antigravity.test.ts @@ -0,0 +1,107 @@ +// Tests for the `omniroute login antigravity` local OAuth helper. +// +// The helper runs the OAuth on the user's own machine (where the Google +// native-loopback consent can complete) and prints a credential blob to paste +// into a remote install. We test the two pieces that, if wrong, silently break +// the flow: the authorization request (must be a plain authorization_code grant +// with a 127.0.0.1 loopback redirect and NO PKCE challenge) and the end-to-end +// orchestration (state validation + exchange + blob emission), with the browser, +// loopback server, and token exchange injected as fakes. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + buildAntigravityAuthRequest, + runAntigravityLogin, +} from "../../bin/cli/commands/login.mjs"; +import { decodeCredentialBlob } from "../../src/lib/oauth/credentialBlob.ts"; + +test("buildAntigravityAuthRequest: loopback redirect on 127.0.0.1 + no PKCE", async () => { + const { authUrl, redirectUri, state } = await buildAntigravityAuthRequest(54321, () => "fixed"); + assert.equal(redirectUri, "http://127.0.0.1:54321/callback"); + assert.equal(state, "fixed"); + + const url = new URL(authUrl); + assert.equal(url.origin, "https://accounts.google.com"); + assert.equal(url.searchParams.get("redirect_uri"), redirectUri); + assert.equal(url.searchParams.get("response_type"), "code"); + assert.equal(url.searchParams.get("state"), "fixed"); + assert.equal(url.searchParams.get("code_challenge"), null, "must NOT carry a PKCE challenge"); +}); + +test("runAntigravityLogin: validates state, exchanges code, prints a decodable blob", async () => { + let exchangedCode = null; + let exchangedRedirect = null; + + const blob = await runAntigravityLogin( + {}, + { + makeState: () => "S", + openBrowser: async () => {}, + startServer: async () => ({ + port: 54321, + waitForCallback: async () => ({ code: "the-code", state: "S" }), + close: async () => {}, + }), + exchange: async (code, redirectUri) => { + exchangedCode = code; + exchangedRedirect = redirectUri; + return { access_token: "ya29.a", refresh_token: "1//r", expires_in: 3600, scope: "x" }; + }, + print: () => {}, + log: () => {}, + } + ); + + assert.equal(exchangedCode, "the-code"); + assert.equal(exchangedRedirect, "http://127.0.0.1:54321/callback"); + + const decoded = decodeCredentialBlob(blob); + assert.equal(decoded.provider, "antigravity"); + assert.equal(decoded.tokens.access_token, "ya29.a"); + assert.equal(decoded.tokens.refresh_token, "1//r"); +}); + +test("runAntigravityLogin: rejects a state mismatch (CSRF guard)", async () => { + await assert.rejects( + () => + runAntigravityLogin( + {}, + { + makeState: () => "expected", + openBrowser: async () => {}, + startServer: async () => ({ + port: 1, + waitForCallback: async () => ({ code: "c", state: "ATTACKER" }), + close: async () => {}, + }), + exchange: async () => ({ access_token: "x" }), + print: () => {}, + log: () => {}, + } + ), + /state mismatch|csrf/i + ); +}); + +test("runAntigravityLogin: surfaces an OAuth error param", async () => { + await assert.rejects( + () => + runAntigravityLogin( + {}, + { + makeState: () => "S", + openBrowser: async () => {}, + startServer: async () => ({ + port: 1, + waitForCallback: async () => ({ error: "access_denied", state: "S" }), + close: async () => {}, + }), + exchange: async () => ({ access_token: "x" }), + print: () => {}, + log: () => {}, + } + ), + /access_denied|authorization failed/i + ); +}); diff --git a/tests/unit/oauth-credential-blob.test.ts b/tests/unit/oauth-credential-blob.test.ts new file mode 100644 index 0000000000..28e8a3dbe7 --- /dev/null +++ b/tests/unit/oauth-credential-blob.test.ts @@ -0,0 +1,84 @@ +// Unit tests for the OAuth credential blob codec used by the remote login helper. +// +// Context (operator report 2026-06-27): Google's `firstparty/nativeapp` consent +// for the embedded Antigravity desktop client only releases the authorization +// code when the loopback redirect (127.0.0.1:) is reachable. On a remote +// VPS install the loopback is unreachable, so the consent hangs and never emits +// a code — the normal "paste the callback URL" fallback has nothing to paste. +// +// The fix is a local helper (`omniroute login antigravity`) that runs the OAuth +// on the user's own machine (loopback reachable → consent completes → tokens), +// then prints a single-line, paste-safe credential blob. The user pastes it into +// the remote dashboard, which decodes it and persists the connection. +// +// This codec is the contract between the helper (encoder) and the server/dashboard +// (decoder). These tests pin: roundtrip fidelity, the human-recognizable prefix, +// version + provider gating, and rejection of malformed/tampered input. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + CREDENTIAL_BLOB_PREFIX, + encodeCredentialBlob, + decodeCredentialBlob, +} from "../../src/lib/oauth/credentialBlob.ts"; + +const SAMPLE = { + provider: "antigravity", + tokens: { + access_token: "ya29.access", + refresh_token: "1//refresh", + id_token: "eyJ.id.token", + expires_in: 3599, + scope: "https://www.googleapis.com/auth/cloud-platform", + }, +}; + +test("encode → decode roundtrips the provider and tokens", () => { + const blob = encodeCredentialBlob(SAMPLE); + const decoded = decodeCredentialBlob(blob); + assert.equal(decoded.provider, "antigravity"); + assert.deepEqual(decoded.tokens, SAMPLE.tokens); +}); + +test("blob is a single paste-safe line with the recognizable prefix", () => { + const blob = encodeCredentialBlob(SAMPLE); + assert.ok(blob.startsWith(CREDENTIAL_BLOB_PREFIX), "must carry the omniroute prefix"); + assert.ok(!/\s/.test(blob), "must contain no whitespace (single line, copy-paste safe)"); + // base64url only after the prefix — no +/= that break in URLs / shells. + const payload = blob.slice(CREDENTIAL_BLOB_PREFIX.length); + assert.match(payload, /^[A-Za-z0-9_-]+$/, "payload must be base64url"); +}); + +test("decode rejects a blob without the prefix", () => { + const raw = Buffer.from(JSON.stringify({ v: 1, ...SAMPLE })).toString("base64url"); + assert.throws(() => decodeCredentialBlob(raw), /prefix|invalid|format/i); +}); + +test("decode rejects an unsupported version", () => { + // Hand-craft a v999 blob with the right prefix. + const payload = Buffer.from(JSON.stringify({ v: 999, ...SAMPLE })).toString("base64url"); + assert.throws(() => decodeCredentialBlob(`${CREDENTIAL_BLOB_PREFIX}${payload}`), /version/i); +}); + +test("decode rejects a blob missing an access_token", () => { + const bad = encodeCredentialBlob({ + provider: "antigravity", + tokens: { refresh_token: "only-refresh" }, + }); + assert.throws(() => decodeCredentialBlob(bad), /access_token|token/i); +}); + +test("decode rejects tampered base64 (not valid JSON)", () => { + assert.throws( + () => decodeCredentialBlob(`${CREDENTIAL_BLOB_PREFIX}not-valid-base64-json!!!`), + /invalid|parse|format/i + ); +}); + +test("encode requires a provider", () => { + assert.throws( + () => encodeCredentialBlob({ tokens: { access_token: "x" } } as never), + /provider/i + ); +}); diff --git a/tests/unit/oauth-paste-credentials-route.test.ts b/tests/unit/oauth-paste-credentials-route.test.ts new file mode 100644 index 0000000000..43793bf08d --- /dev/null +++ b/tests/unit/oauth-paste-credentials-route.test.ts @@ -0,0 +1,79 @@ +// Route-wiring tests for the paste-credentials OAuth action. +// +// These exercise the rejection paths only — they all return 400 BEFORE the +// route touches finalizeTokens (Google APIs) or persistOAuthConnection (DB), so +// no network is hit. The happy path's finalize+persist is the same IO as the +// already-covered `device-complete` action; the new, security-relevant logic +// (allowlist + provider match + blob validation) is unit-tested in +// oauth-paste-credentials.test.ts and re-asserted through the HTTP boundary here. +// +// Auth is disabled via settings (requireLogin:false) so we reach the action +// dispatch rather than a 401. DB handles are released in test.after (CLAUDE.md +// learning: unreleased SQLite handles hang node:test). + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-paste-creds-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const route = await import("../../src/app/api/oauth/[provider]/paste-credentials/route.ts"); +const { encodeCredentialBlob } = await import("../../src/lib/oauth/credentialBlob.ts"); + +const tokens = { access_token: "ya29.x", refresh_token: "1//r", expires_in: 3599 }; + +test.before(async () => { + await settingsDb.updateSettings({ requireLogin: false }); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +async function postPaste(provider: string, body: unknown) { + const request = new Request(`http://localhost:20128/api/oauth/${provider}/paste-credentials`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const response = await route.POST(request, { + params: Promise.resolve({ provider, action: "paste-credentials" }), + } as never); + return { status: response.status, body: await response.json() }; +} + +test("paste-credentials: non-allowlisted provider is rejected with 400", async () => { + const blob = encodeCredentialBlob({ provider: "openai", tokens }); + const { status, body } = await postPaste("openai", { blob }); + assert.equal(status, 400); + assert.match(body.error, /not supported|supported/i); +}); + +test("paste-credentials: malformed blob is rejected with 400", async () => { + const { status, body } = await postPaste("antigravity", { blob: "totally-not-a-blob" }); + assert.equal(status, 400); + assert.match(body.error, /invalid|format|prefix/i); +}); + +test("paste-credentials: provider mismatch (blob for antigravity, route agy) → 400", async () => { + const blob = encodeCredentialBlob({ provider: "antigravity", tokens }); + const { status, body } = await postPaste("agy", { blob }); + assert.equal(status, 400); + assert.match(body.error, /match|mismatch|provider/i); +}); + +test("paste-credentials: empty body fails schema validation with 400", async () => { + const { status } = await postPaste("antigravity", {}); + assert.equal(status, 400); +}); + +test("paste-credentials: error responses never leak a stack trace", async () => { + const { body } = await postPaste("antigravity", { blob: "totally-not-a-blob" }); + assert.ok(!String(body.error).includes("at /"), "must not leak a stack trace"); +}); diff --git a/tests/unit/oauth-paste-credentials.test.ts b/tests/unit/oauth-paste-credentials.test.ts new file mode 100644 index 0000000000..56d0e975f1 --- /dev/null +++ b/tests/unit/oauth-paste-credentials.test.ts @@ -0,0 +1,49 @@ +// Unit tests for the server-side paste-credentials gate. +// +// The remote login helper (`omniroute login antigravity`) prints a credential +// blob; the dashboard POSTs it to /api/oauth//paste-credentials. Before +// the server finalizes + persists the tokens it MUST validate that (a) the route +// provider is on the paste-credentials allowlist (only Google native-loopback +// providers, never arbitrary providers) and (b) the blob's embedded provider +// matches the route provider — otherwise a blob minted for one provider could be +// replayed against another. This pins that gate; the finalize/persist IO is the +// same path as the already-tested `device-complete` action. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { encodeCredentialBlob } from "../../src/lib/oauth/credentialBlob.ts"; +import { + PASTE_CREDENTIAL_PROVIDERS, + parsePastedCredentials, +} from "../../src/lib/oauth/pasteCredentials.ts"; + +const tokens = { access_token: "ya29.x", refresh_token: "1//r", expires_in: 3599 }; + +test("allowlist contains antigravity and its agy alias, not codex", () => { + assert.ok(PASTE_CREDENTIAL_PROVIDERS.has("antigravity")); + assert.ok(PASTE_CREDENTIAL_PROVIDERS.has("agy")); + assert.ok(!PASTE_CREDENTIAL_PROVIDERS.has("codex"), "codex uses its own device-complete path"); +}); + +test("accepts a matching antigravity blob and returns the tokens", () => { + const blob = encodeCredentialBlob({ provider: "antigravity", tokens }); + const result = parsePastedCredentials("antigravity", blob); + assert.equal(result.provider, "antigravity"); + assert.deepEqual(result.tokens, tokens); +}); + +test("rejects a provider not on the allowlist", () => { + const blob = encodeCredentialBlob({ provider: "openai", tokens }); + assert.throws(() => parsePastedCredentials("openai", blob), /not supported|allowlist|supported/i); +}); + +test("rejects a blob whose embedded provider does not match the route provider", () => { + // Blob minted for antigravity, replayed against the agy route → must reject. + const blob = encodeCredentialBlob({ provider: "antigravity", tokens }); + assert.throws(() => parsePastedCredentials("agy", blob), /match|mismatch|provider/i); +}); + +test("propagates codec validation errors (e.g. missing access_token)", () => { + const blob = encodeCredentialBlob({ provider: "antigravity", tokens: { refresh_token: "r" } }); + assert.throws(() => parsePastedCredentials("antigravity", blob), /access_token|token/i); +});