geeViz.eeAuth

Earth Engine multi-tenant auth proxy + client helpers.

A drop-in toolkit for running Earth Engine work behind a token-injecting HTTP proxy. Lets a single Python process talk to EE on behalf of many service accounts concurrently — bypassing the ee.Initialize() global- credential limitation that normally forces process-per-tenant designs.

What “by default” gets you

You usually don’t need to touch this module directly. Calling Map.view() in geeViz.geeView auto-starts the proxy on first use. The flow is:

  1. Map.view()eeCreds.ensure_started("attached")

  2. discover() finds whatever credentials are visible in the environment ($GOOGLE_APPLICATION_CREDENTIALS, the EE persistent file, gcloud ADC, env-var SAs).

  3. If anything was discovered, a local uvicorn proxy is spawned in a daemon thread (or reused if already running).

  4. ee.Initialize is pointed at the proxy and EE traffic routes through it for the rest of the process.

Single-credential users get this for free. Multi-credential workflows register additional creds with addCreds() and switch with use() / with eeCreds.use(...). Every browser tab that Map.view() opens is pinned to the tenant that was current at the time of the call — subsequent use() switches in Python can’t drift open tabs to a different credential.

Layout

Typical use — multi-credential (when you want it)

from geeViz.eeAuth import eeCreds
import ee

eeCreds.addCreds("/path/to/sa-prod.json", "prod")
eeCreds.addCreds("/path/to/sa-training.json", "training")
eeCreds.start()                  # spins up the local proxy

eeCreds.use("prod")
ee.Number(1).getInfo()           # routes through prod SA

with eeCreds.use("training"):
    ee.Number(2).getInfo()       # routes through training SA
# back to prod here

Typical use — running the proxy standalone (Cloud Run, Docker, etc.)

python -m geeViz.eeAuth --port 8888

Embedded in your FastAPI app:

from fastapi import FastAPI
from geeViz.eeAuth.server import build_proxy_router
from geeViz.eeAuth import eeCreds

eeCreds.discover()
app = FastAPI()
app.include_router(
    build_proxy_router(creds=eeCreds), prefix="/ee-api",
)

The proxy resolves which SA to use for each request from (in order):

  1. X-geeViz-Creds request header

  2. ?tenant= query string parameter

  3. /ee-api/t/<tenant>/ path-prefix (used by Map.view() to pin each browser tab to its tenant)

  4. Default tenant (GEE_SERVICE_ACCOUNT_B64 env var, or the first registered credential in eeCreds)

Add SAs by setting env vars matching GEE_<NAME>_SERVICE_ACCOUNT where NAME is a tenant id. The value is a base64-encoded SA JSON key.

Why this exists

The Earth Engine Python SDK stores credentials in module-level state via ee.Initialize(). Two tenants can’t run concurrently in one process without racing each other’s auth. By routing every REST call through a local proxy that injects the right SA per request, you keep one Python process, one ee.Initialize call, and get full multi-tenant concurrency.

Functions

robust_init(*[, verbose, interactive])

Module-level convenience: eeCreds.robust_init(...).

class geeViz.eeAuth.EECreds[source]

Bases: object

Multi-tenant credential registry + EE init lifecycle.

Usually used via the module-level singleton eeCreds — but instantiable directly when you need multiple independent registries (e.g. tests, multi-tenant servers with isolated credential sets):

from geeViz.eeAuth.eeCreds import EECreds
creds = EECreds()
creds.addCreds(...)
creds.start()
addADC(name: str = 'adc', project: str | None = None) EECreds[source]

Register the runtime’s Application Default Credentials as a tenant entry.

Use this when you want the proxy to route through ADC (attached SA on Cloud Run, WIF federation on AWS, gcloud user creds locally) — without holding any key material. Without this, ADC-only deployments still get EE working via robust_init() but the proxy starts empty and the multi-tenant features are inaccessible.

