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 ( +
+ + + + + + {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. + // `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 ( + + +
+

{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 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, + ); + }); +});