From a3bb41df046206d16c5cf4c0f791f1665d252dc2 Mon Sep 17 00:00:00 2001 From: giswqs Date: Tue, 11 Aug 2026 10:08:47 -0400 Subject: [PATCH 1/5] feat(auth): add an optional Auth0 sign-in gate alongside Clerk Hosted deployments can already gate the web app behind Clerk. This adds Auth0 as an alternative provider, configured independently and loaded instead of Clerk, for deployments that already run an Auth0 tenant. Auth0 has no embedded sign-in card, so the gate uses Universal Login: the visitor is redirected to the tenant's hosted page and returned to the app. The URL they arrived on travels in the transaction's appState, so a shared `?project=...` link still opens its project after signing in, and the single-use `code`/`state` parameters are stripped from the address bar on return. Provider selection lives in one place (lib/auth-gate.ts) and follows the precedence the individual settings already use: a provider named in the deployment env beats one baked into the build, and naming both at the same level keeps Clerk so an existing gated image never switches on its own. The Docker entrypoint refuses to boot when both are passed at runtime, and validates the Auth0 pair together rather than serving an ungated app from a half configuration. Each provider stays in its own dynamically imported chunk, both excluded from the service-worker precache, so an ungated deployment downloads neither SDK. The gate remains a property of the build, not the request: the Tauri, mobile, and embedded builds are compiled without it, and `?embed=1` cannot switch it off. Also apply the initial theme in main.tsx when a gate is configured. The signed-out screen paints before mounts, and App is where useThemeMode adds the `dark` class, so a dark-mode visitor was shown a white sign-in screen that only flipped after signing in. This affected the existing Clerk gate too. Verified against the running app with the tenant endpoints stubbed: the sign-in screen replaces the app, /authorize carries the configured client_id, the registered redirect_uri and PKCE, the app renders with a clean URL once signed in, the account menu offers sign-out, a reload stays signed in without another trip to the tenant, and a deep link's query survives. An ungated build still boots straight to the map and requests neither SDK chunk. --- apps/geolibre-desktop/package.json | 1 + .../src/components/auth/Auth0Gate.tsx | 221 ++++++++++++++++++ .../geolibre-desktop/src/i18n/locales/en.json | 7 +- apps/geolibre-desktop/src/lib/auth-gate.ts | 67 ++++++ apps/geolibre-desktop/src/lib/auth0-auth.ts | 98 ++++++++ apps/geolibre-desktop/src/main.tsx | 57 +++-- apps/geolibre-desktop/vite.config.ts | 7 +- docker-compose.yml | 3 + docker/entrypoint.sh | 56 +++++ docker/nginx.conf | 12 +- docs/getting-started.md | 66 +++++- docs/self-hosting.md | 1 + package-lock.json | 95 +++++++- tests/auth-gate.test.ts | 86 +++++++ tests/auth0-auth.test.ts | 131 +++++++++++ 15 files changed, 883 insertions(+), 25 deletions(-) create mode 100644 apps/geolibre-desktop/src/components/auth/Auth0Gate.tsx create mode 100644 apps/geolibre-desktop/src/lib/auth-gate.ts create mode 100644 apps/geolibre-desktop/src/lib/auth0-auth.ts create mode 100644 tests/auth-gate.test.ts create mode 100644 tests/auth0-auth.test.ts diff --git a/apps/geolibre-desktop/package.json b/apps/geolibre-desktop/package.json index d0b0a0419d..b4ef37174b 100644 --- a/apps/geolibre-desktop/package.json +++ b/apps/geolibre-desktop/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.116.0", + "@auth0/auth0-react": "^2.23.0", "@carbonplan/zarr-layer": "^0.8.0", "@cereusdb/standard": "^0.2.0", "@clerk/react": "^6.14.1", diff --git a/apps/geolibre-desktop/src/components/auth/Auth0Gate.tsx b/apps/geolibre-desktop/src/components/auth/Auth0Gate.tsx new file mode 100644 index 0000000000..debe13badf --- /dev/null +++ b/apps/geolibre-desktop/src/components/auth/Auth0Gate.tsx @@ -0,0 +1,221 @@ +import { Auth0Provider, useAuth0, type AppState } from "@auth0/auth0-react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@geolibre/ui"; +import { AlertTriangle, LogOut, User } from "lucide-react"; +import { useCallback, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { useBeforeUnloadGuard } from "../../hooks/useBeforeUnloadGuard"; + +interface Auth0GateProps { + /** Tenant (or custom) domain, already normalized to a bare hostname. */ + domain: string; + /** The Auth0 application's client ID, which is public by design. */ + clientId: string; + children: ReactNode; +} + +/** + * The URL Auth0 returns to after a login or logout. + * + * Resolved from the build's base URL rather than the current location, so it is + * one stable value an operator can paste into the Auth0 application's **Allowed + * Callback URLs** and **Allowed Logout URLs** — Auth0 matches those exactly, and + * a per-entry-path value would be unmatchable. Subpath deployments + * (`GEOLIBRE_APP_BASE`) resolve to their own prefix; the relative-base demo + * build resolves against the directory currently being served. + */ +function redirectUri(): string { + return new URL(import.meta.env.BASE_URL || "/", window.location.href).href; +} + +/** + * Strip the authorization-code parameters Auth0 appends to the return URL. + * + * They are single-use and meaningless once exchanged, and leaving them in the + * address bar means a reload (or a copied link) re-runs a callback that can only + * fail. `returnTo` carries the URL the visitor asked for before being sent to + * Auth0, so a link with GeoLibre's own query parameters survives the round trip. + */ +function onRedirectCallback(appState?: AppState): void { + const url = new URL(appState?.returnTo ?? window.location.href, window.location.href); + for (const param of ["code", "state", "error", "error_description"]) { + url.searchParams.delete(param); + } + window.history.replaceState({}, "", url.toString()); +} + +/** Full-screen centered layout shared by the loading, error, and signed-out screens. */ +function AuthScreen({ children, alert = false }: { children: ReactNode; alert?: boolean }) { + return ( +
+ {children} +
+ ); +} + +/** The signed-in user's avatar, with a menu offering sign-out. */ +function UserMenu() { + const { t } = useTranslation(); + const { user, logout } = useAuth0(); + const label = user?.name || user?.email || user?.nickname; + return ( +
+ + + + + + {label ? ( + <> + + {label} + + + + ) : null} + { + void logout({ logoutParams: { returnTo: redirectUri() } }); + }} + > + + {t("auth.signOut")} + + + +
+ ); +} + +/** The three signed-out states: still resolving, failed, or waiting on the visitor. */ +function Auth0Screens({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + const { isLoading, isAuthenticated, error, loginWithRedirect } = useAuth0(); + + // Preserve the URL the visitor arrived on — Auth0 returns to the registered + // callback URL, which would otherwise drop a shared `?project=…` link. + const signIn = useCallback(() => { + void loginWithRedirect({ appState: { returnTo: window.location.href } }); + }, [loginWithRedirect]); + + if (isLoading) { + return ( +
+ + ); + } + + // Covers an unreachable tenant as much as a login Auth0 itself refused (an + // Action denying an unapproved user answers `access_denied`). Without this the + // gate would sit on the sign-in card with no hint of why nothing happened. + if (error) { + return ( + + +
+

{t("auth.unavailableTitle")}

+

+ {error.message || t("auth.unavailableDescription")} +