Tokens are minted by calling google.auth.default() at mint time, then refreshing. Same code path as keyed SAs from the proxy’s perspective.

addCreds(creds: str | dict | bytes, name: str, project: str | None = None) EECreds[source]

Register a set of credentials under name.

Accepts:

  • File path to a JSON key file (SA, OAuth, WIF, or impersonation config)

  • Base64-encoded JSON string (typical for env vars)

  • JSON string (literal contents)

  • Already-parsed dict

  • Bare service-account email string (foo@bar.iam.gserviceaccount.com) → routed to addImpersonation() so the proxy mints tokens via the IAM credentials API at request time (no key held)

Auto-detects which credential shape it is. project overrides the default project — required for OAuth + impersonation, optional for SAs since their JSON has project_id.

Returns self so chaining works:

eeCreds.addCreds(sa1, "a").addCreds(sa2, "b").start()
addImpersonation(target_email: str, name: str, project: str | None = None) EECreds[source]

Register a tenant that mints tokens by impersonating target_email at request time.

No key material is held. The runtime’s ADC source must hold roles/iam.serviceAccountTokenCreator on the target SA. On each token mint, google.auth.impersonated_credentials calls the IAM credentials API to fetch a short-lived (1h) access token; google-auth refreshes it automatically when it expires.

This is the multi-tenant keyless path: the proxy holds N SA emails, not N JSON keys. Works identically across GCP Cloud Run (source = attached SA), AWS via WIF (source = federated identity from STS), and local dev (source = gcloud user creds).

Parameters:
  • target_email – The SA to impersonate (foo@proj.iam.gserviceaccount.com).

  • name – Tenant name for eeCreds.use(name).

  • project – Quota project for EE calls. If omitted, falls back to the runtime’s default ADC project.

clearWorkloadTag() None[source]

Clear the SDK-side workload tag so subsequent EE calls fall back to the proxy’s default builder. Mapping in the store is preserved (so historical tags remain reversible).

current() str[source]

Return the currently active tenant name. Falls back to the first registered name if no explicit use() has been made yet and at least one credential is registered. Returns "" if nothing’s registered.

discover(*, overwrite: bool = False) list[str][source]

Scan the environment for credentials and register any found.

Lookups, in order — each one that produces a credential gets added under a stable name:

Source

Registered as

$GOOGLE_APPLICATION_CREDENTIALS (JSON path)

"adc"

~/.config/earthengine/credentials (EE persistent)

"ee-persistent"

gcloud ADC well-known file

"adc-default"

$GEE_SERVICE_ACCOUNT_B64 (legacy default SA)

"env-default"

$GEE_<NAME>_SERVICE_ACCOUNT (per-tenant SA keys)

<name>

$GEE_<NAME>_SA_EMAIL (per-tenant impersonation)

<name>

google.auth.default() fallback (Cloud Run / WIF)

"adc"

The fallback only fires when nothing else registered, so keyed deployments aren’t disturbed. On Cloud Run with an attached SA, on GKE with workload identity, or on AWS via Workload Identity Federation, this is the path that boots the proxy without any JSON key on disk.

Returns the list of names actually registered by this call. Existing names are not overwritten unless overwrite=True.

Safe to call multiple times; failing sources are logged but don’t raise — discovery is best-effort.

ensure_started(*, mode: str = 'attached', proxy_port: int = 8889) dict[source]

Idempotent “I want the proxy running, please” helper used by Map.view() and any other code that wants to ride the eeCreds proxy without forcing the user to call addCreds + start.

Modes:

  • "auto": try discovery + start (inline daemon thread). If anything fails, return a status dict with proxy_url="" — caller can fall back.

  • "proxy": try discovery + start (inline daemon thread). RAISE if nothing can be discovered or the proxy fails to bind.

  • "detached": attach to or spawn a long-lived background subprocess running python -m geeViz.eeAuth. Survives the calling script’s exit, so multi-Map.view() workflows and successive script invocations all share one proxy without needing the blocking input() at the end of each. The subprocess is identified by a state file at <tmp>/.geeViz_eeauth_proxy.json; clients verify version + tenant fingerprint via /health and respawn if anything drifted.

  • "legacy": do nothing. Returns immediately with "".

