fix github auth window

This commit is contained in:
Hydra
2026-09-04 17:58:28 +03:00
parent 1292fbe66e
commit 34b3d0e665
12 changed files with 496 additions and 233 deletions
@@ -1,7 +1,7 @@
"use client";
import { useEffect } from "react";
import { GITHUB_CONNECT_ERROR_KEY } from "@/lib/github-connect-error";
import { storeGitHubConnectError } from "@/lib/github-connect-error";
/**
* OAuth callback landing page - auto-closes the popup/window.
@@ -17,7 +17,7 @@ export default function OAuthCallbackClose() {
// connected". Close immediately in that case — no cookies to settle.
const linkError = new URLSearchParams(window.location.search).get("error");
if (linkError) {
try { localStorage.setItem(GITHUB_CONNECT_ERROR_KEY, linkError); } catch { /* storage unavailable */ }
storeGitHubConnectError(linkError);
}
// Give a brief moment for cookies to settle on success, then close.
const timer = setTimeout(() => window.close(), linkError ? 0 : 300);
@@ -1,14 +1,29 @@
"use client";
import { useEffect, useState } from "react";
import { getApiOrigin } from "@/lib/api/urls";
import { GITHUB_CONNECT_ERROR_KEY } from "@/lib/github-connect-error";
import { useEffect, useRef, useState } from "react";
import { getApiErrorMessage, githubApi } from "@/lib/api";
import { storeGitHubConnectError } from "@/lib/github-connect-error";
import { closeAuthWindowAfterSuccess } from "@/utils/authWindow";
/** GitHub App Setup URL landing page for an operator-owned self-hosted App. */
export default function GitHubAppSetupCallback() {
const [message, setMessage] = useState("Verifying the GitHub App installation…");
const claimStarted = useRef(false);
useEffect(() => {
// React's development Strict Mode replays effects. The installation nonce
// is intentionally one-shot, so never submit the same claim twice.
if (claimStarted.current) return;
claimStarted.current = true;
const fail = (detail: string) => {
storeGitHubConnectError(detail);
setMessage(detail);
// Leave enough time to read the local explanation; the opener also
// receives it from localStorage and shows the durable toast.
closeAuthWindowAfterSuccess(2200);
};
async function claim() {
const query = new URLSearchParams(window.location.search);
const flow = query.get("flow");
@@ -18,30 +33,13 @@ export default function GitHubAppSetupCallback() {
const setupAction = query.get("setup_action");
if (!state || (flow === "manifest" ? !code : !installationId)) {
setMessage(
"GitHub did not return the required installation details. Start again from Settings.",
);
fail("GitHub did not return the required installation details. Start again from Settings.");
return;
}
try {
const base = getApiOrigin(window.location.origin);
const endpoint =
flow === "manifest"
? `${base}/api/github/sources/manifest/convert`
: `${base}/api/github/installations/claim`;
const response = await fetch(endpoint, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify(
flow === "manifest" ? { code, state } : { installationId, state, setupAction },
),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data?.message || "Could not verify the installation.");
if (flow === "manifest") {
const data = await githubApi.convertSourceManifest(state, code!);
if (!data?.installUrl)
throw new Error("GitHub App was created, but its install URL is missing.");
setMessage("GitHub App created. Opening repository access…");
@@ -49,23 +47,23 @@ export default function GitHubAppSetupCallback() {
return;
}
const data = await githubApi.claimInstallation({
state,
installationId: installationId!,
setupAction: setupAction ?? undefined,
});
if (data?.pendingApproval) {
setMessage("Installation requested. A GitHub organization owner must approve it.");
closeAuthWindowAfterSuccess(2200);
return;
}
setMessage(
`${data?.installation?.login || "GitHub"} is connected. You can close this window.`,
);
window.setTimeout(() => window.close(), 1200);
closeAuthWindowAfterSuccess(1200);
} catch (error) {
const detail =
error instanceof Error ? error.message : "Could not verify the installation.";
try {
localStorage.setItem(GITHUB_CONNECT_ERROR_KEY, detail);
} catch {
/* unavailable */
}
setMessage(detail);
const detail = getApiErrorMessage(error, "Could not verify the installation.");
fail(detail);
}
}
void claim();
@@ -1,8 +1,10 @@
"use client";
import { useEffect } from "react";
import { getApiOrigin } from "@/lib/api/urls";
import { GITHUB_CONNECT_ERROR_KEY } from "@/lib/github-connect-error";
import { useEffect, useRef, useState } from "react";
import { githubApi, endpoints, getApiErrorMessage } from "@/lib/api";
import { resolveApiNavigationUrl } from "@/lib/api/urls";
import { storeGitHubConnectError } from "@/lib/github-connect-error";
import { closeAuthWindowAfterSuccess } from "@/utils/authWindow";
/**
* OAuth callback for cloud mode - after GitHub OAuth completes,
@@ -11,42 +13,55 @@ import { GITHUB_CONNECT_ERROR_KEY } from "@/lib/github-connect-error";
* Flow: GitHub OAuth → Better Auth callback → this page → GitHub App install
*/
export default function OAuthCallbackInstall() {
const [message, setMessage] = useState("Setting up GitHub access…");
const redirectStarted = useRef(false);
useEffect(() => {
// React's development Strict Mode replays effects. Starting this transition
// twice can mint competing installation states, so keep it one-shot.
if (redirectStarted.current) return;
redirectStarted.current = true;
// Better Auth appends ?error=<code> on a failed link (e.g. the GitHub
// account is already linked to a different user). Hand it to the opener
// via same-origin localStorage and close instead of proceeding to install.
const linkError = new URLSearchParams(window.location.search).get("error");
if (linkError) {
try { localStorage.setItem(GITHUB_CONNECT_ERROR_KEY, linkError); } catch { /* storage unavailable */ }
window.close();
storeGitHubConnectError(linkError);
closeAuthWindowAfterSuccess(0);
return;
}
async function redirect() {
try {
const BASE = getApiOrigin(window.location.origin);
const res = await fetch(`${BASE}/api/github/connect`, {
method: "POST",
credentials: "include",
});
const data = await res.json();
if (data?.url) {
window.location.href = data.url;
// Use the shared API client so self-hosted callback pages honor the
// same-origin `/api/proxy/api` mount instead of calling localhost:4000
// in the operator's browser.
const data = await githubApi.connect();
if (data?.flow === "redirect") {
window.location.href = resolveApiNavigationUrl(
typeof data.url === "string" ? data.url : endpoints.github.connectRedirect,
);
return;
}
} catch {
// If fetch fails, just close - the opener will detect it
if (!data?.connected) {
throw new Error("GitHub did not return an installation destination.");
}
closeAuthWindowAfterSuccess(300);
} catch (error) {
const detail = getApiErrorMessage(error, "Could not continue GitHub setup.");
storeGitHubConnectError(detail);
setMessage(detail);
closeAuthWindowAfterSuccess(1800);
}
window.close();
}
redirect();
void redirect();
}, []);
return (
<div className="flex h-screen items-center justify-center bg-background text-foreground">
<p className="text-sm text-muted-foreground">Setting up GitHub access…</p>
<p className="text-sm text-muted-foreground">{message}</p>
</div>
);
}
+178 -162
View File
@@ -1,27 +1,13 @@
"use client";
import React, {
createContext,
useContext,
useState,
useCallback,
useRef,
useEffect,
} from "react";
import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from "react";
import { githubApi } from "@/lib/api";
import { endpoints } from "@/lib/api/endpoints";
import {
getApiBaseUrl,
getApiErrorMessage,
isAbortError,
isNetworkError,
} from "@/lib/api/client";
import { getApiErrorMessage, isAbortError, isNetworkError } from "@/lib/api/client";
import { resolveApiNavigationUrl } from "@/lib/api/urls";
import { openAuthWindow } from "@/utils/authWindow";
import { useToast } from "@/context/ToastContext";
import {
GITHUB_CONNECT_ERROR_KEY,
githubConnectErrorMessage,
} from "@/lib/github-connect-error";
import { consumeGitHubConnectError, githubConnectErrorMessage } from "@/lib/github-connect-error";
/* ── Types ────────────────────────────────────────────────────────── */
@@ -175,7 +161,13 @@ export type CliAction =
* sending the operator to a shell they may not have. `command` is the
* secondary `gh auth login` hint for bare installs that do have gh. */
| { type: "token"; command: string; message: string }
| { type: "device_flow"; userCode: string; verificationUri: string; expiresIn: number; interval: number };
| {
type: "device_flow";
userCode: string;
verificationUri: string;
expiresIn: number;
interval: number;
};
const GitHubContext = createContext<GitHubContextValue | undefined>(undefined);
@@ -200,15 +192,18 @@ const EMPTY_STATE: GitHubConnectionState = {
primary: null,
};
// OAuth grants and installation nonces are already bounded server-side. This
// client deadline is a UX guard: a callback that cannot close its window must
// never leave every GitHub connect button disabled forever.
const GITHUB_REDIRECT_TIMEOUT_MS = 10 * 60 * 1000;
export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
// Note: setSelfHosted is no longer driven from this context — the
// global platform mode is owned by PlatformContext and read from
// env.CLOUD_MODE during the initial dashboard layout. We deliberately
// don't shadow it here.
const { showToast } = useToast();
const [state, setState] = useState<GitHubConnectionState>(
initialData?.state ?? EMPTY_STATE,
);
const [state, setState] = useState<GitHubConnectionState>(initialData?.state ?? EMPTY_STATE);
const [connecting, setConnecting] = useState(false);
const [loading, setLoading] = useState(!initialData);
@@ -234,6 +229,9 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
// (user-status, installations, install-url) on the API side, so
// dedup is load-bearing for the SaaS request rate.
const inflightRefresh = useRef<Promise<void> | null>(null);
// A state update does not synchronously disable every connect trigger. Guard
// the operation itself so a double click cannot mint two OAuth/install flows.
const connectInFlight = useRef(false);
// Convenience derived from state.primary — every existing call site
// that read `connected` keeps working.
@@ -243,62 +241,56 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
const refresh = useCallback(async () => {
if (inflightRefresh.current) return inflightRefresh.current;
const work = (async () => {
// refresh() runs after every connect / disconnect / device-flow completion,
// so drop the cached /github/status verdict here — the Settings card and
// library App badge will then re-probe the new connection state instead of
// serving the stale cached one (covers connect paths that don't go through
// the Settings card's own force-refresh).
githubApi.invalidateStatus();
try {
const res = await githubApi.getUserHome();
const nextState: GitHubConnectionState = res?.state ?? EMPTY_STATE;
setState(nextState);
// refresh() runs after every connect / disconnect / device-flow completion,
// so drop the cached /github/status verdict here — the Settings card and
// library App badge will then re-probe the new connection state instead of
// serving the stale cached one (covers connect paths that don't go through
// the Settings card's own force-refresh).
githubApi.invalidateStatus();
try {
const res = await githubApi.getUserHome();
const nextState: GitHubConnectionState = res?.state ?? EMPTY_STATE;
setState(nextState);
if (res?.installUrl) setInstallUrl(res.installUrl);
else setInstallUrl(null);
if (res?.capabilities) setCapabilities(res.capabilities as GitHubCapabilities);
if (res?.installUrl) setInstallUrl(res.installUrl);
else setInstallUrl(null);
if (res?.capabilities) setCapabilities(res.capabilities as GitHubCapabilities);
if (nextState.primary !== null) {
setCliAction(null);
setAccounts(res.accounts ?? []);
const primaryLogin =
nextState.sources.openshipApp.login ??
nextState.sources.ghCli.login ??
"";
setUserLogin(primaryLogin);
if (!selectedOwner && primaryLogin) {
setSelectedOwnerState(primaryLogin);
if (nextState.primary !== null) {
setCliAction(null);
setAccounts(res.accounts ?? []);
const primaryLogin =
nextState.sources.openshipApp.login ?? nextState.sources.ghCli.login ?? "";
setUserLogin(primaryLogin);
if (!selectedOwner && primaryLogin) {
setSelectedOwnerState(primaryLogin);
}
setRepos(res.repos ?? []);
} else {
setAccounts([]);
setRepos([]);
}
setRepos(res.repos ?? []);
} else {
setAccounts([]);
setRepos([]);
}
// Surface partial-failure diagnostics from the server. The request
// succeeded overall but one or more upstream fetches failed silently
// server-side — show them so the user has a clue why a section is
// empty (e.g. "App path failed: …" / "CLI repo merge failed: …").
if (res?.errors && typeof res.errors === "object") {
const entries = Object.entries(res.errors as Record<string, string>);
for (const [key, message] of entries) {
if (!message) continue;
showToast(`GitHub ${key}: ${message}`, "error", "GitHub");
// Surface partial-failure diagnostics from the server. The request
// succeeded overall but one or more upstream fetches failed silently
// server-side — show them so the user has a clue why a section is
// empty (e.g. "App path failed: …" / "CLI repo merge failed: …").
if (res?.errors && typeof res.errors === "object") {
const entries = Object.entries(res.errors as Record<string, string>);
for (const [key, message] of entries) {
if (!message) continue;
showToast(`GitHub ${key}: ${message}`, "error", "GitHub");
}
}
} catch (err) {
// Defer transient network/abort errors to the global NetworkErrorHandler;
// only surface ApiError-shaped failures here.
if (isAbortError(err) || isNetworkError(err)) return;
setState(EMPTY_STATE);
showToast(getApiErrorMessage(err, "Couldn't load GitHub data"), "error", "GitHub");
} finally {
setLoading(false);
}
} catch (err) {
// Defer transient network/abort errors to the global NetworkErrorHandler;
// only surface ApiError-shaped failures here.
if (isAbortError(err) || isNetworkError(err)) return;
setState(EMPTY_STATE);
showToast(
getApiErrorMessage(err, "Couldn't load GitHub data"),
"error",
"GitHub",
);
} finally {
setLoading(false);
}
})();
inflightRefresh.current = work;
try {
@@ -319,87 +311,127 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
}, [refresh, initialData]);
/* ── Connect GitHub ─────────────────────────────────────────── */
const connect = useCallback(async (source?: "oauth" | "cli") => {
setConnecting(true);
setCliAction(null);
const connect = useCallback(
async (source?: "oauth" | "cli") => {
if (connectInFlight.current) return;
connectInFlight.current = true;
const finishRedirectFlow = () => {
setConnecting(false);
// Surface a link failure the callback page stashed (e.g. the GitHub
// account is already linked to a different user) — otherwise the flow
// just silently reports "not connected".
try {
const linkError = localStorage.getItem(GITHUB_CONNECT_ERROR_KEY);
// Reserve the popup while this call still has a browser user gesture. The
// API decides the actual destination asynchronously; opening it afterwards
// is routinely blocked by popup protection. Explicit CLI/device flows do
// not navigate away, so they do not need a window.
let reservedWindow: ReturnType<typeof openAuthWindow> | null = null;
setConnecting(true);
setCliAction(null);
const finishConnect = () => {
connectInFlight.current = false;
setConnecting(false);
};
let redirectFinished = false;
let redirectTimeout: number | null = null;
const finishRedirectFlow = () => {
if (redirectFinished) return;
redirectFinished = true;
if (redirectTimeout !== null) window.clearTimeout(redirectTimeout);
finishConnect();
// Surface a link failure the callback page stashed (e.g. the GitHub
// account is already linked to a different user) — otherwise the flow
// just silently reports "not connected".
const linkError = consumeGitHubConnectError();
if (linkError) {
localStorage.removeItem(GITHUB_CONNECT_ERROR_KEY);
showToast(githubConnectErrorMessage(linkError), "error", "GitHub");
}
} catch { /* storage unavailable */ }
// One immediate + one short follow-up. The immediate call covers
// the happy path; the 1500ms follow-up covers the race where the
// popup closes before the SaaS-side cookie/DB write is visible.
// `refresh()` is in-flight-deduped so a re-entry coalesces.
void refresh();
window.setTimeout(() => void refresh(), 1500);
};
// One immediate + one short follow-up. The immediate call covers
// the happy path; the 1500ms follow-up covers the race where the
// popup closes before the SaaS-side cookie/DB write is visible.
// `refresh()` is in-flight-deduped so a re-entry coalesces.
void refresh();
window.setTimeout(() => void refresh(), 1500);
};
try {
const res = await githubApi.connect(source);
try {
reservedWindow = source === "cli" ? null : openAuthWindow();
const res = await githubApi.connect(source);
// Already connected - just refresh
if (res?.connected) {
setConnecting(false);
refresh();
return;
}
switch (res?.flow) {
case "redirect": {
// Prefer a backend-provided URL when the next step is known
// (for example, GitHub App installation after OAuth).
const redirectUrl = res.url ?? `${getApiBaseUrl()}${endpoints.github.connectRedirect}`;
const handle = openAuthWindow(redirectUrl);
handle.onClose(finishRedirectFlow);
// Already connected - just refresh
if (res?.connected) {
reservedWindow?.close();
finishConnect();
void refresh();
return;
}
case "device_code":
// Show verification code inline
setCliAction({
type: "device_flow",
userCode: res.userCode,
verificationUri: res.verificationUri,
expiresIn: res.expiresIn,
interval: res.interval,
});
setConnecting(false);
return;
switch (res?.flow) {
case "redirect": {
const handle = reservedWindow ?? openAuthWindow();
reservedWindow = handle;
if (handle.blocked) {
finishConnect();
showToast(
"Your browser blocked the GitHub sign-in window. Allow pop-ups and try again.",
"error",
"GitHub",
);
return;
}
case "token":
// Instance has no device client id — collect a token inline.
setCliAction({ type: "token", command: res.command, message: res.message });
setConnecting(false);
return;
// A backend URL may be absolute (github.com / cloud handoff) or an
// API-root path. Resolve the latter against the real API mount, not
// window.location — app.openship.io/api/... is a dashboard 404.
const redirectUrl = resolveApiNavigationUrl(
typeof res.url === "string" ? res.url : endpoints.github.connectRedirect,
);
handle.navigate(redirectUrl);
handle.onClose(finishRedirectFlow);
redirectTimeout = window.setTimeout(() => {
handle.close();
finishRedirectFlow();
showToast("GitHub connection timed out. Please try again.", "error", "GitHub");
}, GITHUB_REDIRECT_TIMEOUT_MS);
return;
}
case "terminal":
// Show terminal instruction
setCliAction({ type: "terminal", command: res.command, message: res.message });
setConnecting(false);
return;
case "device_code":
reservedWindow?.close();
// Show verification code inline
setCliAction({
type: "device_flow",
userCode: res.userCode,
verificationUri: res.verificationUri,
expiresIn: res.expiresIn,
interval: res.interval,
});
finishConnect();
return;
default:
setConnecting(false);
case "token":
reservedWindow?.close();
// Instance has no device client id — collect a token inline.
setCliAction({ type: "token", command: res.command, message: res.message });
finishConnect();
return;
case "terminal":
reservedWindow?.close();
// Show terminal instruction
setCliAction({ type: "terminal", command: res.command, message: res.message });
finishConnect();
return;
default:
reservedWindow?.close();
finishConnect();
}
} catch (err) {
reservedWindow?.close();
finishConnect();
if (isAbortError(err) || isNetworkError(err)) return;
showToast(getApiErrorMessage(err, "Failed to connect to GitHub"), "error", "GitHub");
}
} catch (err) {
setConnecting(false);
if (isAbortError(err) || isNetworkError(err)) return;
showToast(
getApiErrorMessage(err, "Failed to connect to GitHub"),
"error",
"GitHub",
);
}
}, [refresh, showToast]);
},
[refresh, showToast],
);
/* ── Connect with a pasted token ────────────────────────────── */
const connectWithToken = useCallback(
@@ -426,11 +458,7 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
await refresh();
} catch (err) {
if (isAbortError(err) || isNetworkError(err)) return;
showToast(
getApiErrorMessage(err, "Failed to disconnect from GitHub"),
"error",
"GitHub",
);
showToast(getApiErrorMessage(err, "Failed to disconnect from GitHub"), "error", "GitHub");
}
},
[refresh, showToast],
@@ -449,11 +477,7 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
refresh();
} else if (res?.status === "error") {
setCliAction(null);
showToast(
res?.message || res?.error || "GitHub device flow failed",
"error",
"GitHub",
);
showToast(res?.message || res?.error || "GitHub device flow failed", "error", "GitHub");
}
} catch (err) {
// Keep polling on transient failures. Only surface a non-network
@@ -461,11 +485,7 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
// instead of an interval that silently spins forever.
if (isAbortError(err) || isNetworkError(err)) return;
if (err instanceof Error && (err as any).status) {
showToast(
getApiErrorMessage(err, "GitHub device flow failed"),
"error",
"GitHub",
);
showToast(getApiErrorMessage(err, "GitHub device flow failed"), "error", "GitHub");
}
}
}, interval);
@@ -508,16 +528,12 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
setLoadingRepos(false);
return;
}
showToast(
getApiErrorMessage(err, "Couldn't load repositories"),
"error",
"GitHub",
);
showToast(getApiErrorMessage(err, "Couldn't load repositories"), "error", "GitHub");
} finally {
setLoadingRepos(false);
}
},
[connected, showToast]
[connected, showToast],
);
/* ── Owner change → fetch repos ─────────────────────────────── */
@@ -528,7 +544,7 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
fetchReposForOwner(owner);
}
},
[selectedOwner, fetchReposForOwner]
[selectedOwner, fetchReposForOwner],
);
return (
+1
View File
@@ -234,6 +234,7 @@ export const endpoints = {
connect: "github/connect",
connectRedirect: "github/connect/redirect",
connectPoll: "github/connect/poll",
installationClaim: "github/installations/claim",
disconnect: "github/disconnect",
instanceToken: "github/instance-token",
sources: "github/sources",
+8
View File
@@ -205,6 +205,14 @@ export const githubApi = {
/** Poll device flow status */
pollConnect: () => api.get<any>(endpoints.github.connectPoll),
/** Finalize a workspace-bound GitHub App installation callback. */
claimInstallation: (input: { state: string; installationId: string; setupAction?: string }) =>
api.post<{
ok: boolean;
pendingApproval?: boolean;
installation?: { login?: string };
}>(endpoints.github.installationClaim, input),
/**
* Disconnect a GitHub source.
* - "oauth" → remove the Openship App / OAuth account row
+43 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { alignLoopbackOrigin } from "./urls";
import { alignLoopbackOrigin, resolveApiNavigationUrl } from "./urls";
describe("alignLoopbackOrigin", () => {
it("rewrites a 127.0.0.1 API origin when the page is served from localhost", () => {
@@ -36,3 +36,45 @@ describe("alignLoopbackOrigin", () => {
expect(alignLoopbackOrigin("not a url", "http://localhost:3001")).toBe("not a url");
});
});
describe("resolveApiNavigationUrl", () => {
it("maps an API-root redirect through the dashboard proxy mount", () => {
expect(
resolveApiNavigationUrl(
"/api/github/connect/redirect?install_state=nonce",
"https://app.openship.io/api/proxy/api",
),
).toBe("https://app.openship.io/api/proxy/api/github/connect/redirect?install_state=nonce");
});
it("maps the same redirect to a split API origin", () => {
expect(
resolveApiNavigationUrl(
"/api/github/connect/redirect?install_state=nonce",
"https://api.openship.io/api",
),
).toBe("https://api.openship.io/api/github/connect/redirect?install_state=nonce");
});
it("accepts an endpoint-registry path without duplicating the API prefix", () => {
expect(
resolveApiNavigationUrl("github/connect/redirect", "https://ops.example.com/api/proxy/api/"),
).toBe("https://ops.example.com/api/proxy/api/github/connect/redirect");
});
it("preserves an absolute GitHub destination", () => {
expect(
resolveApiNavigationUrl(
"https://github.com/apps/openship/installations/new?state=nonce",
"https://app.openship.io/api/proxy/api",
),
).toBe("https://github.com/apps/openship/installations/new?state=nonce");
});
it("rejects unsafe schemes and relative paths that escape the API mount", () => {
expect(() =>
resolveApiNavigationUrl("javascript:alert(1)", "https://api.openship.io/api"),
).toThrow();
expect(() => resolveApiNavigationUrl("../auth", "https://api.openship.io/api")).toThrow();
});
});
+54 -7
View File
@@ -27,9 +27,8 @@ function resolveTarget(rawUrl?: string): Target {
const origin = rawUrl ? originOf(rawUrl) : undefined;
if (!origin) return DEFAULT_TARGET;
return (
TARGETS.find(
(t) => originOf(t.dashboard) === origin || originOf(t.api) === origin,
) ?? DEFAULT_TARGET
TARGETS.find((t) => originOf(t.dashboard) === origin || originOf(t.api) === origin) ??
DEFAULT_TARGET
);
}
@@ -84,11 +83,9 @@ function sameOriginProxyOrigin(): string | null {
// into `window.__OPENSHIP_API_ORIGIN__` for the browser bundle (whose base URL
// is a module-load constant — a build-time NEXT_PUBLIC var can't carry it).
function localApiOverride(): string | null {
if (typeof window !== "undefined") {
const injected = (window as { __OPENSHIP_API_ORIGIN__?: string })
.__OPENSHIP_API_ORIGIN__;
const injected = (window as { __OPENSHIP_API_ORIGIN__?: string }).__OPENSHIP_API_ORIGIN__;
if (!injected) return null;
return alignLoopbackOrigin(injected.replace(/\/+$/, ""), window.location.origin);
}
@@ -136,6 +133,57 @@ export function getRestApiBaseUrl() {
return `${getApiOrigin()}/api`;
}
/**
* Resolve a URL returned by the API for direct browser navigation.
*
* Most API responses return absolute external URLs, but a few auth flows return
* an API-root path such as `/api/github/connect/redirect`. Resolving that path
* against `window.location` is wrong in both split-host SaaS and self-hosted
* proxy mode: it lands on the dashboard instead of the API. Treat relative
* values as API-relative and preserve already-absolute HTTP(S) destinations.
*
* `apiBase` is injectable so all deployment topologies can be covered without
* mutating build-time environment variables in tests.
*/
export function resolveApiNavigationUrl(target: string, apiBase = getRestApiBaseUrl()): string {
const value = target.trim();
if (!value) throw new Error("API navigation URL is empty");
// Absolute destinations (for example github.com or api.openship.io) are
// authoritative, but never allow a non-web scheme into window navigation.
if (/^[A-Za-z][A-Za-z\d+.-]*:/.test(value)) {
const absolute = new URL(value);
if (absolute.protocol !== "http:" && absolute.protocol !== "https:") {
throw new Error("API navigation URL must use HTTP or HTTPS");
}
return absolute.toString();
}
if (value.startsWith("//")) {
throw new Error("Protocol-relative API navigation URLs are not allowed");
}
const normalizedBase = new URL(apiBase.endsWith("/") ? apiBase : `${apiBase}/`);
if (normalizedBase.protocol !== "http:" && normalizedBase.protocol !== "https:") {
throw new Error("API base URL must use HTTP or HTTPS");
}
// The server speaks in canonical `/api/...` paths. The browser client base
// already includes that segment (or `/api/proxy/api`), so remove it once.
let relative = value.replace(/^\/+/, "");
if (relative === "api") relative = "";
else if (relative.startsWith("api/")) relative = relative.slice("api/".length);
const resolved = new URL(relative, normalizedBase);
// A server-provided relative path must not escape the selected API mount.
if (
resolved.origin !== normalizedBase.origin ||
!resolved.pathname.startsWith(normalizedBase.pathname)
) {
throw new Error("API navigation URL escaped the API base");
}
return resolved.toString();
}
export function getCloudDashboardUrl(rawUrl?: string) {
return originOf(rawUrl ?? "") ?? cloudPartner(currentTarget()).dashboard;
}
@@ -158,4 +206,3 @@ export function getMarketingOrigin() {
}
return "https://openship.io";
}
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from "vitest";
import {
consumeGitHubConnectError,
GITHUB_CONNECT_ERROR_KEY,
githubConnectErrorMessage,
storeGitHubConnectError,
} from "./github-connect-error";
describe("githubConnectErrorMessage", () => {
it("maps known OAuth error codes to actionable copy", () => {
expect(githubConnectErrorMessage("account_already_linked_to_different_user")).toContain(
"already linked to a different Openship user",
);
});
it("labels an unknown OAuth error code", () => {
expect(githubConnectErrorMessage("provider_callback_failed")).toBe(
"Couldn't connect GitHub (provider_callback_failed).",
);
});
it("preserves a server-provided installation error message", () => {
expect(githubConnectErrorMessage("This install link expired. Start again.")).toBe(
"This install link expired. Start again.",
);
});
});
describe("GitHub connect error storage", () => {
it("shares a callback error once and clears it after consumption", () => {
const values = new Map<string, string>();
const storage = {
getItem: vi.fn((key: string) => values.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => values.set(key, value)),
removeItem: vi.fn((key: string) => values.delete(key)),
};
storeGitHubConnectError("claim failed", storage);
expect(values.get(GITHUB_CONNECT_ERROR_KEY)).toBe("claim failed");
expect(consumeGitHubConnectError(storage)).toBe("claim failed");
expect(consumeGitHubConnectError(storage)).toBeNull();
});
it("fails closed when browser storage is unavailable", () => {
expect(() => storeGitHubConnectError("claim failed", null)).not.toThrow();
expect(consumeGitHubConnectError(null)).toBeNull();
});
});
+47 -2
View File
@@ -9,6 +9,44 @@
*/
export const GITHUB_CONNECT_ERROR_KEY = "openship.github.connectError";
type ConnectErrorStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
function browserStorage(): ConnectErrorStorage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
/** Store a callback error for the window that initiated the OAuth flow. */
export function storeGitHubConnectError(
error: string,
storage: ConnectErrorStorage | null = browserStorage(),
): void {
if (!error || !storage) return;
try {
storage.setItem(GITHUB_CONNECT_ERROR_KEY, error);
} catch {
/* storage unavailable */
}
}
/** Read and clear the callback error so it cannot leak into a later attempt. */
export function consumeGitHubConnectError(
storage: ConnectErrorStorage | null = browserStorage(),
): string | null {
if (!storage) return null;
try {
const error = storage.getItem(GITHUB_CONNECT_ERROR_KEY);
if (error !== null) storage.removeItem(GITHUB_CONNECT_ERROR_KEY);
return error;
} catch {
return null;
}
}
const MESSAGES: Record<string, string> = {
account_already_linked_to_different_user:
"That GitHub account is already linked to a different Openship user. Sign in as that user, or disconnect GitHub there first.",
@@ -20,6 +58,13 @@ const MESSAGES: Record<string, string> = {
};
export function githubConnectErrorMessage(code: string | null | undefined): string {
if (!code) return "Couldn't connect GitHub. Please try again.";
return MESSAGES[code] ?? `Couldn't connect GitHub (${code}).`;
const value = code?.trim();
if (!value) return "Couldn't connect GitHub. Please try again.";
if (MESSAGES[value]) return MESSAGES[value];
// OAuth failures arrive as machine codes, while later installation-claim
// failures are already useful server messages stored through the same
// cross-window channel. Preserve those messages instead of wrapping them as
// if the entire sentence were an unknown error code.
return /^[a-z\d._-]+$/i.test(value) ? `Couldn't connect GitHub (${value}).` : value;
}
+37 -1
View File
@@ -1,10 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { closeAuthWindowAfterSuccess } from "./authWindow";
import { closeAuthWindowAfterSuccess, openAuthWindow } from "./authWindow";
describe("closeAuthWindowAfterSuccess", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("closes a script-opened auth popup even when no opener is available", () => {
@@ -30,3 +31,38 @@ describe("closeAuthWindowAfterSuccess", () => {
expect(close).toHaveBeenCalledOnce();
});
});
describe("openAuthWindow", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("reports when popup protection blocked the reserved window", () => {
vi.stubGlobal("window", {
screen: { width: 1440, height: 900 },
open: vi.fn(() => null),
});
expect(openAuthWindow().blocked).toBe(true);
});
it("reserves a browser window before navigating to the async auth URL", () => {
const popup = {
closed: false,
location: { href: "about:blank" },
focus: vi.fn(),
close: vi.fn(),
};
vi.stubGlobal("window", {
screen: { width: 1440, height: 900 },
open: vi.fn(() => popup),
});
const handle = openAuthWindow();
handle.navigate("https://api.openship.io/api/github/connect/redirect");
expect(handle.blocked).toBe(false);
expect(popup.location.href).toBe("https://api.openship.io/api/github/connect/redirect");
expect(popup.focus).toHaveBeenCalledOnce();
});
});
+13 -8
View File
@@ -15,6 +15,8 @@
/* ── Public types ─────────────────────────────────────────────────── */
export interface AuthWindowHandle {
/** True when the browser refused to create the popup. */
readonly blocked: boolean;
/** Redirect / open the auth URL. In browser, redirects the popup.
* In Electron, opens system browser. */
navigate: (url: string) => void;
@@ -54,9 +56,7 @@ export function closeAuthWindowAfterSuccess(
function isElectron(): boolean {
return (
typeof window !== "undefined" &&
"desktop" in window &&
!!(window as any).desktop?.isDesktop
typeof window !== "undefined" && "desktop" in window && !!(window as any).desktop?.isDesktop
);
}
@@ -72,7 +72,7 @@ function createBrowserHandle(initialUrl = "about:blank"): AuthWindowHandle {
const popup = window.open(
initialUrl,
"Auth",
`width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top}`
`width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top}`,
);
let timer: ReturnType<typeof setInterval> | null = null;
@@ -85,6 +85,7 @@ function createBrowserHandle(initialUrl = "about:blank"): AuthWindowHandle {
}
return {
blocked: popup === null,
navigate(url: string) {
if (popup && !popup.closed) {
popup.location.href = url;
@@ -121,11 +122,14 @@ function createElectronHandle(initialUrl?: string): AuthWindowHandle {
// If the caller supplied a URL up front (matching the browser handle's
// behavior where `window.open(initialUrl, ...)` opens immediately),
// hand it to the desktop bridge now. Without this, callers that pass
// `openAuthWindow(url)` and never call `.navigate()` (GitHubContext,
// the legacy initAuthWindow helper) would silently no-op in desktop.
// hand it to the desktop bridge now. This keeps that supported calling
// convention equivalent across browser and desktop runtimes.
if (initialUrl) {
try { desktop?.onboarding?.openExternal?.(initialUrl); } catch { /* bridge missing */ }
try {
desktop?.onboarding?.openExternal?.(initialUrl);
} catch {
/* bridge missing */
}
}
function cleanup() {
@@ -136,6 +140,7 @@ function createElectronHandle(initialUrl?: string): AuthWindowHandle {
}
return {
blocked: false,
navigate(url: string) {
desktop.onboarding.openExternal(url);
},