+
+ +
+ ); + } + + if (!isAuthenticated) { + return ( + +
+

{t("auth.signInTitle")}

+

{t("auth.signInDescription")}

+
+ +
+ ); + } + + return ( + <> + {children} + + + ); +} + +/** + * Optional whole-app sign-in gate for hosted web deployments, backed by Auth0. + * + * The sibling of {@link ../auth/ClerkGate.ClerkGate}: the two are configured + * independently and only one is ever loaded, since this module is dynamically + * imported only when an Auth0 domain and client ID are configured. Normal web, + * Tauri, mobile, and embedded builds initialize neither. + * + * Auth0 has no drop-in embedded sign-in card, so this uses Universal Login: the + * visitor is redirected to the tenant's hosted login page and returned here. + * That is Auth0's supported flow — embedded cross-origin login depends on + * third-party cookies that browsers now block. + * + * It gates *rendering* only, and is not a server authorization boundary: the + * deployment must still validate sessions (or another credential) at the reverse + * proxy for `/sidecar`, `/ai`, and any other upstream service. See the Auth0 + * section of docs/getting-started.md. + */ +export function Auth0Gate({ domain, clientId, children }: Auth0GateProps) { + // Keep the unsaved-work prompt alive across the signed-out screens, for the + // same reason ClerkGate does: mounts the same guard, but it unmounts + // the moment the session ends — on an expiry as much as on a sign-out click — + // and the project state survives in the module-scope store, so the tab could + // otherwise be closed with unsaved changes and no "Leave site?" prompt. + useBeforeUnloadGuard(); + return ( + + ); +} diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 1787311d97..437bd6f8b9 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -1602,7 +1602,12 @@ "auth": { "unavailableTitle": "Sign-in is unavailable", "unavailableDescription": "GeoLibre could not reach the sign-in service, so it cannot tell whether you are signed in. This is usually temporary; if it persists, this deployment's authentication settings may need attention.", - "retry": "Try again" + "retry": "Try again", + "signInTitle": "Sign in to GeoLibre", + "signInDescription": "This deployment requires an account. You will be taken to a secure sign-in page and returned here.", + "signIn": "Sign in", + "signOut": "Sign out", + "account": "Account" }, "basemapExtract": { "title": "Extract Offline Basemap", diff --git a/apps/geolibre-desktop/src/lib/auth-gate.ts b/apps/geolibre-desktop/src/lib/auth-gate.ts new file mode 100644 index 0000000000..bc7129b90e --- /dev/null +++ b/apps/geolibre-desktop/src/lib/auth-gate.ts @@ -0,0 +1,67 @@ +import { AUTH0_DOMAIN_ENV, resolveAuth0Config } from "./auth0-auth"; +import { + CLERK_PUBLISHABLE_KEY_ENV, + resolveClerkPublishableKey, + resolveClerkWaitlistEnabled, +} from "./clerk-auth"; +import { readDeploymentEnv, readDeploymentEnvValue, type EnvRecord } from "./deployment-env"; + +/** Which optional sign-in gate a hosted deployment has configured, if any. */ +export type AuthGateConfig = + | { provider: "clerk"; publishableKey: string; waitlist: boolean } + | { provider: "auth0"; domain: string; clientId: string }; + +/** + * Pick the sign-in gate for a web deployment. + * + * Clerk and Auth0 are configured independently and only one is ever loaded, so + * a deployment that names both needs a rule. It is the same rule the individual + * settings already follow (see deployment-env.ts): the deployment env is the + * more specific statement, so a provider named there wins over one baked into + * the build. Naming both at the same level keeps Clerk, which shipped first — + * an image built with a Clerk key must not switch providers on its own. The + * Docker entrypoint refuses to boot when both are passed at runtime, so that + * tie only arises from build-time environment variables. + * + * @param webApp - Whether this is the hosted web build. Must be derived from + * the build target alone: a runtime signal the visitor controls (`?embed=1`) + * would let anyone switch a configured gate off. + * @param deploymentEnv - Runtime env; defaults to the value on `window`. + * @param buildEnv - Build-time env; defaults to `import.meta.env`. + * @returns The provider and its settings, or undefined when no gate is configured. + */ +export function resolveAuthGate( + webApp: boolean, + deploymentEnv?: EnvRecord, + buildEnv?: EnvRecord, +): AuthGateConfig | undefined { + if (!webApp) return undefined; + const deployment = deploymentEnv ?? readDeploymentEnv(); + const build = buildEnv ?? (import.meta.env as EnvRecord); + + const clerkKey = resolveClerkPublishableKey(true, deployment, build); + const auth0 = resolveAuth0Config(true, deployment, build); + + const clerk = (): AuthGateConfig => ({ + provider: "clerk", + publishableKey: clerkKey!, + // Resolved across both tiers rather than the winning one, so a build-time + // key can still have its waitlist screen turned on at runtime. + waitlist: resolveClerkWaitlistEnabled(true, deployment, build), + }); + + if (clerkKey && auth0) { + // Raw presence, not a full resolve: this only asks which tier *named* the + // provider. Re-resolving would re-report a partial Auth0 configuration that + // the merged read above already completed from the build env. + const clerkNamedAtRuntime = Boolean( + readDeploymentEnvValue(CLERK_PUBLISHABLE_KEY_ENV, deployment, {}), + ); + const auth0NamedAtRuntime = Boolean(readDeploymentEnvValue(AUTH0_DOMAIN_ENV, deployment, {})); + if (auth0NamedAtRuntime && !clerkNamedAtRuntime) return { provider: "auth0", ...auth0 }; + return clerk(); + } + if (clerkKey) return clerk(); + if (auth0) return { provider: "auth0", ...auth0 }; + return undefined; +} diff --git a/apps/geolibre-desktop/src/lib/auth0-auth.ts b/apps/geolibre-desktop/src/lib/auth0-auth.ts new file mode 100644 index 0000000000..fe77b69f5f --- /dev/null +++ b/apps/geolibre-desktop/src/lib/auth0-auth.ts @@ -0,0 +1,98 @@ +import { readDeploymentEnvValue, type EnvRecord } from "./deployment-env"; + +export const AUTH0_DOMAIN_ENV = "VITE_GEOLIBRE_AUTH0_DOMAIN"; + +export const AUTH0_CLIENT_ID_ENV = "VITE_GEOLIBRE_AUTH0_CLIENT_ID"; + +/** The two public values an Auth0 single-page application needs in the browser. */ +export interface Auth0Config { + /** Tenant (or custom) domain, as a bare hostname: `example.us.auth0.com`. */ + domain: string; + /** The Auth0 application's client ID, which is public by design. */ + clientId: string; +} + +/** + * Normalize an operator-supplied Auth0 domain to a bare hostname. + * + * Auth0's dashboard shows the domain without a scheme, but `https://…` (with or + * without a trailing slash) is the natural thing to paste, and the SDK builds + * its authorize/token URLs by string concatenation — a scheme left in place + * yields `https://https://…` and a redirect that fails with no useful error. + * + * @param value - The raw environment value, or undefined when unset. + * @returns The lowercase hostname, or undefined when unset or malformed. + */ +function normalizeDomain(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + // Drop the scheme and anything from the first path separator onward, so both + // `https://tenant.us.auth0.com/` and `tenant.us.auth0.com` land on the host. + const host = trimmed + .replace(/^https?:\/\//i, "") + .split("/")[0] + .toLowerCase(); + // A hostname only: the charset rejects a port, credentials, or a query, any + // of which would silently produce an unreachable Auth0 endpoint. Requiring a + // dot rejects a bare label such as `localhost`, which is never a tenant. + if (!/^[a-z0-9.-]+$/.test(host) || !host.includes(".")) return undefined; + return host; +} + +/** + * Validate an Auth0 client ID. + * + * Auth0 issues base62 identifiers, so anything outside that charset is a paste + * error (a whole URL, a JSON fragment, a quoted value) rather than a client ID. + * + * @param value - The raw environment value, or undefined when unset. + * @returns The trimmed client ID, or undefined when unset or malformed. + */ +function normalizeClientId(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed || !/^[A-Za-z0-9_-]+$/.test(trimmed)) return undefined; + return trimmed; +} + +/** + * Resolve the optional Auth0 configuration for a web deployment. + * + * Both values are required, so a partial configuration keeps authentication + * disabled rather than initializing an SDK that cannot complete a login. That + * case is also loud: the Docker entrypoint refuses to boot on a half + * configuration, so reaching it here means a build-time env was set that way. + * + * Native and embedded callers should pass `false` for `webApp` so a build-time + * environment variable cannot accidentally gate an offline application. + * `webApp` must be derived from the build target alone — a runtime signal the + * visitor controls (a query parameter such as `?embed=1`) would let anyone + * switch the gate off. + * + * @param webApp - Whether this is the hosted web build. + * @param deploymentEnv - Runtime env; defaults to the value on `window`. + * @param buildEnv - Build-time env; defaults to `import.meta.env`. + * @returns The domain and client ID, or undefined when the gate is off. + */ +export function resolveAuth0Config( + webApp: boolean, + deploymentEnv?: EnvRecord, + buildEnv?: EnvRecord, +): Auth0Config | undefined { + if (!webApp) return undefined; + const domain = normalizeDomain(readDeploymentEnvValue(AUTH0_DOMAIN_ENV, deploymentEnv, buildEnv)); + const clientId = normalizeClientId( + readDeploymentEnvValue(AUTH0_CLIENT_ID_ENV, deploymentEnv, buildEnv), + ); + if (!domain || !clientId) { + // Only complain when something was configured: an unset gate is the normal + // case for every public deployment and must stay silent. + if (domain || clientId) { + console.error( + `[GeoLibre] Ignoring an incomplete Auth0 configuration: ${AUTH0_DOMAIN_ENV} and ` + + `${AUTH0_CLIENT_ID_ENV} must both be set to valid values. The sign-in gate is OFF.`, + ); + } + return undefined; + } + return { domain, clientId }; +} diff --git a/apps/geolibre-desktop/src/main.tsx b/apps/geolibre-desktop/src/main.tsx index fa964777bd..1fb13c4137 100644 --- a/apps/geolibre-desktop/src/main.tsx +++ b/apps/geolibre-desktop/src/main.tsx @@ -46,6 +46,7 @@ import "./lib/swipe-style"; import { registerSW } from "virtual:pwa-register"; import { TooltipProvider } from "@geolibre/ui"; import { I18nextProvider } from "react-i18next"; +import type { ReactNode } from "react"; // Initializes i18next (resolves the UI language from the `?locale`/`?lang` query // param, stored settings, or the browser) before React renders, so the first // paint is already in the right language. English is bundled; other locales are @@ -55,7 +56,8 @@ import i18n, { i18nReady } from "./i18n"; import { installDiagnosticsCapture } from "./lib/diagnostics"; import { isTauri } from "./lib/is-tauri"; import { installStaleChunkReload } from "./lib/stale-chunk-reload"; -import { resolveClerkPublishableKey, resolveClerkWaitlistEnabled } from "./lib/clerk-auth"; +import { resolveAuthGate, type AuthGateConfig } from "./lib/auth-gate"; +import { getInitialThemeMode } from "./hooks/useThemeMode"; installDiagnosticsCapture(); // In the desktop build, route geocoding (place search / reverse geocode) @@ -102,8 +104,44 @@ installStaleChunkReload(); // `isEmbedded()` — that returns true for a plain `?embed=1` query parameter, so // any visitor could disable a configured sign-in wall by typing a URL. const isHostedWebApp = !isTauri() && !__GEOLIBRE_EMBED_BUILD__; -const clerkPublishableKey = resolveClerkPublishableKey(isHostedWebApp); -const clerkWaitlistEnabled = resolveClerkWaitlistEnabled(isHostedWebApp); +// Clerk or Auth0, whichever this deployment configured (neither, normally). +const authGate = resolveAuthGate(isHostedWebApp); +if (authGate) { + // Apply the initial theme now rather than leaving it to . A gate paints + // a full-screen signed-out page *before* App mounts, and App is where + // useThemeMode adds the `dark` class — so without this a dark-mode visitor + // gets a white sign-in screen that flips to dark only after signing in. This + // sets exactly what useThemeMode's layout effect will set a moment later + // (same helper, same `?theme=` handling), so it is a no-op once App mounts. + const initialTheme = getInitialThemeMode(); + document.documentElement.classList.toggle("dark", initialTheme === "dark"); + document.documentElement.style.colorScheme = initialTheme; +} + +/** + * Load the configured gate's chunk and return a wrapper for the app tree. + * + * Each provider lives in its own dynamically imported module, so a deployment + * downloads only the SDK it actually uses — and an ungated build downloads + * neither. Returns null when no gate is configured. + */ +function loadAuthGate( + config: AuthGateConfig | undefined, +): Promise<((children: ReactNode) => ReactNode) | null> { + if (!config) return Promise.resolve(null); + if (config.provider === "clerk") { + return import("./components/auth/ClerkGate").then(({ ClerkGate }) => (children: ReactNode) => ( + + {children} + + )); + } + return import("./components/auth/Auth0Gate").then(({ Auth0Gate }) => (children: ReactNode) => ( + + {children} + + )); +} // Register the offline/PWA service worker (web build only). `registerSW` is a // no-op stub in the Tauri desktop and embedded Jupyter builds, where the plugin // is disabled (see vite.config.ts pwaPlugin). @@ -147,21 +185,14 @@ registerSW({ void Promise.all([ import("./App"), import("./components/common/error-boundaries"), - clerkPublishableKey ? import("./components/auth/ClerkGate") : Promise.resolve(null), + loadAuthGate(authGate), // Gate the first render on i18next being initialized with the active locale's // (lazily loaded) catalog, so the UI never paints raw translation keys. i18nReady, ]) - .then(([{ default: App }, { AppErrorBoundary }, clerkModule]) => { + .then(([{ default: App }, { AppErrorBoundary }, withAuthGate]) => { const app = ; - const authenticatedApp = - clerkPublishableKey && clerkModule ? ( - - {app} - - ) : ( - app - ); + const authenticatedApp = withAuthGate ? withAuthGate(app) : app; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/apps/geolibre-desktop/vite.config.ts b/apps/geolibre-desktop/vite.config.ts index 39e241cc4d..77043c0433 100644 --- a/apps/geolibre-desktop/vite.config.ts +++ b/apps/geolibre-desktop/vite.config.ts @@ -739,10 +739,11 @@ function pwaPlugin(): Plugin[] { // is auto-named `i18n-` and must stay precached, so this must NOT match // it. English is bundled there, so it stays precached and works offline. "**/i18n-locale-*.js", - // Optional hosted-web authentication. This chunk is requested only when a - // Clerk publishable key is configured, so public deployments should not - // download it during service-worker installation. + // Optional hosted-web authentication. These chunks are requested only when + // the matching provider is configured, so public deployments should not + // download either during service-worker installation. "**/ClerkGate-*.js", + "**/Auth0Gate-*.js", ]; // Note: the 4 KB public/pyodide/pyodide-worker.js shim is intentionally left // in the precache (revisioned, so no stale-after-deploy risk). The heavy diff --git a/docker-compose.yml b/docker-compose.yml index 1e48be1f5f..ba7dbe834b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,11 @@ services: # them with the public TLS origins when deploying behind an ingress. GEOLIBRE_SHARE_URL: "${GEOLIBRE_SHARE_URL:-http://localhost:8000}" GEOLIBRE_COLLAB_URL: "${GEOLIBRE_COLLAB_URL:-ws://localhost:8787}" + # Optional sign-in gate: configure either Clerk or Auth0, not both. GEOLIBRE_CLERK_PUBLISHABLE_KEY: "${GEOLIBRE_CLERK_PUBLISHABLE_KEY:-}" GEOLIBRE_CLERK_WAITLIST: "${GEOLIBRE_CLERK_WAITLIST:-}" + GEOLIBRE_AUTH0_DOMAIN: "${GEOLIBRE_AUTH0_DOMAIN:-}" + GEOLIBRE_AUTH0_CLIENT_ID: "${GEOLIBRE_AUTH0_CLIENT_ID:-}" depends_on: geolibre-server: condition: service_healthy diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index aad396281d..50d955ea2c 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -192,6 +192,39 @@ if clerk_waitlist in ("1", "true"): elif clerk_waitlist not in ("", "0", "false"): raise SystemExit("ERROR: GEOLIBRE_CLERK_WAITLIST must be 1/true or 0/false.") +# Optional Auth0 sign-in gate, the alternative to Clerk above. Both values are +# public by design (they end up in the runtime config every visitor downloads); +# an Auth0 client *secret* is not used by a single-page application and must +# never be passed here. The pair is validated together so a half configuration +# fails at container startup rather than silently serving an ungated app. +auth0_domain = os.environ.get("GEOLIBRE_AUTH0_DOMAIN", "").strip() +auth0_client_id = os.environ.get("GEOLIBRE_AUTH0_CLIENT_ID", "").strip() +if auth0_domain or auth0_client_id: + if clerk_key: + raise SystemExit( + "ERROR: configure either Clerk or Auth0, not both. Unset " + "GEOLIBRE_CLERK_PUBLISHABLE_KEY or the GEOLIBRE_AUTH0_* variables." + ) + if not auth0_domain or not auth0_client_id: + raise SystemExit( + "ERROR: GEOLIBRE_AUTH0_DOMAIN and GEOLIBRE_AUTH0_CLIENT_ID must be set together." + ) + # The dashboard shows the domain without a scheme, but "https://tenant..." + # is the natural thing to paste; the SDK builds its URLs by concatenation, + # so a scheme left in place yields https://https://... and a login that + # fails with no useful error. Normalize here and reject anything that is not + # a plain hostname (a port, credentials, a path). + auth0_host = re.sub(r"^https?://", "", auth0_domain, flags=re.IGNORECASE).split("/")[0].lower() + if not re.fullmatch(r"[a-z0-9.-]+", auth0_host) or "." not in auth0_host: + raise SystemExit( + "ERROR: GEOLIBRE_AUTH0_DOMAIN must be a tenant hostname such as example.us.auth0.com." + ) + # Auth0 issues base62 client IDs, so anything else is a paste error. + if not re.fullmatch(r"[A-Za-z0-9_-]+", auth0_client_id): + raise SystemExit("ERROR: GEOLIBRE_AUTH0_CLIENT_ID is not a valid Auth0 client ID.") + deployment["VITE_GEOLIBRE_AUTH0_DOMAIN"] = auth0_host + deployment["VITE_GEOLIBRE_AUTH0_CLIENT_ID"] = auth0_client_id + # Origins allowed to drive a framed app over the embed postMessage API. Unset # means the API stays off, so a public deployment can never be driven by the # page that frames it. "*" allows any origin: private networks only. @@ -386,6 +419,10 @@ if [ -n "$(trim "${GEOLIBRE_CLERK_PUBLISHABLE_KEY:-}")" ]; then esac fi +if [ -n "$(trim "${GEOLIBRE_AUTH0_DOMAIN:-}")" ]; then + echo "Auth0 sign-in gate enabled for $(trim "$GEOLIBRE_AUTH0_DOMAIN")." +fi + # Render the nginx config from the immutable image template on every boot. The # template is never mutated, so a container *restart* (which re-runs this script # with a freshly generated token but keeps the writable layer) always writes a @@ -449,6 +486,23 @@ if clerk_key: clerk_src = f" https://{clerk_fapi} https://challenges.cloudflare.com https://*.protect.clerk.com" clerk_frame_src = " https://challenges.cloudflare.com https://*.protect.clerk.com" +# Auth0 needs no script-src entry -- its SDK is bundled into the app -- and its +# token endpoint is already covered by the bare `https:` in connect-src. What it +# does need is frame-src for the hidden silent-authentication iframe the SDK +# opens against the tenant to restore a session. Validated independently of the +# runtime-config block above for the same reason the Clerk decode is: this is a +# separate `python -c` process, so an edit that reorders or drops that block must +# not turn a bad value into a raw traceback here. +auth0_frame_src = "" +auth0_domain = os.environ.get("GEOLIBRE_AUTH0_DOMAIN", "").strip() +if auth0_domain: + auth0_host = re.sub(r"^https?://", "", auth0_domain, flags=re.IGNORECASE).split("/")[0].lower() + if not re.fullmatch(r"[a-z0-9.-]+", auth0_host) or "." not in auth0_host: + raise SystemExit( + "ERROR: GEOLIBRE_AUTH0_DOMAIN must be a tenant hostname such as example.us.auth0.com." + ) + auth0_frame_src = f" https://{auth0_host}" + src = open("/etc/nginx/nginx.conf.template").read() open("/etc/nginx/conf.d/default.conf", "w").write( src.replace("__GEOLIBRE_SIDECAR_TOKEN__", token).replace( @@ -457,6 +511,8 @@ open("/etc/nginx/conf.d/default.conf", "w").write( "__GEOLIBRE_CLERK_SCRIPT_SRC__", clerk_src ).replace( "__GEOLIBRE_CLERK_FRAME_SRC__", clerk_frame_src + ).replace( + "__GEOLIBRE_AUTH0_FRAME_SRC__", auth0_frame_src ) ) ' diff --git a/docker/nginx.conf b/docker/nginx.conf index 693ed86477..6a98d581c2 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -96,15 +96,19 @@ server { # __GEOLIBRE_CLERK_SCRIPT_SRC__/__GEOLIBRE_CLERK_FRAME_SRC__ are likewise # replaced at boot with the Clerk Frontend API host encoded in # GEOLIBRE_CLERK_PUBLISHABLE_KEY plus Clerk's fixed bot-protection origins - # (empty when unset). These are deliberately NOT mirrored to the Tauri CSP: - # the sign-in gate is compiled out of the desktop and embed builds - # (main.tsx gates it on the build target), so Clerk never loads there. + # (empty when unset). __GEOLIBRE_AUTH0_FRAME_SRC__ is the same idea for the + # alternative Auth0 gate: GEOLIBRE_AUTH0_DOMAIN, which the SDK opens in a + # hidden iframe for silent authentication. Auth0 needs no script-src entry + # (its SDK is bundled) and its token endpoint is covered by the bare + # `https:` in connect-src. These are deliberately NOT mirrored to the Tauri + # CSP: the sign-in gate is compiled out of the desktop and embed builds + # (main.tsx gates it on the build target), so neither provider loads there. # The Tauri CSP additionally allows http://127.0.0.1:* / http://localhost:* # in frame-src/child-src so the desktop app can embed its locally # launched JupyterLab server in the Notebook panel. That is desktop-only # and intentionally NOT mirrored here: the web build embeds the # same-origin self-hosted JupyterLite site, already covered by 'self'. - add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https: data: blob: http://127.0.0.1:* http://localhost:* wss://collab.geolibre.app__GEOLIBRE_COLLAB_CONNECT_SRC__ ws://127.0.0.1:* ws://localhost:*; img-src 'self' data: blob: https:; media-src 'self' blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' blob: 'unsafe-eval' 'wasm-unsafe-eval' https://cdn.jsdelivr.net/npm/ https://cdn.jsdelivr.net/pyodide/ https://accounts.google.com__GEOLIBRE_CLERK_SCRIPT_SRC__; child-src 'self' https://accounts.google.com https://www.google.com; frame-src 'self' https://accounts.google.com https://www.google.com__GEOLIBRE_CLERK_FRAME_SRC__; worker-src blob: 'self'" always; + add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https: data: blob: http://127.0.0.1:* http://localhost:* wss://collab.geolibre.app__GEOLIBRE_COLLAB_CONNECT_SRC__ ws://127.0.0.1:* ws://localhost:*; img-src 'self' data: blob: https:; media-src 'self' blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' blob: 'unsafe-eval' 'wasm-unsafe-eval' https://cdn.jsdelivr.net/npm/ https://cdn.jsdelivr.net/pyodide/ https://accounts.google.com__GEOLIBRE_CLERK_SCRIPT_SRC__; child-src 'self' https://accounts.google.com https://www.google.com; frame-src 'self' https://accounts.google.com https://www.google.com__GEOLIBRE_CLERK_FRAME_SRC____GEOLIBRE_AUTH0_FRAME_SRC__; worker-src blob: 'self'" always; } # The service worker has a stable filename, so it must always revalidate; diff --git a/docs/getting-started.md b/docs/getting-started.md index f93474ea2f..02cc4e2e2f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -270,7 +270,10 @@ publicly. #### Clerk sign-in gate (optional) For individual user accounts instead of one shared password, configure a Clerk -application for the deployment domain and pass its publishable key: +application for the deployment domain and pass its publishable key. (If you +already use Auth0, skip to [the Auth0 gate](#auth0-sign-in-gate-optional) — +it is the same feature with a different provider, and you configure one or the +other, never both.) ```bash docker run --rm -p 8080:80 \ @@ -323,6 +326,67 @@ backend that verifies Clerk session tokens on every request. Use the existing `GEOLIBRE_AUTH_USER` and `GEOLIBRE_AUTH_PASSWORD` variables as well when the whole container must be protected before its assets are served. +#### Auth0 sign-in gate (optional) + +The same whole-app sign-in gate, for deployments that already use Auth0. Create +a **Single Page Application** in the Auth0 Dashboard and pass its domain and +client ID — both are public values, and an Auth0 client *secret* is neither +needed nor accepted here: + +```bash +docker run --rm -p 8080:80 \ + -e GEOLIBRE_AUTH0_DOMAIN='example.us.auth0.com' \ + -e GEOLIBRE_AUTH0_CLIENT_ID='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \ + ghcr.io/opengeos/geolibre:latest +``` + +Both variables are required together, and configuring Auth0 *and* Clerk at once +is refused at startup rather than resolved silently — pick one provider. As with +Clerk, neither SDK is loaded when nothing is configured, the gate applies only +to the hosted web application (the Tauri, mobile, and embedded/Jupyter builds +are compiled without it), and it is a property of the build rather than the +request, so `?embed=1` cannot switch it off. The build-time equivalents are +`VITE_GEOLIBRE_AUTH0_DOMAIN` and `VITE_GEOLIBRE_AUTH0_CLIENT_ID`; the runtime +variables win when both are present. + +In the Auth0 application's settings, add the deployment URL **with its trailing +slash** to all three of **Allowed Callback URLs**, **Allowed Logout URLs**, and +**Allowed Web Origins** — for example `https://gis.example.com/`, or +`https://gis.example.com/geolibre/` for a [subpath +deployment](#subpath-and-onboarding-build-arguments). Auth0 matches those +exactly, and a missing entry surfaces as a callback error instead of a login. + +Auth0 has no embedded sign-in card, so GeoLibre uses **Universal Login**: the +visitor clicks *Sign in*, is redirected to your tenant's hosted login page, and +is returned to the app. The URL they arrived on is carried through the round +trip, so a shared `?project=…` link still opens its project after signing in. + +##### Approving users + +Who may sign in is decided in the Auth0 Dashboard, not by GeoLibre: + +- **Authentication → Database → Sign Ups** — turning off self-service sign-up + makes the deployment invite-only; you then add people from **User Management + → Users** or through an enterprise connection. +- **Actions** — a post-login Action that calls `api.access.deny()` for anyone + outside your organization or role. GeoLibre shows the denial on its own error + screen with a way to try another account, rather than a blank page. + +There is no equivalent of Clerk's waitlist form, so `GEOLIBRE_CLERK_WAITLIST` +has no Auth0 counterpart. + +The session is cached in the browser so a page reload does not bounce through +the login page again. Nothing here requests an API audience, so that cached +token is an identity assertion only and grants no access to any upstream +service by itself. + +Like the Clerk gate, this controls access to the GeoLibre interface but is not a +server authorization boundary. Keep `/sidecar`, `/ai`, and any other sensitive +upstream service behind nginx authentication, Cloudflare Access, or a backend +that verifies Auth0 tokens on every request, and use `GEOLIBRE_AUTH_USER` / +`GEOLIBRE_AUTH_PASSWORD` as well when the whole container must be protected +before its assets are served. + #### Subpath and onboarding build arguments For deployments under a URL subpath, pass the app base at build time: diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 7919b0db2b..b86a32df30 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -143,6 +143,7 @@ Settings that matter for a private deployment: | `GEOLIBRE_AUTH_USER` / `GEOLIBRE_AUTH_PASSWORD` | set, for a quick single credential | nginx Basic Auth over the app and the `/sidecar` API. One shared credential, not accounts. Use a real auth proxy for multi-user or SSO. | | `GEOLIBRE_CLERK_PUBLISHABLE_KEY` | unset, or a Clerk publishable key | Unset keeps the app public and does not load Clerk. A key requires individual users to sign in before the web interface renders; protect server APIs separately. | | `GEOLIBRE_CLERK_WAITLIST` | unset, or `1` alongside a Clerk key | Adds Clerk's waitlist form to the sign-in screen, so visitors can request access and you approve each one from the Clerk Dashboard. Leave unset for an invite-only ("restricted") instance, where nothing would act on a request. | +| `GEOLIBRE_AUTH0_DOMAIN` / `GEOLIBRE_AUTH0_CLIENT_ID` | unset, or both, if you use Auth0 instead of Clerk | The same sign-in gate backed by Auth0 Universal Login. Both are required together, and the container refuses to start if Clerk is configured as well — pick one provider. | | `GEOLIBRE_CONVERSION_ROOTS` | `/data` (the image default) | Confines every sidecar read and write to the mounted directory. | | `GEOLIBRE_POSTGIS_HOSTS` | unset unless needed | The sidecar's PostGIS endpoints refuse every destination until this names the allowed databases, so a caller cannot aim them at hosts only the container can reach. | | `GEOLIBRE_DISABLE_SIDECAR` | `1` if you do not need it | Runs nginx only. | diff --git a/package-lock.json b/package-lock.json index 76938d76e8..3236699cc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "version": "2.5.0", "dependencies": { "@anthropic-ai/sdk": "^0.116.0", + "@auth0/auth0-react": "^2.23.0", "@carbonplan/zarr-layer": "^0.8.0", "@cereusdb/standard": "^0.2.0", "@clerk/react": "^6.14.1", @@ -419,6 +420,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@auth0/auth0-auth-js": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@auth0/auth0-auth-js/-/auth0-auth-js-1.12.1.tgz", + "integrity": "sha512-YgYOAGmfwO40YOYkYSW7XvE6b+qkdlXdpzBshvOjCioygesUxIw2qRerilW1/8XQV41mnFvQIPnsTdEOHN9BeA==", + "license": "MIT", + "dependencies": { + "jose": "^6.0.8", + "openid-client": "^6.8.0" + } + }, + "node_modules/@auth0/auth0-react": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.23.0.tgz", + "integrity": "sha512-oyvZWPoGPSAEf/ZM+1BxOBqCsS7l8vxA+USR62C41z4bmgvulWEafNJDx9X0E91qPkHc9mEjwdah66GaZuQLeg==", + "license": "MIT", + "dependencies": { + "@auth0/auth0-spa-js": "^2.24.1" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1", + "react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" + } + }, + "node_modules/@auth0/auth0-spa-js": { + "version": "2.24.1", + "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.24.1.tgz", + "integrity": "sha512-iS18fBlWWxQ/FPtWgWzYYEImsNRumJG2cZ08xV0HsEmWtTBv+54A+fnyFx9eiNBP7cLp0emwDDjgeG+hQEbVBA==", + "license": "MIT", + "dependencies": { + "@auth0/auth0-auth-js": "^1.10.0", + "browser-tabs-lock": "^1.3.0", + "dpop": "^2.1.1", + "es-cookie": "~1.3.2" + } + }, "node_modules/@aws-sdk/client-bedrock-runtime": { "version": "3.1107.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1107.0.tgz", @@ -12425,6 +12461,16 @@ "integrity": "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==", "license": "MIT" }, + "node_modules/browser-tabs-lock": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz", + "integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "lodash": ">=4.17.21" + } + }, "node_modules/browserslist": { "version": "4.28.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", @@ -13392,6 +13438,15 @@ "url": "https://dotenvx.com" } }, + "node_modules/dpop": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz", + "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/draco3d": { "version": "1.5.7", "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", @@ -13613,6 +13668,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-cookie": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz", + "integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -16438,9 +16499,9 @@ } }, "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -17051,6 +17112,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", @@ -18325,6 +18392,15 @@ "fflate": "^0.8.0" } }, + "node_modules/oauth4webapi": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.7.tgz", + "integrity": "sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -18521,6 +18597,19 @@ } } }, + "node_modules/openid-client": { + "version": "6.8.5", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.5.tgz", + "integrity": "sha512-jNGC/5wnTYwCcEUe2ss0IRUmVRQcgxM0A1nLb3eX/9llqNbMWOQd2xd+qDAgfVCpA5Qh96Y1cdnkfbva6+bSdA==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.8", + "oauth4webapi": "^3.8.7" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/tests/auth-gate.test.ts b/tests/auth-gate.test.ts new file mode 100644 index 0000000000..19b4dcb6cb --- /dev/null +++ b/tests/auth-gate.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { AUTH0_CLIENT_ID_ENV, AUTH0_DOMAIN_ENV } from "../apps/geolibre-desktop/src/lib/auth0-auth"; +import { resolveAuthGate } from "../apps/geolibre-desktop/src/lib/auth-gate"; +import { + CLERK_PUBLISHABLE_KEY_ENV, + CLERK_WAITLIST_ENV, +} from "../apps/geolibre-desktop/src/lib/clerk-auth"; + +type Env = Record; + +const CLERK_KEY = "pk_live_Y2xlcmsuZXhhbXBsZS5jb20k"; +const AUTH0 = { domain: "tenant.us.auth0.com", clientId: "aBcD1234efGh5678" }; + +/** An env record naming Clerk. */ +function clerkEnv(waitlist?: string): Env { + return { [CLERK_PUBLISHABLE_KEY_ENV]: CLERK_KEY, [CLERK_WAITLIST_ENV]: waitlist }; +} + +/** An env record naming Auth0. */ +function auth0Env(): Env { + return { [AUTH0_DOMAIN_ENV]: AUTH0.domain, [AUTH0_CLIENT_ID_ENV]: AUTH0.clientId }; +} + +describe("sign-in gate selection", () => { + it("stays off when neither provider is configured", () => { + assert.equal(resolveAuthGate(true, {}, {}), undefined); + }); + + it("selects Clerk when only Clerk is configured", () => { + assert.deepEqual(resolveAuthGate(true, clerkEnv(), {}), { + provider: "clerk", + publishableKey: CLERK_KEY, + waitlist: false, + }); + }); + + it("selects Auth0 when only Auth0 is configured", () => { + assert.deepEqual(resolveAuthGate(true, auth0Env(), {}), { provider: "auth0", ...AUTH0 }); + }); + + it("turns a build-time Clerk key's waitlist on from the deployment env", () => { + const gate = resolveAuthGate(true, { [CLERK_WAITLIST_ENV]: "1" }, clerkEnv()); + assert.deepEqual(gate, { provider: "clerk", publishableKey: CLERK_KEY, waitlist: true }); + }); + + it("keeps Clerk when both are named at the same level", () => { + // Both in the deployment env... + assert.equal( + resolveAuthGate(true, { ...clerkEnv(), ...auth0Env() }, {})?.provider, + "clerk", + "deployment env", + ); + // ...and both baked into the build. + assert.equal( + resolveAuthGate(true, {}, { ...clerkEnv(), ...auth0Env() })?.provider, + "clerk", + "build env", + ); + }); + + it("lets a deployment-configured provider override a build-configured one", () => { + assert.deepEqual(resolveAuthGate(true, auth0Env(), clerkEnv()), { + provider: "auth0", + ...AUTH0, + }); + assert.deepEqual(resolveAuthGate(true, clerkEnv(), auth0Env()), { + provider: "clerk", + publishableKey: CLERK_KEY, + waitlist: false, + }); + }); + + it("completes a split Auth0 configuration across both levels", () => { + const gate = resolveAuthGate( + true, + { [AUTH0_DOMAIN_ENV]: AUTH0.domain }, + { [AUTH0_CLIENT_ID_ENV]: AUTH0.clientId }, + ); + assert.deepEqual(gate, { provider: "auth0", ...AUTH0 }); + }); + + it("never gates native or embedded applications", () => { + assert.equal(resolveAuthGate(false, { ...clerkEnv(), ...auth0Env() }, {}), undefined); + }); +}); diff --git a/tests/auth0-auth.test.ts b/tests/auth0-auth.test.ts new file mode 100644 index 0000000000..4cf7e6c33d --- /dev/null +++ b/tests/auth0-auth.test.ts @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + AUTH0_CLIENT_ID_ENV, + AUTH0_DOMAIN_ENV, + resolveAuth0Config, +} from "../apps/geolibre-desktop/src/lib/auth0-auth"; + +const CLIENT_ID = "aBcD1234efGh5678ijKl9012mnOp3456"; + +/** Build an env record for the two Auth0 variables. */ +function env(domain?: string, clientId?: string): Record { + return { [AUTH0_DOMAIN_ENV]: domain, [AUTH0_CLIENT_ID_ENV]: clientId }; +} + +/** + * Run `body` with console.error silenced. + * + * The resolver logs when it rejects a half or malformed configuration, which is + * the point — but it would otherwise print on every negative assertion here. + */ +function quietly(body: () => T): T { + const original = console.error; + console.error = () => {}; + try { + return body(); + } finally { + console.error = original; + } +} + +describe("optional Auth0 authentication", () => { + it("stays disabled when nothing is configured", () => { + assert.equal(resolveAuth0Config(true, {}, {}), undefined); + }); + + it("resolves a complete configuration", () => { + assert.deepEqual(resolveAuth0Config(true, env("tenant.us.auth0.com", CLIENT_ID), {}), { + domain: "tenant.us.auth0.com", + clientId: CLIENT_ID, + }); + }); + + it("normalizes a domain pasted as a URL", () => { + for (const domain of [ + "https://tenant.us.auth0.com", + "https://tenant.us.auth0.com/", + "http://tenant.us.auth0.com", + " Tenant.US.auth0.com ", + ]) { + assert.deepEqual( + resolveAuth0Config(true, env(domain, CLIENT_ID), {})?.domain, + "tenant.us.auth0.com", + domain, + ); + } + }); + + it("rejects a domain that is not a bare hostname", () => { + for (const domain of ["localhost", "tenant.us.auth0.com:8443", "user@tenant.us.auth0.com"]) { + assert.equal( + quietly(() => resolveAuth0Config(true, env(domain, CLIENT_ID), {})), + undefined, + ); + } + }); + + it("rejects a client ID outside Auth0's charset", () => { + for (const clientId of ["not a client id", '"quoted"', "https://example.com"]) { + assert.equal( + quietly(() => resolveAuth0Config(true, env("tenant.us.auth0.com", clientId), {})), + undefined, + ); + } + }); + + it("stays disabled — loudly — on a half configuration", () => { + for (const record of [env("tenant.us.auth0.com", undefined), env(undefined, CLIENT_ID)]) { + const messages: unknown[] = []; + const original = console.error; + console.error = (message: unknown) => messages.push(message); + try { + assert.equal(resolveAuth0Config(true, record, {}), undefined); + } finally { + console.error = original; + } + assert.equal(messages.length, 1); + } + }); + + it("says nothing when the gate is simply unconfigured", () => { + const messages: unknown[] = []; + const original = console.error; + console.error = (message: unknown) => messages.push(message); + try { + assert.equal(resolveAuth0Config(true, {}, {}), undefined); + } finally { + console.error = original; + } + assert.deepEqual(messages, []); + }); + + it("prefers the Docker runtime values over the build-time values", () => { + assert.deepEqual( + resolveAuth0Config( + true, + env(" runtime.us.auth0.com ", "runtimeClientId"), + env("build.us.auth0.com", "buildClientId"), + ), + { domain: "runtime.us.auth0.com", clientId: "runtimeClientId" }, + ); + }); + + it("falls back to the build-time values", () => { + assert.deepEqual(resolveAuth0Config(true, {}, env("build.us.auth0.com", "buildClientId")), { + domain: "build.us.auth0.com", + clientId: "buildClientId", + }); + }); + + it("never gates native or embedded applications", () => { + assert.equal( + resolveAuth0Config( + false, + env("tenant.us.auth0.com", CLIENT_ID), + env("build.us.auth0.com", "buildClientId"), + ), + undefined, + ); + }); +}); From 5492fa2d2d6c8657d2896beee3a23bc5447a3648 Mon Sep 17 00:00:00 2001 From: giswqs Date: Tue, 11 Aug 2026 10:25:07 -0400 Subject: [PATCH 2/5] Address CodeRabbit review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth-gate.ts: count either half of the Auth0 pair as naming Auth0 in the deployment env. The pair can be split across the runtime and build tiers, so a deployment supplying only the client ID at runtime had still named Auth0 there, but checking the domain alone handed it back to Clerk. Covered by a new precedence test. - auth0-auth.ts: decide whether to log an incomplete configuration from the raw environment values rather than the normalized ones. Two malformed values both normalize to undefined, so the loudest misconfiguration — nothing usable at all — was the one case that disabled the gate silently. The half/malformed test now covers both-malformed and each single-malformed value. - docs/getting-started.md: Allowed Web Origins takes the origin only, with no trailing slash and no path, unlike Allowed Callback/Logout URLs. The previous text told operators to paste the same trailing-slash URL into all three, which for a subpath deployment would break silent authentication. --- apps/geolibre-desktop/src/lib/auth-gate.ts | 9 +++++++-- apps/geolibre-desktop/src/lib/auth0-auth.ts | 15 +++++++++------ docs/getting-started.md | 18 ++++++++++++------ tests/auth-gate.test.ts | 11 +++++++++++ tests/auth0-auth.test.ts | 16 +++++++++++++--- 5 files changed, 52 insertions(+), 17 deletions(-) diff --git a/apps/geolibre-desktop/src/lib/auth-gate.ts b/apps/geolibre-desktop/src/lib/auth-gate.ts index bc7129b90e..40e511122c 100644 --- a/apps/geolibre-desktop/src/lib/auth-gate.ts +++ b/apps/geolibre-desktop/src/lib/auth-gate.ts @@ -1,4 +1,4 @@ -import { AUTH0_DOMAIN_ENV, resolveAuth0Config } from "./auth0-auth"; +import { AUTH0_CLIENT_ID_ENV, AUTH0_DOMAIN_ENV, resolveAuth0Config } from "./auth0-auth"; import { CLERK_PUBLISHABLE_KEY_ENV, resolveClerkPublishableKey, @@ -57,7 +57,12 @@ export function resolveAuthGate( const clerkNamedAtRuntime = Boolean( readDeploymentEnvValue(CLERK_PUBLISHABLE_KEY_ENV, deployment, {}), ); - const auth0NamedAtRuntime = Boolean(readDeploymentEnvValue(AUTH0_DOMAIN_ENV, deployment, {})); + // Either half counts: the pair can be split across the two tiers, so a + // deployment that supplies only the client ID at runtime has still named + // Auth0 there, and checking the domain alone would hand it back to Clerk. + const auth0NamedAtRuntime = + Boolean(readDeploymentEnvValue(AUTH0_DOMAIN_ENV, deployment, {})) || + Boolean(readDeploymentEnvValue(AUTH0_CLIENT_ID_ENV, deployment, {})); if (auth0NamedAtRuntime && !clerkNamedAtRuntime) return { provider: "auth0", ...auth0 }; return clerk(); } diff --git a/apps/geolibre-desktop/src/lib/auth0-auth.ts b/apps/geolibre-desktop/src/lib/auth0-auth.ts index fe77b69f5f..38c99a1b37 100644 --- a/apps/geolibre-desktop/src/lib/auth0-auth.ts +++ b/apps/geolibre-desktop/src/lib/auth0-auth.ts @@ -79,14 +79,17 @@ export function resolveAuth0Config( buildEnv?: EnvRecord, ): Auth0Config | undefined { if (!webApp) return undefined; - const domain = normalizeDomain(readDeploymentEnvValue(AUTH0_DOMAIN_ENV, deploymentEnv, buildEnv)); - const clientId = normalizeClientId( - readDeploymentEnvValue(AUTH0_CLIENT_ID_ENV, deploymentEnv, buildEnv), - ); + const rawDomain = readDeploymentEnvValue(AUTH0_DOMAIN_ENV, deploymentEnv, buildEnv); + const rawClientId = readDeploymentEnvValue(AUTH0_CLIENT_ID_ENV, deploymentEnv, buildEnv); + const domain = normalizeDomain(rawDomain); + const clientId = normalizeClientId(rawClientId); if (!domain || !clientId) { // Only complain when something was configured: an unset gate is the normal - // case for every public deployment and must stay silent. - if (domain || clientId) { + // case for every public deployment and must stay silent. Tested on the raw + // values, not the normalized ones — two malformed values normalize to + // undefined, and reading those would turn the loudest misconfiguration + // (nothing usable at all) into the one that says nothing. + if (rawDomain || rawClientId) { console.error( `[GeoLibre] Ignoring an incomplete Auth0 configuration: ${AUTH0_DOMAIN_ENV} and ` + `${AUTH0_CLIENT_ID_ENV} must both be set to valid values. The sign-in gate is OFF.`, diff --git a/docs/getting-started.md b/docs/getting-started.md index 02cc4e2e2f..0d8712ced7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -349,12 +349,18 @@ request, so `?embed=1` cannot switch it off. The build-time equivalents are `VITE_GEOLIBRE_AUTH0_DOMAIN` and `VITE_GEOLIBRE_AUTH0_CLIENT_ID`; the runtime variables win when both are present. -In the Auth0 application's settings, add the deployment URL **with its trailing -slash** to all three of **Allowed Callback URLs**, **Allowed Logout URLs**, and -**Allowed Web Origins** — for example `https://gis.example.com/`, or -`https://gis.example.com/geolibre/` for a [subpath -deployment](#subpath-and-onboarding-build-arguments). Auth0 matches those -exactly, and a missing entry surfaces as a callback error instead of a login. +In the Auth0 application's settings, these three fields do not take the same +value: + +- **Allowed Callback URLs** and **Allowed Logout URLs** take the deployment URL + **with its trailing slash** — `https://gis.example.com/`, or + `https://gis.example.com/geolibre/` for a [subpath + deployment](#subpath-and-onboarding-build-arguments). Auth0 matches these + exactly, and a missing entry surfaces as a callback error instead of a login. +- **Allowed Web Origins** takes the **origin only** — no trailing slash and no + path, so `https://gis.example.com` even for a subpath deployment. A path here + is not matched and breaks the silent-authentication request that restores an + existing session. Auth0 has no embedded sign-in card, so GeoLibre uses **Universal Login**: the visitor clicks *Sign in*, is redirected to your tenant's hosted login page, and diff --git a/tests/auth-gate.test.ts b/tests/auth-gate.test.ts index 19b4dcb6cb..2940a24453 100644 --- a/tests/auth-gate.test.ts +++ b/tests/auth-gate.test.ts @@ -71,6 +71,17 @@ describe("sign-in gate selection", () => { }); }); + it("counts either half of a split Auth0 pair as naming Auth0 at runtime", () => { + // Only the client ID is set at runtime; the domain and a Clerk key come + // from the build. Auth0 is still the provider the deployment asked for. + const gate = resolveAuthGate( + true, + { [AUTH0_CLIENT_ID_ENV]: AUTH0.clientId }, + { ...clerkEnv(), [AUTH0_DOMAIN_ENV]: AUTH0.domain }, + ); + assert.deepEqual(gate, { provider: "auth0", ...AUTH0 }); + }); + it("completes a split Auth0 configuration across both levels", () => { const gate = resolveAuthGate( true, diff --git a/tests/auth0-auth.test.ts b/tests/auth0-auth.test.ts index 4cf7e6c33d..3e7f34ce36 100644 --- a/tests/auth0-auth.test.ts +++ b/tests/auth0-auth.test.ts @@ -74,8 +74,18 @@ describe("optional Auth0 authentication", () => { } }); - it("stays disabled — loudly — on a half configuration", () => { - for (const record of [env("tenant.us.auth0.com", undefined), env(undefined, CLIENT_ID)]) { + it("stays disabled — loudly — on a half or malformed configuration", () => { + const records = [ + env("tenant.us.auth0.com", undefined), + env(undefined, CLIENT_ID), + // One malformed value alongside a good one... + env("localhost", CLIENT_ID), + env("tenant.us.auth0.com", "not a client id"), + // ...and both malformed, which normalizes to the same shape as "unset" + // and so is the case most at risk of failing silently. + env("localhost", "not a client id"), + ]; + for (const record of records) { const messages: unknown[] = []; const original = console.error; console.error = (message: unknown) => messages.push(message); @@ -84,7 +94,7 @@ describe("optional Auth0 authentication", () => { } finally { console.error = original; } - assert.equal(messages.length, 1); + assert.equal(messages.length, 1, JSON.stringify(record)); } }); From 47ddcb8eda4c06df14d90dcd7b56088b8dd2b9d4 Mon Sep 17 00:00:00 2001 From: giswqs Date: Tue, 11 Aug 2026 10:36:36 -0400 Subject: [PATCH 3/5] Address Claude review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore a deep link's query before the boot-time settings read it. Auth0 returns to the registered callback URL, so `?locale=`/`?theme=` were gone on the load that followed. `onRedirectCallback` put the address bar back, but it runs in a React effect — after `getInitialLanguage()` has already resolved the UI language at module scope during `import "./i18n"`. The query is now stashed before the redirect leaves and merged back in on the callback load, ahead of that read (lib/auth-return-url.ts, imported for its side effect above ./i18n). Confirmed with a negative control: with the restore disabled, a visit to `?theme=dark&locale=fr` comes back English. The theme self-healed already, since useThemeMode re-reads the URL when App mounts after the callback, but it now also avoids a light-to-dark flash. - Document the localStorage session cache for operators, and say in the code what bounds it: no API audience and no refresh token are requested, so the cached entry is a short-lived identity assertion that cannot be renewed. It does outlive the tab and is readable by same-origin plugin code, which the Auth0 section of the docs now states. --- .../src/components/auth/Auth0Gate.tsx | 17 +++- .../src/lib/auth-return-url-boot.ts | 10 +++ .../src/lib/auth-return-url.ts | 84 +++++++++++++++++++ apps/geolibre-desktop/src/main.tsx | 7 ++ docs/getting-started.md | 13 ++- tests/auth-return-url.test.ts | 40 +++++++++ 6 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 apps/geolibre-desktop/src/lib/auth-return-url-boot.ts create mode 100644 apps/geolibre-desktop/src/lib/auth-return-url.ts create mode 100644 tests/auth-return-url.test.ts diff --git a/apps/geolibre-desktop/src/components/auth/Auth0Gate.tsx b/apps/geolibre-desktop/src/components/auth/Auth0Gate.tsx index debe13badf..717b9a0c3b 100644 --- a/apps/geolibre-desktop/src/components/auth/Auth0Gate.tsx +++ b/apps/geolibre-desktop/src/components/auth/Auth0Gate.tsx @@ -12,6 +12,7 @@ import { AlertTriangle, LogOut, User } from "lucide-react"; import { useCallback, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useBeforeUnloadGuard } from "../../hooks/useBeforeUnloadGuard"; +import { stashAuthReturnQuery } from "../../lib/auth-return-url"; interface Auth0GateProps { /** Tenant (or custom) domain, already normalized to a bare hostname. */ @@ -123,7 +124,12 @@ function Auth0Screens({ children }: { children: ReactNode }) { // Preserve the URL the visitor arrived on — Auth0 returns to the registered // callback URL, which would otherwise drop a shared `?project=…` link. + // `returnTo` restores the address bar once the SDK has processed the callback; + // the stash additionally puts the query back *before* the next load reads it, + // for the settings resolved during boot (`?locale=`, `?theme=`). See + // lib/auth-return-url.ts. const signIn = useCallback(() => { + stashAuthReturnQuery(); void loginWithRedirect({ appState: { returnTo: window.location.href } }); }, [loginWithRedirect]); @@ -210,9 +216,14 @@ export function Auth0Gate({ domain, clientId, children }: Auth0GateProps) { // Persist the session across reloads. The default in-memory cache would // restore it through a hidden silent-authentication iframe, which fails // wherever third-party cookies are blocked (Safari by default), sending - // the visitor back to the sign-in card after every refresh. Nothing here - // requests an API audience, so the cached token is an identity assertion - // and grants no access to any upstream service on its own. + // the visitor back to the sign-in card after every refresh. + // + // The trade is that the cached entry outlives the tab and is readable by + // anything running on this origin, plugins included. What it holds is + // bounded on purpose: no API audience is requested and `useRefreshTokens` + // is left off, so it is a short-lived identity assertion that cannot be + // renewed and opens no upstream service on its own. Documented for + // operators in the Auth0 section of docs/getting-started.md. cacheLocation="localstorage" > {children} diff --git a/apps/geolibre-desktop/src/lib/auth-return-url-boot.ts b/apps/geolibre-desktop/src/lib/auth-return-url-boot.ts new file mode 100644 index 0000000000..7c7999e32e --- /dev/null +++ b/apps/geolibre-desktop/src/lib/auth-return-url-boot.ts @@ -0,0 +1,10 @@ +// Side-effect entry point for restoring a deep link after a sign-in redirect. +// +// This exists as its own module purely so the restore happens at *import* time. +// A call in `main.tsx`'s body would run after every one of its imports has been +// evaluated — including `./i18n`, which resolves the UI language from the query +// string while it loads. Importing this above `./i18n` is what puts the query +// back in time to be read. See `auth-return-url.ts` for why. +import { restoreAuthReturnQuery } from "./auth-return-url"; + +restoreAuthReturnQuery(); diff --git a/apps/geolibre-desktop/src/lib/auth-return-url.ts b/apps/geolibre-desktop/src/lib/auth-return-url.ts new file mode 100644 index 0000000000..1b89e34e79 --- /dev/null +++ b/apps/geolibre-desktop/src/lib/auth-return-url.ts @@ -0,0 +1,84 @@ +// Carries a deep link's query parameters across a sign-in redirect. +// +// The Auth0 gate sends the visitor to the tenant's hosted login page and Auth0 +// returns them to one registered callback URL, so everything after the `?` is +// gone on the load that follows. `Auth0Gate`'s `onRedirectCallback` puts the +// original URL back, but that runs inside a React effect — too late for the two +// settings the app resolves *synchronously while booting*, before any component +// mounts: the UI language (`getInitialLanguage()`, called at module scope during +// `import "./i18n"`) and the theme (`getInitialThemeMode()`). Without this, a +// visitor arriving at `?theme=dark&locale=fr` on a gated deployment gets their +// OS theme and persisted language on the first paint after signing in, and their +// actual choice only on the next reload. +// +// So the query is stashed before the redirect leaves and merged back in on the +// callback load, ahead of those reads. Only the query and hash are stored, never +// a URL: nothing here can send the visitor anywhere. + +/** sessionStorage key holding the query+hash a sign-in redirect is about to lose. */ +const STASH_KEY = "geolibre.auth.returnQuery"; + +/** + * Merge stashed query parameters into the callback URL's own. + * + * The callback's parameters win, so the single-use `code`/`state` Auth0 appended + * are never shadowed by a stale stash. A key the callback does not carry is + * restored with *all* of its values, so a repeated parameter survives intact. + * + * @param currentSearch - `location.search` of the callback URL. + * @param stashedSearch - The query string saved before the redirect. + * @returns A `?`-prefixed query string, or `""` when the result is empty. + */ +export function mergeStashedQuery(currentSearch: string, stashedSearch: string): string { + const current = new URLSearchParams(currentSearch); + const stashed = new URLSearchParams(stashedSearch); + for (const key of new Set(stashed.keys())) { + if (current.has(key)) continue; + for (const value of stashed.getAll(key)) current.append(key, value); + } + const merged = current.toString(); + return merged ? `?${merged}` : ""; +} + +/** + * Remember the current query and hash before a sign-in redirect leaves the page. + * + * A no-op when sessionStorage is unavailable (private-mode restrictions, storage + * disabled): the deep link is then lost on the round trip exactly as it was + * before, which is a cosmetic loss and never a reason to block signing in. + */ +export function stashAuthReturnQuery(): void { + try { + sessionStorage.setItem(STASH_KEY, window.location.search + window.location.hash); + } catch { + // Storage blocked — see above. + } +} + +/** + * Put a stashed deep link back, if this load is a sign-in callback. + * + * Must run before anything reads `location.search` for a startup setting — see + * the note on its import in `main.tsx`. Consumes the stash either way, so a + * stale entry cannot leak into an unrelated later navigation. + */ +export function restoreAuthReturnQuery(): void { + let stashed: string | null = null; + try { + stashed = sessionStorage.getItem(STASH_KEY); + if (stashed !== null) sessionStorage.removeItem(STASH_KEY); + } catch { + return; + } + if (!stashed) return; + const params = new URLSearchParams(window.location.search); + // Only an authorization-code callback gets its query rewritten. Any other load + // reaching a leftover stash (a new tab, an abandoned login) keeps its own URL. + if (!params.has("code") || !params.has("state")) return; + const hashAt = stashed.indexOf("#"); + const stashedSearch = hashAt === -1 ? stashed : stashed.slice(0, hashAt); + const stashedHash = hashAt === -1 ? "" : stashed.slice(hashAt); + const search = mergeStashedQuery(window.location.search, stashedSearch); + const hash = window.location.hash || stashedHash; + window.history.replaceState({}, "", `${window.location.pathname}${search}${hash}`); +} diff --git a/apps/geolibre-desktop/src/main.tsx b/apps/geolibre-desktop/src/main.tsx index 1fb13c4137..973e92ceef 100644 --- a/apps/geolibre-desktop/src/main.tsx +++ b/apps/geolibre-desktop/src/main.tsx @@ -47,6 +47,13 @@ import { registerSW } from "virtual:pwa-register"; import { TooltipProvider } from "@geolibre/ui"; import { I18nextProvider } from "react-i18next"; import type { ReactNode } from "react"; +// Puts a deep link's query back after a sign-in redirect dropped it. This import +// MUST stay above `./i18n` below: it does its work while loading, and `./i18n` +// resolves the UI language from the query string while *it* loads, so a later +// position would restore the parameters after they had already been read. Same +// for the theme, resolved further down this file. A no-op when no sign-in +// redirect is in flight, so every other build just pays for an empty module. +import "./lib/auth-return-url-boot"; // Initializes i18next (resolves the UI language from the `?locale`/`?lang` query // param, stored settings, or the browser) before React renders, so the first // paint is already in the right language. English is bundled; other locales are diff --git a/docs/getting-started.md b/docs/getting-started.md index 0d8712ced7..a8d5295010 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -381,10 +381,15 @@ Who may sign in is decided in the Auth0 Dashboard, not by GeoLibre: There is no equivalent of Clerk's waitlist form, so `GEOLIBRE_CLERK_WAITLIST` has no Auth0 counterpart. -The session is cached in the browser so a page reload does not bounce through -the login page again. Nothing here requests an API audience, so that cached -token is an identity assertion only and grants no access to any upstream -service by itself. +The session is cached in the browser's local storage so a page reload does not +bounce through the login page again. Two things follow from that, worth knowing +before you enable the gate. Nothing here requests an API audience or a refresh +token, so what is cached is a short-lived identity assertion that cannot be +renewed and grants no access to any upstream service by itself. But it does +outlive the tab, and GeoLibre runs plugins on the same origin as the app — a +plugin you install can read it, as it could any other same-origin storage. +Install plugins you trust, and keep the server-side protections below in place +regardless. Like the Clerk gate, this controls access to the GeoLibre interface but is not a server authorization boundary. Keep `/sidecar`, `/ai`, and any other sensitive diff --git a/tests/auth-return-url.test.ts b/tests/auth-return-url.test.ts new file mode 100644 index 0000000000..cad9ee2a94 --- /dev/null +++ b/tests/auth-return-url.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { mergeStashedQuery } from "../apps/geolibre-desktop/src/lib/auth-return-url"; + +describe("restoring a deep link across a sign-in redirect", () => { + it("adds the stashed parameters to the callback's own", () => { + assert.equal( + mergeStashedQuery("?code=abc&state=xyz", "?locale=fr&theme=dark"), + "?code=abc&state=xyz&locale=fr&theme=dark", + ); + }); + + it("never lets a stale stash shadow the callback's parameters", () => { + // A second login started from a URL that still carried an old code/state + // must not resurrect them — the callback's values are the live ones. + assert.equal( + mergeStashedQuery("?code=new&state=fresh", "?code=old&state=stale&locale=fr"), + "?code=new&state=fresh&locale=fr", + ); + }); + + it("keeps every value of a repeated parameter", () => { + assert.equal( + mergeStashedQuery("?code=abc", "?layer=roads&layer=rivers"), + "?code=abc&layer=roads&layer=rivers", + ); + }); + + it("preserves an encoded value", () => { + const project = "https%3A%2F%2Fexample.com%2Fa.geolibre.json"; + const merged = new URLSearchParams(mergeStashedQuery("?code=abc", `?project=${project}`)); + assert.equal(merged.get("project"), "https://example.com/a.geolibre.json"); + }); + + it("handles an empty stash and an empty callback query", () => { + assert.equal(mergeStashedQuery("?code=abc", ""), "?code=abc"); + assert.equal(mergeStashedQuery("", "?locale=fr"), "?locale=fr"); + assert.equal(mergeStashedQuery("", ""), ""); + }); +}); From ebd80111ddf806b7be706415c865c27bf824f902 Mon Sep 17 00:00:00 2001 From: giswqs Date: Tue, 11 Aug 2026 10:44:35 -0400 Subject: [PATCH 4/5] Restore the deep link on a refused login too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard required both `code` and `state`, so it matched only a successful return from Auth0. A refusal — an Action calling api.access.deny(), a declined consent — comes back as `error`/`error_description` with `state` and no `code`, which fell through and discarded the stash. The theme and language the visitor asked for were then missing from the one screen that has something to tell them. Extracted the check as `isSignInCallback`, which now requires `state` (Auth0's CSRF token, present on every return) plus either `code` or `error`, and unit tested both returns along with the loads that must not qualify. Verified against the running app with the tenant stubbed to refuse: the denial lands on the error screen showing Auth0's own reason, with the requested language and theme applied. Note the auth.* strings themselves are English in every non-English catalog today — pre-existing, and equally true of the Clerk gate — so this restores the language, not yet the translation. --- .../src/lib/auth-return-url.ts | 26 +++++++++++++--- tests/auth-return-url.test.ts | 31 ++++++++++++++++++- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/apps/geolibre-desktop/src/lib/auth-return-url.ts b/apps/geolibre-desktop/src/lib/auth-return-url.ts index 1b89e34e79..d2d86a071d 100644 --- a/apps/geolibre-desktop/src/lib/auth-return-url.ts +++ b/apps/geolibre-desktop/src/lib/auth-return-url.ts @@ -55,6 +55,25 @@ export function stashAuthReturnQuery(): void { } } +/** + * Whether a query string is Auth0 returning from a login attempt. + * + * `state` is Auth0's CSRF token and rides on every return, successful or not. + * What follows it differs: `code` on success, `error`/`error_description` when + * the tenant refused — an Action calling `api.access.deny()`, a rejected + * consent. Both are returns from the redirect this module compensates for, and + * the refusal is if anything the more important one to cover: it is the screen + * that has something to say, so it is the screen that most needs the visitor's + * own language. + * + * @param search - `location.search` of the current load. + */ +export function isSignInCallback(search: string): boolean { + const params = new URLSearchParams(search); + if (!params.has("state")) return false; + return params.has("code") || params.has("error"); +} + /** * Put a stashed deep link back, if this load is a sign-in callback. * @@ -71,10 +90,9 @@ export function restoreAuthReturnQuery(): void { return; } if (!stashed) return; - const params = new URLSearchParams(window.location.search); - // Only an authorization-code callback gets its query rewritten. Any other load - // reaching a leftover stash (a new tab, an abandoned login) keeps its own URL. - if (!params.has("code") || !params.has("state")) return; + // Only a return from Auth0 gets its query rewritten. Any other load reaching a + // leftover stash (a new tab, an abandoned login) keeps its own URL. + if (!isSignInCallback(window.location.search)) return; const hashAt = stashed.indexOf("#"); const stashedSearch = hashAt === -1 ? stashed : stashed.slice(0, hashAt); const stashedHash = hashAt === -1 ? "" : stashed.slice(hashAt); diff --git a/tests/auth-return-url.test.ts b/tests/auth-return-url.test.ts index cad9ee2a94..7549ce273f 100644 --- a/tests/auth-return-url.test.ts +++ b/tests/auth-return-url.test.ts @@ -1,6 +1,35 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { mergeStashedQuery } from "../apps/geolibre-desktop/src/lib/auth-return-url"; +import { + isSignInCallback, + mergeStashedQuery, +} from "../apps/geolibre-desktop/src/lib/auth-return-url"; + +describe("recognizing a return from Auth0", () => { + it("matches a successful login", () => { + assert.equal(isSignInCallback("?code=abc&state=xyz"), true); + }); + + it("matches a refused login, which carries no code", () => { + // An Action calling api.access.deny() returns error + state and no code. + // This is the screen that most needs the visitor's own language. + assert.equal( + isSignInCallback("?error=access_denied&error_description=Not%20approved&state=xyz"), + true, + ); + }); + + it("ignores a load that is not a return from Auth0", () => { + for (const search of ["", "?project=https%3A%2F%2Fexample.com%2Fa.json", "?theme=dark"]) { + assert.equal(isSignInCallback(search), false, search); + } + }); + + it("requires the CSRF state, so a bare code or error does not qualify", () => { + assert.equal(isSignInCallback("?code=abc"), false); + assert.equal(isSignInCallback("?error=access_denied"), false); + }); +}); describe("restoring a deep link across a sign-in redirect", () => { it("adds the stashed parameters to the callback's own", () => { From 67191c81d512cf76b3b0cfb79a6866b4707a85de Mon Sep 17 00:00:00 2001 From: giswqs Date: Tue, 11 Aug 2026 11:05:32 -0400 Subject: [PATCH 5/5] Address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Keep one login attempt out of the next. auth0-react cleans the callback URL only after a callback it accepted, so a refusal leaves `error` in the address bar; "Try again" then stashed it, the restore merged it into the next, successful callback, and the SDK rejected a login that had actually worked — a permanent lockout, since every retry re-armed it. The stash now strips Auth0's own parameters (CALLBACK_PARAMS, shared with onRedirectCallback so the list has one home). Confirmed with a negative control: without the strip the retry never reaches the map. - Correct what the localStorage session cache is claimed to be. Without `useRefreshTokens` the SDK still renews through silent authentication while the Auth0 session cookie answers, so "cannot be renewed" was wrong. The docs and the code comment now say no refresh token is stored and that renewal depends on that flow, which a browser blocking the cookie will fail. The missing API audience still means no upstream access on its own. - Log the normalized Auth0 host at boot, not the raw variable, so the line matches the host actually written to the runtime config and the CSP frame-src. Uses two scheme-specific sed expressions rather than a case-insensitive match, which is a GNU extension. --- .../src/components/auth/Auth0Gate.tsx | 14 +++--- .../src/lib/auth-return-url.ts | 31 ++++++++++++- docker/entrypoint.sh | 11 ++++- docs/getting-started.md | 16 ++++--- tests/auth-return-url.test.ts | 44 +++++++++++++++++++ 5 files changed, 101 insertions(+), 15 deletions(-) diff --git a/apps/geolibre-desktop/src/components/auth/Auth0Gate.tsx b/apps/geolibre-desktop/src/components/auth/Auth0Gate.tsx index 717b9a0c3b..3bb9c80c6a 100644 --- a/apps/geolibre-desktop/src/components/auth/Auth0Gate.tsx +++ b/apps/geolibre-desktop/src/components/auth/Auth0Gate.tsx @@ -12,7 +12,7 @@ import { AlertTriangle, LogOut, User } from "lucide-react"; import { useCallback, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useBeforeUnloadGuard } from "../../hooks/useBeforeUnloadGuard"; -import { stashAuthReturnQuery } from "../../lib/auth-return-url"; +import { CALLBACK_PARAMS, stashAuthReturnQuery } from "../../lib/auth-return-url"; interface Auth0GateProps { /** Tenant (or custom) domain, already normalized to a bare hostname. */ @@ -46,7 +46,7 @@ function redirectUri(): string { */ function onRedirectCallback(appState?: AppState): void { const url = new URL(appState?.returnTo ?? window.location.href, window.location.href); - for (const param of ["code", "state", "error", "error_description"]) { + for (const param of CALLBACK_PARAMS) { url.searchParams.delete(param); } window.history.replaceState({}, "", url.toString()); @@ -220,10 +220,12 @@ export function Auth0Gate({ domain, clientId, children }: Auth0GateProps) { // // The trade is that the cached entry outlives the tab and is readable by // anything running on this origin, plugins included. What it holds is - // bounded on purpose: no API audience is requested and `useRefreshTokens` - // is left off, so it is a short-lived identity assertion that cannot be - // renewed and opens no upstream service on its own. Documented for - // operators in the Auth0 section of docs/getting-started.md. + // bounded on purpose: no API audience is requested, so it is an identity + // assertion that opens no upstream service on its own, and + // `useRefreshTokens` is left off, so no refresh token is stored — renewal + // goes back through silent authentication against the tenant, which only + // succeeds while the Auth0 session cookie is there to answer it. + // Documented for operators in the Auth0 section of docs/getting-started.md. cacheLocation="localstorage" > {children} diff --git a/apps/geolibre-desktop/src/lib/auth-return-url.ts b/apps/geolibre-desktop/src/lib/auth-return-url.ts index d2d86a071d..fa0c83f99c 100644 --- a/apps/geolibre-desktop/src/lib/auth-return-url.ts +++ b/apps/geolibre-desktop/src/lib/auth-return-url.ts @@ -18,6 +18,30 @@ /** sessionStorage key holding the query+hash a sign-in redirect is about to lose. */ const STASH_KEY = "geolibre.auth.returnQuery"; +/** + * The parameters Auth0 appends to the callback URL, which belong to one attempt. + * + * They must never be carried into the next one. A refused login leaves `error` + * in the address bar — auth0-react only cleans the URL after a callback it + * *accepted*, so the error screen keeps them — and stashing that URL for the + * retry would put `error` back on the next, successful callback, where the SDK + * rejects the whole login. That is a permanent lockout: every retry re-arms it. + */ +export const CALLBACK_PARAMS = ["code", "state", "error", "error_description"] as const; + +/** + * Drop Auth0's callback parameters from a query string. + * + * @param search - A query string, with or without its leading `?`. + * @returns A `?`-prefixed query string, or `""` when nothing is left. + */ +export function stripCallbackParams(search: string): string { + const params = new URLSearchParams(search); + for (const key of CALLBACK_PARAMS) params.delete(key); + const remaining = params.toString(); + return remaining ? `?${remaining}` : ""; +} + /** * Merge stashed query parameters into the callback URL's own. * @@ -43,13 +67,18 @@ export function mergeStashedQuery(currentSearch: string, stashedSearch: string): /** * Remember the current query and hash before a sign-in redirect leaves the page. * + * Only the app's own parameters are kept: signing in again from the error screen + * would otherwise stash that screen's `error`, and hand it to the next attempt. + * See {@link CALLBACK_PARAMS}. + * * A no-op when sessionStorage is unavailable (private-mode restrictions, storage * disabled): the deep link is then lost on the round trip exactly as it was * before, which is a cosmetic loss and never a reason to block signing in. */ export function stashAuthReturnQuery(): void { try { - sessionStorage.setItem(STASH_KEY, window.location.search + window.location.hash); + const search = stripCallbackParams(window.location.search); + sessionStorage.setItem(STASH_KEY, search + window.location.hash); } catch { // Storage blocked — see above. } diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 50d955ea2c..fb5faaa48c 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -420,7 +420,16 @@ if [ -n "$(trim "${GEOLIBRE_CLERK_PUBLISHABLE_KEY:-}")" ]; then fi if [ -n "$(trim "${GEOLIBRE_AUTH0_DOMAIN:-}")" ]; then - echo "Auth0 sign-in gate enabled for $(trim "$GEOLIBRE_AUTH0_DOMAIN")." + # Normalized the same way the Python blocks above do, so an operator diffing + # this line against the generated runtime config and CSP sees the one host all + # three actually use — pasting "https://tenant.us.auth0.com/" is expected, and + # echoing it back verbatim would not match either generated file. Two + # scheme-specific expressions rather than one case-insensitive match, which is + # a GNU sed extension. + echo "Auth0 sign-in gate enabled for $(trim "$GEOLIBRE_AUTH0_DOMAIN" | + tr '[:upper:]' '[:lower:]' | + sed -e 's#^http://##' -e 's#^https://##' | + cut -d/ -f1)." fi # Render the nginx config from the immutable image template on every boot. The diff --git a/docs/getting-started.md b/docs/getting-started.md index a8d5295010..7db1ccf9e2 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -383,13 +383,15 @@ has no Auth0 counterpart. The session is cached in the browser's local storage so a page reload does not bounce through the login page again. Two things follow from that, worth knowing -before you enable the gate. Nothing here requests an API audience or a refresh -token, so what is cached is a short-lived identity assertion that cannot be -renewed and grants no access to any upstream service by itself. But it does -outlive the tab, and GeoLibre runs plugins on the same origin as the app — a -plugin you install can read it, as it could any other same-origin storage. -Install plugins you trust, and keep the server-side protections below in place -regardless. +before you enable the gate. Nothing here requests an API audience, so what is +cached is an identity assertion that grants no access to any upstream service by +itself, and no refresh token is stored — once it expires, renewal goes back +through a silent request to your tenant, which succeeds only while the Auth0 +session cookie is available to answer it (a browser blocking that cookie sends +the visitor to the login page instead). But the cached entry does outlive the +tab, and GeoLibre runs plugins on the same origin as the app — a plugin you +install can read it, as it could any other same-origin storage. Install plugins +you trust, and keep the server-side protections below in place regardless. Like the Clerk gate, this controls access to the GeoLibre interface but is not a server authorization boundary. Keep `/sidecar`, `/ai`, and any other sensitive diff --git a/tests/auth-return-url.test.ts b/tests/auth-return-url.test.ts index 7549ce273f..0973ac2b14 100644 --- a/tests/auth-return-url.test.ts +++ b/tests/auth-return-url.test.ts @@ -3,8 +3,52 @@ import { describe, it } from "node:test"; import { isSignInCallback, mergeStashedQuery, + stripCallbackParams, } from "../apps/geolibre-desktop/src/lib/auth-return-url"; +describe("keeping one login attempt out of the next", () => { + it("drops Auth0's parameters, keeping the app's", () => { + assert.equal( + stripCallbackParams("?code=abc&state=xyz&project=p1&locale=fr"), + "?project=p1&locale=fr", + ); + }); + + it("drops the parameters a refusal leaves behind", () => { + // The error screen keeps these: auth0-react cleans the URL only after a + // callback it accepted. Signing in again from there must not carry them. + assert.equal( + stripCallbackParams("?error=access_denied&error_description=Nope&state=xyz&locale=fr"), + "?locale=fr", + ); + }); + + it("returns an empty string when only Auth0's parameters were present", () => { + assert.equal(stripCallbackParams("?code=abc&state=xyz"), ""); + assert.equal(stripCallbackParams(""), ""); + }); + + it("survives a retry round trip without re-arming the error", () => { + // Deep link → refused → "Try again" stashes the error screen's URL → a + // successful callback merges it back. `error` must not reappear, or the SDK + // rejects the login that just succeeded and every retry does it again. + const deepLink = "?project=p1&locale=fr"; + const refused = mergeStashedQuery( + "?error=access_denied&error_description=Nope&state=s1", + stripCallbackParams(deepLink), + ); + const retryStash = stripCallbackParams(refused); + const accepted = mergeStashedQuery("?code=c2&state=s2", retryStash); + const params = new URLSearchParams(accepted); + assert.equal(params.has("error"), false, accepted); + assert.equal(params.has("error_description"), false, accepted); + assert.equal(params.get("code"), "c2"); + assert.equal(params.get("state"), "s2"); + assert.equal(params.get("project"), "p1"); + assert.equal(params.get("locale"), "fr"); + }); +}); + describe("recognizing a return from Auth0", () => { it("matches a successful login", () => { assert.equal(isSignInCallback("?code=abc&state=xyz"), true);