Returns {proxy_url, tenants, current, mode, discovered}. proxy_url == "" means caller should fall back.

getTagStore()[source]

Return the current tag store, constructing the default lazily on first call. See setTagStore() for how to override.

get_token(name: str | None = None, force_refresh: bool = False) dict[source]

Mint (or return cached) access token for name. If no name is given, uses the currently active tenant.

Returns {access_token, project_id, client_email, tenant} — same shape as geeViz.eeAuth.registry.SARegistry.get_token so build_proxy_router can accept either.

has(name: str) bool[source]
info(name: str | None = None) dict[source]

Inspect a registered credential without exposing the secret key material. Returns {name, type, project_id, client_email, source}. name=None returns the currently active one.

list() list[str][source]

Return registered credential names in insertion order.

lookupWorkloadTag(tag: str) dict | None[source]

Recover the parts dict for a previously-minted tag. Returns None if the tag isn’t in this process’s tag store (could be from another instance if not using shared storage, or an untagged / externally-tagged request).

names() list[str][source]

Alias for list() — also returns registered names.

property proxy_url: str | None

URL of the in-process proxy (if one’s running). Useful for embedding into iframe URLs / Map exports so the JS side uses the same proxy.

restart(*, mode: str = 'detached') dict[source]

Full stop() followed by ensure_started(mode=mode). Use after editing any geeViz.eeAuth source file so a fresh proxy loads the changes instead of the caller attaching to a still-running subprocess whose Python interpreter cached the old bytecode. Returns the ensure_started status dict.

robust_init(*, verbose: bool = False, interactive: bool = True) dict[source]

Best-effort EE initialization with the simplest possible UX.

Decision tree (first hit wins, no prompts):

  1. EE already initialized AND a test call works → return as-is.

  2. eeAuth multi-tenant proxy via ensure_started → use it.

  3. ee.Initialize() with NO project arg → let EE’s own resolution chain (credentials’ quota_project_id → ADC → env vars) pick the project. This is the path that mirrors what ee.Initialize() would do if the user typed it themselves.

  4. Fallback: ee.Authenticate(force=True, auth_mode='localhost') (interactive only) and re-run step 3.

No project-id prompts. If a user wants a specific project they can call ee.Initialize(project='X') themselves before importing geeViz, or run earthengine set_project X / gcloud auth application-default set-quota-project X.

Returns a status dict:

{"ok": bool,
 "source": "already-initialized" | "eeauth-proxy"
         | "ee-auto-init" | "interactive-auth",
 "project": "..."}

Raises RuntimeError when no path completes — e.g. non-interactive environment with no creds, or no quota project discoverable after a fresh authenticate.

Parameters:
  • verbose – Print progress to stdout.

  • interactive – If False, skip the ee.Authenticate() fallback and raise instead. Useful for daemons / CI where blocking on a browser would hang.

router(**kwargs)[source]

Return a FastAPI APIRouter that proxies EE requests using these credentials. Mount it in your own FastAPI app:

app.include_router(eeCreds.router(), prefix="/ee-api")

kwargs pass through to build_proxy_router — customize the tenant header, resolver, workload-tag builder, etc.

setTagSecret(secret: str) EECreds[source]

Set the secret used when minting workload tags. Defaults to $WORKLOAD_TAG_SECRET (or a local-dev fallback if unset). Same secret is required to re-mint the same tag from the same parts across processes.

setTagStore(store) EECreds[source]

Replace the default TagStore used for auto-minted workload tags. Pass an InMemoryTagStore(), SQLiteTagStore(path=...), or any object matching the TagStore protocol. Call this BEFORE start() if you want the proxy’s fallback builder to write into your store. Returns self for chaining.

