diff --git a/apps/geolibre-desktop/package.json b/apps/geolibre-desktop/package.json
index d0b0a0419..b4ef37174 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 000000000..3bb9c80c6
--- /dev/null
+++ b/apps/geolibre-desktop/src/components/auth/Auth0Gate.tsx
@@ -0,0 +1,234 @@
+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";
+import { CALLBACK_PARAMS, stashAuthReturnQuery } from "../../lib/auth-return-url";
+
+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 CALLBACK_PARAMS) {
+ 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 (
+
+ );
+}
+
+/** 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.
+ // `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]);
+
+ 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 (
+
+
+
+
+
+ );
+ }
+
+ 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 (
+
+ {children}
+
+ );
+}
diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json
index 1787311d9..437bd6f8b 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 000000000..40e511122
--- /dev/null
+++ b/apps/geolibre-desktop/src/lib/auth-gate.ts
@@ -0,0 +1,72 @@
+import { AUTH0_CLIENT_ID_ENV, 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, {}),
+ );
+ // 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();
+ }
+ if (clerkKey) return clerk();
+ if (auth0) return { provider: "auth0", ...auth0 };
+ return undefined;
+}
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 000000000..7c7999e32
--- /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 000000000..fa0c83f99
--- /dev/null
+++ b/apps/geolibre-desktop/src/lib/auth-return-url.ts
@@ -0,0 +1,131 @@
+// 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";
+
+/**
+ * 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.
+ *
+ * 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.
+ *
+ * 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 {
+ const search = stripCallbackParams(window.location.search);
+ sessionStorage.setItem(STASH_KEY, search + window.location.hash);
+ } catch {
+ // Storage blocked — see above.
+ }
+}
+
+/**
+ * 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.
+ *
+ * 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;
+ // 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);
+ 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/lib/auth0-auth.ts b/apps/geolibre-desktop/src/lib/auth0-auth.ts
new file mode 100644
index 000000000..38c99a1b3
--- /dev/null
+++ b/apps/geolibre-desktop/src/lib/auth0-auth.ts
@@ -0,0 +1,101 @@
+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 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. 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.`,
+ );
+ }
+ return undefined;
+ }
+ return { domain, clientId };
+}
diff --git a/apps/geolibre-desktop/src/main.tsx b/apps/geolibre-desktop/src/main.tsx
index fa964777b..973e92cee 100644
--- a/apps/geolibre-desktop/src/main.tsx
+++ b/apps/geolibre-desktop/src/main.tsx
@@ -46,6 +46,14 @@ 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";
+// 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
@@ -55,7 +63,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 +111,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 +192,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 39e241cc4..77043c043 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 1e48be1f5..ba7dbe834 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 aad396281..fb5faaa48 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,19 @@ if [ -n "$(trim "${GEOLIBRE_CLERK_PUBLISHABLE_KEY:-}")" ]; then
esac
fi
+if [ -n "$(trim "${GEOLIBRE_AUTH0_DOMAIN:-}")" ]; then
+ # 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
# 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 +495,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 +520,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 693ed8647..6a98d581c 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 f93474ea2..7db1ccf9e 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,80 @@ 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, 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
+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'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, 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
+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 7919b0db2..b86a32df3 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 76938d76e..3236699cc 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 000000000..2940a2445
--- /dev/null
+++ b/tests/auth-gate.test.ts
@@ -0,0 +1,97 @@
+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("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,
+ { [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/auth-return-url.test.ts b/tests/auth-return-url.test.ts
new file mode 100644
index 000000000..0973ac2b1
--- /dev/null
+++ b/tests/auth-return-url.test.ts
@@ -0,0 +1,113 @@
+import assert from "node:assert/strict";
+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);
+ });
+
+ 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", () => {
+ 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("", ""), "");
+ });
+});
diff --git a/tests/auth0-auth.test.ts b/tests/auth0-auth.test.ts
new file mode 100644
index 000000000..3e7f34ce3
--- /dev/null
+++ b/tests/auth0-auth.test.ts
@@ -0,0 +1,141 @@
+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 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);
+ try {
+ assert.equal(resolveAuth0Config(true, record, {}), undefined);
+ } finally {
+ console.error = original;
+ }
+ assert.equal(messages.length, 1, JSON.stringify(record));
+ }
+ });
+
+ 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,
+ );
+ });
+});