Authentication โ
How identities authenticate to OpenCrane and how a single human login grants access to both the opencrane-api and the user's own OpenClaw pod.
Terminology: the per-user OpenClaw agent gateway is a UserTenant (the openclaw /
TenantCRD); "UserTenant" is the canonical doc name while the CRD kind is stillTenantin code. All users in an org connect through the org's single host<org>.<base>; the identity-routing proxy (in the ClusterTenant operator) routes each session to its pod. The ClusterTenant is the customer/isolation unit that owns the org host. See the authoritative Tenancy Model. Below, "tenant pod" / "tenant gateway" means a UserTenant.
Status legend: โ implemented ยท ๐ถ planned/target. The OIDC opencrane-api session and the identity-routing proxy (
GET /api/v1/auth/gateway-resolve) are implemented today. The browser holds no pod credential โ connection auth is handled entirely by the proxy replaying the OIDC session cookie. The connection security posture is adopted โ trusted-proxy + per-pod owner pinning (CONN.9/10); seeclaw-security-considerations.md.
Two planes, one identity โ
OpenCrane has two backends a user touches, and they must not require two logins:
| Plane | What it serves | How it is reached |
|---|---|---|
| Control plane | management + metadata: tenants, policies, groups, budgets, skills, audit, auth | the versioned opencrane-api (OIDC session) |
| UserTenant pod (OpenClaw) | the live agent session: chat, Cognee retrieval, canvas | the org's gateway WebSocket at wss://<org>.<base>/gateway, routed to the user's pod by the identity-routing proxy, via the OpenClaw Gateway v4 protocol |
The principle is one identity, brokered access: the human signs in once via OIDC; when the browser opens the gateway WebSocket, the identity-routing proxy replays that OIDC session cookie to the control plane, resolves the caller to their own pod, and reverse-proxies the connection. The browser is never handed a pod credential and never logs in a second time โ there is no pairing link and no bearer token to steal. Revoking the OIDC session stops the next connection immediately.
End-to-end flow (single sign-on) โ
1. Browser โ /api/v1/auth/login (OIDC) โ IdP โ /api/v1/auth/callback โ session cookie โ the ONLY login
2. Browser opens wss://<org>.<base>/gateway (the org's gateway WebSocket; the SPA owns /)
3. Identity-routing proxy (in the operator):
- checks Origin against CSWSH allowlist (exact vanity hosts + any https://<org>.<base>)
- calls GET /api/v1/auth/gateway-resolve (replaying only the session cookie)
- control plane resolves: verified email โ tenant โ pod (fail-closed on ambiguity)
- proxy strips client-supplied X-Forwarded-User, injects the verified email, and
reverse-proxies the WS upgrade to openclaw-<user>.<ns>.svc:<gatewayPort>
4. Gateway runs in trusted-proxy mode; OpenClaw's owner-pinning guard (CONN.10)
rejects any X-Forwarded-User that isn't the pod's registered owner.
5. Browser holds only the HTTP-only session cookie. Re-login only when the OIDC
session itself expires. Re-connect is automatic; no token management required.All steps are โ
implemented (gated by gatewayProxy.enabled).
Why this shape โ
- One login. Users never authenticate twice; pod routing is resolved from the established OIDC session.
- No browser-held pod credential. The browser holds only its HTTP-only session cookie; the proxy carries all pod-routing logic, so there is nothing to steal from the browser.
- Defence in depth. Cross-tenant safety rests on two independent layers: the proxy's
gateway-resolve(routing level) and per-pod owner pinning (pod level) โ either alone suffices. - Revocation is immediate. Invalidating the OIDC session stops the next gateway-resolve call; an already-open socket can be cut via a Kubernetes pod force-disconnect (no parallel credential to revoke).
Credential types (keep them distinct) โ
| Credential | Subject | Audience / target | TTL / storage | Status |
|---|---|---|---|---|
| Control-plane session cookie | the human | control plane + identity-routing proxy | server-signed, HTTP-only cookie (~12h) | โ |
| Projected SA token | a Kubernetes service account | obot-gateway / feat-skill-registry / opencrane-server | ~600s, kubelet-rotated, in-cluster only | โ |
The browser holds only the HTTP-only session cookie. There is no bootstrap token, no device token, and no pod-specific credential in the browser.
The projected SA token is workload identity and must never be handed to a browser. It is how the pod calls outward โ e.g. OpenClaw โ Obot MCP Gateway (aud=obot-gateway), and the contract re-pull loop โ control plane (aud=opencrane-server). The browser never holds an obot-gateway token and never talks to Obot directly.
The pod's Kubernetes ServiceAccount is also what SPIRE mints its SPIFFE SVID from (spiffe://opencrane/ct/<org>/<workload>), so a workload's network identity and its outward-call identity are the same principal. That SVID is what the silo's CiliumNetworkPolicy rules and mutual TLS are keyed on โ see Identity & network isolation (Cilium + SPIFFE).
Two OIDC registrations: fleet and silo โ
OpenCrane's Stage 4 architecture uses two separate OIDC registrations โ one for the fleet-manager and one per silo's clustertenant-manager. Both use the same backend-for-frontend session model but target different Zitadel projects and audiences.
| Registration | Component | Helm key | Audience |
|---|---|---|---|
| Fleet OIDC | fleet-manager (in opencrane-system) | fleetManager.oidc.* | Fleet operators and billing admins โ ClusterTenant lifecycle, billing, platform DNS, Zitadel admin |
| Silo OIDC | clustertenant-manager (per-silo namespace) | clustertenantManager.oidc.* | Per-org end users โ tenant management, policies, skills, sessions |
Configure them independently. The fleet and silo can share the same Zitadel instance but must use different Zitadel projects (and therefore different client IDs and redirect URIs).
Zitadel management is fleet-only
The fleet-manager is the sole holder of the Zitadel Management API service-account key (fleetManager.zitadel.*). The per-silo clustertenant-manager uses standard OIDC discovery at clustertenantManager.oidc.issuerUrl for user login only โ it makes no Zitadel Management API calls. See Fleet and silo operating model.
Control-plane session (OIDC) โ
OpenCrane uses a backend-for-frontend session model for human access to both the fleet plane and each silo's tenant-facing plane.
- The browser is redirected to an OpenID Connect provider.
- The backend completes the Authorization Code flow with PKCE.
- The backend stores the authenticated user in a secure HTTP-only session cookie.
- Clients read login state from
/api/auth/meand never keep an OAuth bearer token in browser storage.
This works with Google Identity and with self-hosted providers such as Keycloak, Dex, Authentik, or Zitadel. The variables below apply to either the fleet-manager or the clustertenant-manager; see the table above to know which Helm key maps to which.
Required environment variables โ
Set these via Helm (fleetManager.oidc.* for the fleet plane, clustertenantManager.oidc.* for each silo) โ not directly on the deployments.
| Variable | Required | Purpose |
|---|---|---|
OIDC_ISSUER_URL | Yes | Issuer URL used for OIDC discovery |
OIDC_CLIENT_ID | Yes | Client identifier registered with the IdP |
OIDC_CLIENT_SECRET | Optional | Client secret for confidential clients |
OIDC_REDIRECT_URI | Yes | Must point to /api/auth/callback on the opencrane-api |
OIDC_SESSION_SECRET | Yes | Secret used to sign the opencrane-api session cookie |
OIDC_SCOPES | No | Defaults to openid email profile |
OIDC_COOKIE_NAME | No | Defaults to opencrane_oidc |
OIDC_COOKIE_SECURE | No | Explicit override; otherwise forced true in production and inferred from the redirect-URI scheme in dev (fail-closed โ see CONN.2) |
OIDC_SESSION_MAX_AGE_SECONDS | No | Defaults to 43200 (12 hours) |
OIDC_ALLOWED_EMAIL_DOMAINS | No | Comma-separated allowlist of email domains |
OIDC_ALLOWED_EMAILS | No | Comma-separated allowlist of exact email addresses |
OIDC_GROUPS_CLAIM | No | Claim carrying the caller's group memberships. Defaults to groups |
OIDC_ROLES_CLAIM | No | Claim carrying the caller's roles; unioned with groups. Defaults to roles |
OPENCRANE_PLATFORM_OPERATOR_GROUPS | No | Comma-separated, lowercased group/role names that grant platform-operator. Empty โ nobody (fail-closed) |
OPENCRANE_ORG_ADMIN_GROUPS | No | Comma-separated, lowercased group/role names that grant org-admin. Empty โ nobody (fail-closed) |
OPENCRANE_PLATFORM_OPERATOR_SEED_EMAIL | No | Per-cluster seed that bootstraps the first platform operator by verified email. Empty โ nobody (fail-closed). See Platform-operator seed |
Trusted issuer โ Zitadel, no Entra dependency โ
OpenCrane trusts exactly one OIDC issuer: the one at OIDC_ISSUER_URL. In the deployed topology that issuer is Zitadel, operated as a Mode-2 broker โ it is the single identity provider the opencrane-api validates tokens against. There is no upstream Entra (Azure AD) dependency: OpenCrane does not federate to, call, or require Microsoft Entra. The login flow is standards-only OIDC discovery + Authorization Code with PKCE, so any spec-compliant issuer works, but the trusted, supported issuer is Zitadel.
Configure Zitadel to emit the caller's group memberships and roles as claims, then point the claim-name env vars at them:
OIDC_GROUPS_CLAIMโ the claim Zitadel puts group memberships in (defaultgroups).OIDC_ROLES_CLAIMโ the claim Zitadel puts project/app roles in (defaultroles).
Both claims are read and unioned, so a match in either grants the corresponding flag. In Zitadel this typically means adding the Groups and/or Roles claims to the ID token / userinfo via an action or the project's role-assertion settings, and mapping the OpenCrane operator/org-admin group names into OPENCRANE_PLATFORM_OPERATOR_GROUPS / OPENCRANE_ORG_ADMIN_GROUPS (comma-separated, compared lowercased).
Platform-operator seed (bootstrapping the first operator) โ
Before any group/role mapping exists in Zitadel, a fresh cluster has no platform operator โ OPENCRANE_PLATFORM_OPERATOR_GROUPS is empty and the derived isPlatformOperator is false for everyone (fail-closed). The seed is the per-cluster bootstrap for exactly this gap:
- Set
OPENCRANE_PLATFORM_OPERATOR_SEED_EMAILto the email of the person who should be the first operator. The caller whose verified OIDC email equals the seed (compared case-insensitively and trimmed) is treated as a platform operator. - The seed is additive to the group check: a caller is a platform operator if their groups match or their verified email matches the seed (seed OR group โ operator).
- It is fail-closed: an empty/unset seed grants operator to nobody, and an email the IdP marks unverified never matches (login already rejects an unverified email).
- It is a per-cluster install parameter โ never hardcoded. Set it at install time (the wizard prompts for it;
apps/fleet-platform/deploy.sh --platform-operator-seed-email โฆor theOPENCRANE_PLATFORM_OPERATOR_SEED_EMAILenv accept it; the Helm value iscontrolPlane.oidc.platformOperatorSeedEmail). Once a Zitadel group mapping is in place, remove the seed and rely on groups.
Like isPlatformOperator itself, the seed is an introspection-only stopgap until a first-class role model lands โ the API stays the enforcement point.
Google Identity example โ
- Create a Web application OAuth client in Google Cloud.
- Add the opencrane-api callback URL as an authorized redirect URI.
- Set the opencrane-api environment variables.
OIDC_ISSUER_URL=https://accounts.google.com
OIDC_CLIENT_ID=1234567890-abc123.apps.googleusercontent.com
OIDC_CLIENT_SECRET=replace-me
OIDC_REDIRECT_URI=https://opencrane-api.example.com/api/auth/callback
OIDC_SESSION_SECRET=replace-with-a-long-random-secret
OIDC_ALLOWED_EMAIL_DOMAINS=example.comLocal or non-cloud example โ
Use any OIDC-capable IdP that exposes a discovery document. Example with Keycloak:
OIDC_ISSUER_URL=https://keycloak.local/realms/opencrane
OIDC_CLIENT_ID=opencrane-api
OIDC_CLIENT_SECRET=replace-me
OIDC_REDIRECT_URI=http://localhost:8080/api/auth/callback
OIDC_SESSION_SECRET=replace-with-a-long-random-secret
OIDC_COOKIE_SECURE=false
OIDC_ALLOWED_EMAIL_DOMAINS=local.testThe same model works with Dex or Authentik as long as the issuer supports standard OpenID Connect discovery.
CLI and automation โ
- CLI uses the OIDC device authorization grant (
POST /auth/deviceโ/auth/device/activatein a browser โ poll/auth/device/token). - Automation / CI uses a static bearer token (
Authorization: Bearer โฆ). Treat this as a migration target; prefer OIDC/IAM where possible.
UserTenant pod access (identity-routing proxy) โ
To reach a user's OpenClaw, the browser opens the org's gateway WebSocket at wss://<org>.<base>/gateway (the org control-UI SPA owns /, the API owns /api/*). The identity-routing proxy (folded into the ClusterTenant operator) authorises and routes the connection โ the browser holds no pod credential.
The routing authority is GET /api/v1/auth/gateway-resolve โ
. On each WebSocket upgrade the proxy:
- Checks
Originagainst the CSWSH allowlist (exact vanity entries + anyhttps://<org>.<base>) โ fails closed if the Origin is missing or not allowed. - Calls
GET /api/v1/auth/gateway-resolveon the control plane, replaying only the session cookie. The control plane resolves the caller's UserTenant from the session's verified email only โ no request-supplied tenant input โ matched case-insensitively; more than one match fails closed (403). - Strips any client-supplied
X-Forwarded-User, injects the verified email, and reverse-proxies toopenclaw-<user>.<ns>.svc:<gatewayPort>.
The pod runs in trusted-proxy mode and pins the allowed identity via gateway.auth.trustedProxy.allowUsers (the pod's owner email โ CONN.10), so a mis-routed socket is rejected at the pod as a second independent guard.
Because the tenant is derived solely from the OIDC session, a caller cannot reach another user's pod.
Security posture (adopted โ CONN.9/10) โ
The adopted model (2026-06): session-authorised trusted-proxy auth with no browser-held pod credential + per-pod owner pinning + transport hardening (HSTS, wss://-only, fail-closed Secure cookie โ CONN.2). The control plane stays connection-stateless. Full threat model and accepted trade-offs are in claw-security-considerations.md.
Authorization (who can do what) โ
Authentication establishes who; authorization is split across the two planes:
- Control plane โ management routes are operator-facing.
/auth/mecarries identity (sub,email,name) but no role claim today; a roles/ capabilities claim is a ๐ถ target so gating can be explicit. - Data plane โ what a pod may retrieve/act on is governed by
AccessPolicy,Groupawareness grants, and tenant dataset memberships, compiled per tenant into the effective contract (GET /tenants/{name}/effective-contract). The OpenClaw pairing profile also grants the device a bounded role/scopes on the pod gateway (noderole +operator.read/write/approvals;operator.admin/operator.pairingrequire separate approval).
Kubernetes and IAM split โ
- Human identity is handled by the OIDC provider and the opencrane-api session.
- Kubernetes RBAC remains machine-facing and is bound to Kubernetes service accounts.
- Cloud IAM or local secret systems are bound to workloads through the Kubernetes service account identity, not through human bearer tokens.
Review notes โ
- The static bearer-token path can remain as a temporary break-glass fallback for API-only usage; prefer OIDC/IAM for production.
- For production, prefer a confidential client with
OIDC_CLIENT_SECRETset. - Behind an ingress or reverse proxy, preserve forwarded headers so callback and secure-cookie handling use the external URL correctly (the control plane sets
trust proxy, soX-Forwarded-Protodrivesreq.secure/ HSTS). - Never expose kubelet-projected SA tokens to browsers; the session cookie is the only browser-held credential, and it never reaches a pod directly โ the proxy intermediates all pod connections.
See also โ
- Identity & network isolation (Cilium + SPIFFE) โ the workload-identity side: SPIFFE SVIDs, the who-can-talk-to-whom rules, and how they compose with human OIDC identity
- Networking & isolation โ the two-plane model, NetworkPolicy enforcement, the three-layer gateway seam, and known egress gaps
- Connection security โ CONN.9/CONN.10 threat model and transport hardening posture
- Zitadel key rotation โ how to rotate the fleet-manager's Zitadel SA key that provisions ClusterTenant Zitadel Orgs
- Fleet and silo operating model โ how fleet OIDC and per-silo OIDC are configured separately
- Silo IAM: inheritance & sharing โ how the Zitadel-bound subject flows into grant compilation and dataset derivation