setWorkloadTag(**parts) str[source]

Mint a tag from parts, store the mapping, and set it on the EE Python SDK so every subsequent EE call carries it.

Three things happen atomically:

  1. tag = mint_workload_tag(parts, secret=…) — deterministic short hash (wl_<hex>).

  2. getTagStore().put(tag, parts) — so lookupWorkloadTag(tag) can recover parts later, and any Cloud Monitoring row tagged with tag can be joined back to identity.

  3. ee.data.setWorkloadTag(tag) — SDK-side setter. The tag is attached to every subsequent .getInfo(), .getMapId(), export, etc., AND baked into any tile URL returned by getMapId, so browser tile fetches inherit it.

Since the proxy’s default builder honours client-set tags (if request.query_params['workloadTag']: return it), the same tag round-trips through the proxy without any custom builder needed.

Returns the minted tag so callers can log / correlate it.

start(*, proxy_port: int = 8889, proxy_host: str = '127.0.0.1', ee_init: bool = True, launch_proxy: bool = True, workload_tag_builder=None) dict[source]

Initialize Earth Engine for multi-credential use.

Steps (each can be disabled via kwargs):

  1. launch_proxy=True: start a background HTTP proxy that injects per-tenant SA / OAuth tokens. Required for switching credentials at runtime without re-initializing ee.

  2. ee_init=True: call ee.Initialize(url=proxy_url, ...) so the EE Python SDK routes all REST calls through the proxy.

Returns a status dict with {started, proxy_url, tenants, ee_initialized} for inspection.

Idempotent — calling start() twice is safe; the second call returns the current state.

stop() None[source]

Shut down the proxy — in-process AND any detached subprocess — and un-initialize the EE SDK so subsequent calls fail fast.

Without the SDK reset, ee.Image(...).getInfo() after stop() hangs on the socket timeout of the (now-dead) proxy URL before raising. ee.Reset() clears the SDK’s cached connection so the next call raises “Earth Engine client library not initialized” immediately.

Prints one line describing what was torn down so the caller has a visible confirmation rather than a silent state change.

Safe to call when nothing is running.

classmethod stop_detached() bool[source]

Public helper: kill the detached proxy (if any) and clear the state file. Returns True if a process was actually terminated, False if there was nothing to kill.

sync_oauth_project(project: str) int[source]

Update every OAuth entry’s project_id to project and invalidate the cached access token so the next mint includes the new project on the x-goog-user-project header.

Used by robustInitializer after the legacy ee.Initialize fallback succeeds: discovery may have guessed a project the OAuth user can’t access (e.g. gcloud config pointing at a service-account-owned project), but legacy init knows what ACTUALLY works. Syncing that back to the OAuth entries means subsequent Map.view() calls route through the proxy with the correct project instead of repeating the 403.

Service-account entries are NOT touched — their project_id came from the SA JSON and is authoritative.

Parameters:

project – The known-good project ID.

Returns:

Number of entries actually updated.

use(name: str)[source]

Switch the active credential and return a context manager that restores the previous one on exit. Works as a statement OR a with block:

eeCreds.use("acme")              # switch and forget
ee.Image(1).getInfo()            # uses acme

with eeCreds.use("ian"):         # scoped
    ee.Image(2).getInfo()        # uses ian
# back to acme here
geeViz.eeAuth.robust_init(*, verbose: bool = False, interactive: bool = True) dict[source]

Module-level convenience: eeCreds.robust_init(...).

Centralizes the “get EE up and running, prefer the proxy, never silently fall through to gcloud ADC without saying so” bootstrap that geeViz.geeView and external callers both want.

class geeViz.eeAuth.SARegistry[source]

Bases: object

Per-tenant service-account credentials + cached access tokens.

get_token(tenant: str | None, force_refresh: bool = False) dict[source]

