diff --git a/changelog.d/fixes/8845-oauth-web-client-type.md b/changelog.d/fixes/8845-oauth-web-client-type.md new file mode 100644 index 000000000000..a7e6f77536a5 --- /dev/null +++ b/changelog.d/fixes/8845-oauth-web-client-type.md @@ -0,0 +1 @@ +- **OAuth**: `ANTIGRAVITY_OAUTH_CLIENT_TYPE=web` lets a remote deployment use a Google Web application OAuth client, upgrading the loopback redirect to `OMNIROUTE_PUBLIC_BASE_URL` instead of leaving the operator to hand-edit the callback URL. Opt-in and requires custom credentials — unset or `desktop` keeps the existing loopback behaviour untouched diff --git a/src/lib/oauth/providers.ts b/src/lib/oauth/providers.ts index 3a0bb4a62745..a369ec154f39 100644 --- a/src/lib/oauth/providers.ts +++ b/src/lib/oauth/providers.ts @@ -47,6 +47,20 @@ function isLoopbackHostname(hostname: string): boolean { return /^(localhost|127\.0\.0\.1|\[::1\]|::1)$/i.test(hostname); } +function upgradeLoopbackToPublic(redirectUri: string, publicBaseUrl: string): string { + try { + const requested = new URL(redirectUri); + if (!isLoopbackHostname(requested.hostname)) { + return redirectUri; + } + const callbackPath = + requested.pathname && requested.pathname !== "/" ? requested.pathname : "/callback"; + return `${publicBaseUrl}${callbackPath}${requested.search}`; + } catch { + return redirectUri; + } +} + /** * Google providers default to loopback redirects so the embedded public * credentials keep working on out-of-the-box local installs. When operators @@ -68,21 +82,30 @@ export function resolveBrowserOAuthRedirectUri( } const publicBaseUrl = - normalizeBaseUrl(env.NEXT_PUBLIC_BASE_URL) || normalizeBaseUrl(env.OMNIROUTE_PUBLIC_BASE_URL); + normalizeBaseUrl(env?.NEXT_PUBLIC_BASE_URL) || normalizeBaseUrl(env?.OMNIROUTE_PUBLIC_BASE_URL); if (!publicBaseUrl) { return redirectUri; } + // Web application OAuth client type allows non-loopback redirect URIs. + // When the operator sets ANTIGRAVITY_OAUTH_CLIENT_TYPE=web with custom + // credentials, upgrade the loopback redirect so remote deployments work + // without SSH tunneling. Non-web client types fall through to the + // existing custom-credentials upgrade path below. + if (GOOGLE_BROWSER_PROVIDERS.has(providerName)) { + const clientType = (env?.ANTIGRAVITY_OAUTH_CLIENT_TYPE || "").toLowerCase().trim(); + if (clientType === "web") { + return upgradeLoopbackToPublic(redirectUri, publicBaseUrl); + } + } + try { const requested = new URL(redirectUri); if (!isLoopbackHostname(requested.hostname)) { return redirectUri; } - - const callbackPath = - requested.pathname && requested.pathname !== "/" ? requested.pathname : "/callback"; - return `${publicBaseUrl}${callbackPath}${requested.search}`; + return upgradeLoopbackToPublic(redirectUri, publicBaseUrl); } catch { return redirectUri; } diff --git a/tests/unit/oauth-redirect-uri-mismatch.test.ts b/tests/unit/oauth-redirect-uri-mismatch.test.ts index daa540860c77..27f034265a83 100644 --- a/tests/unit/oauth-redirect-uri-mismatch.test.ts +++ b/tests/unit/oauth-redirect-uri-mismatch.test.ts @@ -305,3 +305,112 @@ test("OMNIROUTE_PUBLIC_BASE_URL is used as fallback when NEXT_PUBLIC_BASE_URL is assert.equal(redirectUri, "https://fallback.example.com/callback"); }); + +// --------------------------------------------------------------------------- +// Web client type (ANTIGRAVITY_OAUTH_CLIENT_TYPE=web) - public redirect +// --------------------------------------------------------------------------- + +test("antigravity with client type 'web' and custom credentials switches loopback to public URL", () => { + const redirectUri = resolveBrowserOAuthRedirectUri( + "antigravity", + "http://127.0.0.1:20128/callback", + { + OMNIROUTE_PUBLIC_BASE_URL: "https://192.168.100.10:20128", + ANTIGRAVITY_OAUTH_CLIENT_TYPE: "web", + ANTIGRAVITY_OAUTH_CLIENT_ID: "custom-id.apps.googleusercontent.com", + ANTIGRAVITY_OAUTH_CLIENT_SECRET: "custom-secret", + } + ); + + assert.equal( + redirectUri, + "https://192.168.100.10:20128/callback", + "web client type must use public base URL" + ); +}); + +test("agy with client type 'web' and custom credentials switches loopback to public URL", () => { + const redirectUri = resolveBrowserOAuthRedirectUri("agy", "http://127.0.0.1:20128/callback", { + OMNIROUTE_PUBLIC_BASE_URL: "https://omniroute.example.com", + ANTIGRAVITY_OAUTH_CLIENT_TYPE: "web", + ANTIGRAVITY_OAUTH_CLIENT_ID: "custom-id.apps.googleusercontent.com", + ANTIGRAVITY_OAUTH_CLIENT_SECRET: "custom-secret", + }); + + assert.equal( + redirectUri, + "https://omniroute.example.com/callback", + "agy must inherit web client type from antigravity" + ); +}); + +test("client type 'web' without custom credentials keeps loopback", () => { + const redirectUri = resolveBrowserOAuthRedirectUri( + "antigravity", + "http://127.0.0.1:20128/callback", + { + OMNIROUTE_PUBLIC_BASE_URL: "https://omniroute.example.com", + ANTIGRAVITY_OAUTH_CLIENT_TYPE: "web", + ANTIGRAVITY_OAUTH_CLIENT_ID: DEFAULT_ANTIGRAVITY_CLIENT_ID, + ANTIGRAVITY_OAUTH_CLIENT_SECRET: "GOCSPX-SomeDefaultSecret", + } + ); + + assert.equal( + redirectUri, + "http://127.0.0.1:20128/callback", + "web client type with default credentials must keep loopback" + ); +}); + +test("client type 'desktop' (default) keeps loopback even with public base URL", () => { + const redirectUri = resolveBrowserOAuthRedirectUri( + "antigravity", + "http://127.0.0.1:20128/callback", + { + OMNIROUTE_PUBLIC_BASE_URL: "https://omniroute.example.com", + ANTIGRAVITY_OAUTH_CLIENT_TYPE: "desktop", + } + ); + + assert.equal( + redirectUri, + "http://127.0.0.1:20128/callback", + "desktop client type must keep loopback" + ); +}); + +test("no client type set defaults to desktop (keeps loopback)", () => { + const redirectUri = resolveBrowserOAuthRedirectUri( + "antigravity", + "http://127.0.0.1:20128/callback", + { + OMNIROUTE_PUBLIC_BASE_URL: "https://omniroute.example.com", + } + ); + + assert.equal( + redirectUri, + "http://127.0.0.1:20128/callback", + "missing client type must default to desktop" + ); +}); + +test("client type 'web' preserves custom callback path", () => { + const redirectUri = resolveBrowserOAuthRedirectUri( + "antigravity", + "http://127.0.0.1:20128/custom-callback", + { + OMNIROUTE_PUBLIC_BASE_URL: "https://omniroute.example.com", + ANTIGRAVITY_OAUTH_CLIENT_TYPE: "web", + ANTIGRAVITY_OAUTH_CLIENT_ID: "custom-id.apps.googleusercontent.com", + ANTIGRAVITY_OAUTH_CLIENT_SECRET: "custom-secret", + } + ); + + assert.equal( + redirectUri, + "https://omniroute.example.com/custom-callback", + "custom callback path must be preserved" + ); +});