Return {access_token, project_id, client_email, tenant} for the given tenant. Caches across calls; refresh-on-expire happens automatically. Raises KeyError if no tenant matches and no default is configured.

has_tenant(tenant: str) bool[source]

True iff the registry has a service-account entry for tenant.

list_tenants() list[str][source]

All tenant slugs currently registered, sorted alphabetically.

resolve(tenant: str | None) str[source]

Pick the actual tenant to use. Unknown / missing → default. Returns "" if neither the requested tenant nor a default is configured — callers should treat that as “registry not ready”.

geeViz.eeAuth.get_registry() SARegistry[source]

Return the process-wide SA registry, constructing it lazily on first access.

Thread-safe: the constructor grabs its own lock. Subsequent callers receive the same instance.

Returns:

The singleton registry.

Return type:

SARegistry

geeViz.eeAuth.reset_registry() None[source]

Clear the singleton so the next get_registry() re-reads the env.

Used by tests that mutate GEEVIZ_SA_JSON_* env vars between cases — without a reset the cached registry would ignore the changes.

geeViz.eeAuth.build_workload_tag(*parts: str) str[source]

Join sanitized parts with __ and clamp to EE’s 63-char limit.

Empty / falsy parts are dropped. The final tag is guaranteed to satisfy EE’s regex: [a-z0-9][a-z0-9_\-]{0,61}[a-z0-9]. Returns an empty string if everything was dropped — callers should treat empty as “no tag” and skip the workload-tag header / body field entirely.

geeViz.eeAuth.sanitize_workload_tag_part(s: str) str[source]

Sanitize a single component of a workload tag.

  • Lowercases.

  • Replaces disallowed characters with -.

  • Collapses runs of - to a single -.

  • Collapses runs of _ to a single _ so the __ separator stays unambiguous when parts are joined.

  • Strips leading/trailing - and _ (EE rejects tags that don’t begin and end with an alphanumeric).

geeViz.eeAuth.initialize_via_proxy(proxy_url: str, tenant_header: str = 'X-geeViz-Creds', project: str | None = None) bool[source]

Initialize the Earth Engine SDK to route all REST calls through proxy_url.

Uses AnonymousCredentials since the proxy holds the real SA credentials. The SDK’s bearer-token header is stripped by TenantAwareHttp before reaching the proxy anyway.

Parameters:
  • proxy_url – Base URL of the EE proxy, e.g. "http://localhost:8888/ee-api". No trailing slash.

  • tenant_header – Header name the proxy expects for tenant routing. Default X-geeViz-Creds matches geeViz.eeAuth.server.

  • project – Placeholder project id passed to ee.Initialize (EE requires one but the proxy overrides per-tenant via x-goog-user-project). Default "ee-proxy-placeholder".

Returns:

True on success, False if init failed (caller should fall back to direct ee.Initialize or surface the error). Prints any underlying exception to stderr; doesn’t re-raise.

geeViz.eeAuth.tenant_context(tenant: str)[source]

Scoped tenant switch.

with tenant_context("training"):
    ee.Image(1).getInfo()
# back to previous tenant here
geeViz.eeAuth.set_tenant(tenant: str)[source]

Set the current tenant for subsequent EE calls in this context.

Returns a token that can be passed to reset_tenant to restore the previous value. Prefer tenant_context() for scoped use.

geeViz.eeAuth.reset_tenant(token) None[source]

Restore the tenant to what it was before the matching set_tenant call.

Parameters:

token – The token object returned by the paired set_tenant() call. Passing a token from a different scope raises ValueError.

class geeViz.eeAuth.TenantAwareHttp(tenant_header: str = 'X-geeViz-Creds')[source]

Bases: object

httplib2.Http subclass that stamps the tenant header on every outbound request and strips whatever Authorization the SDK injected.

Subclassed at first instantiation so httplib2 is only imported when actually used — keeps unit tests that mock the EE init path from needing the dependency. The header name is set per-instance so you can run multiple proxies with different conventions in the same process if you really need to.

Thread safety

httplib2.Http is NOT thread-safe — its per-host connection cache (self.connections) is a plain dict mutated from inside request() and socket.HTTPConnection objects hold per-instance socket state. When the EE SDK shares one transport across a ThreadPoolExecutor (as Map.testLayers() does with 8 workers), concurrent threads tear down each other’s sockets mid-request, surfacing as 'NoneType' object has no attribute 'close' and Windows WinError 10038/10057 socket errors.

Workaround: route each thread’s request() call to its OWN httplib2.Http instance stored in threading.local(). EE’s SDK only consults the transport for request() — it doesn’t reach into self.connections directly — so per-thread instances are a safe drop-in.

geeViz.eeAuth.build_proxy_router(creds=None, upstream: str = 'https://content-earthengine.googleapis.com', tenant_header: str = 'X-geeViz-Creds', tenant_query_param: str = 'tenant', tenant_resolver: Callable[[Request, str], str] | None = None, workload_tag_builder: Callable[[Request, str], str] | None = None) APIRouter[source]

Build a FastAPI APIRouter that handles {path:path} and proxies every request to upstream with the right SA token.

Parameters:
  • creds – Object exposing get_token(tenant, force_refresh=False) -> {access_token, project_id, tenant, ...}. Accepts an EECreds instance, an SARegistry, or any other object with the same interface. None (default) uses the process-wide env-var registry (legacy).

  • upstream – Base URL of the real EE API. content-earthengine.googleapis.com works for both maps and compute. earthengine.googleapis.com is also accepted for most endpoints.

  • tenant_header – Header name to read for routing. Default X-geeViz-Creds. Must match the client side.

  • tenant_query_param – Query string key to read for tenant routing (browser iframe pattern). Default "tenant". Stripped from the outbound URL so EE never sees it.

  • tenant_resolver – Custom function (request) -> str to pick the tenant. Override for richer auth schemes (e.g. resolve via IAP email lookup). Default reads tenant_header then tenant_query_param.

  • workload_tag_builder – Custom function (request, tenant) -> str that returns the workload tag for billing attribution. Returning "" disables tagging on this request. Default builds ee-proxy__<tenant>.

Mount the returned router on whatever prefix you like — typically /ee-api.

geeViz.eeAuth.create_proxy_app(creds=None, upstream: str = 'https://content-earthengine.googleapis.com', tenant_header: str = 'X-geeViz-Creds', tenant_query_param: str = 'tenant', tenant_resolver: Callable[[Request, str], str] | None = None, workload_tag_builder: Callable[[Request, str], str] | None = None, prefix: str = '/ee-api', serve_geeview: bool = True) FastAPI[source]

Build a standalone FastAPI app with the proxy mounted at prefix. Suitable for direct serving via uvicorn or for testing.

creds accepts an EECreds / SARegistry-like object; None falls back to the env-var registry. See build_proxy_router() for the other parameters.

Use build_proxy_router directly if you want to mount in an existing FastAPI app and share its middleware / lifecycle.

Parameters:

serve_geeview – When True (default for standalone runs), also mount the geeView frontend bundle at /geeView/*. This makes the detached proxy the single long-lived server for both EE auth (/ee-api/*) and Map.view() HTML (/geeView/...). Same origin, same port — browser tabs survive script exits without a daemon-thread server inside each script. Set False to keep the proxy auth-only.

Modules

client

Client-side helpers for routing the Earth Engine Python SDK through a token-injecting proxy.

eeCreds

Multi-tenant credential registry + EE init lifecycle.

monitoring

Earth Engine usage monitoring — Cloud Monitoring poller.

registry

Multi-tenant Earth Engine service-account registry.

server

FastAPI proxy for Earth Engine that injects per-tenant SA tokens.

tags

Earth Engine workload-tag helpers.

tests