Groundfloor Docs

Shell Auth Modes

How the Shell host handles authentication across different deployment modes.

Audience: Shell host team Companion: 03-authentication.md (platform Keycloak + Control Plane API auth) Control Plane: http://localhost:8088 in dev

A workspace chooses how its end users sign in to the deployed Shell at {slug}.app.groundfloor.cloud. Shell discovers this at boot and adapts. There are three modes:

ModeEnd-user loginOIDC issuerWho manages users
noneNo login — app is open / handles its own accessn/a
groundfloorOIDC against a Groundfloor-managed realm gf-ws-{workspace_id}provisioned by Control PlaneControl Plane → Authentication area
externalOIDC against the customer's own IdP (Okta, Azure AD, Auth0, …)customer-providedcustomer's IdP

Dev plan: start with none (no IdP needed), confirm the Shell renders and the resolution flow works, then layer in groundfloor once a local Keycloak is running, and external last.


How Shell discovers the mode (all modes)

Shell calls one public, unauthenticated endpoint at boot to learn the mode and (if any) the OIDC parameters:

GET {CONTROLPLANE_URL}/v1/public/workspaces/by-host?host={host}

Response (PublicWorkspaceAuthResponse):

// mode = none
{ "workspace_id": "42e6…", "mode": "none", "oidc": null }

// mode = groundfloor or external (once configured)
{
  "workspace_id": "42e6…",
  "mode": "groundfloor",
  "oidc": { "issuer": "https://auth…/realms/gf-ws-42e6…", "client_id": "shell" }
}
  • oidc is null for none, and also null while groundfloor is still provisioning (no issuer yet).
  • No token required. Rate-limited per client IP (RATE_LIMIT_PUBLIC_SHELL).

Host resolution (important for dev)

by-host maps the host query param to a workspace by checking, in order: auth.host.custom_domainauth.host.subdomain → workspace slug, where the subdomain is parsed from {subdomain}.app.groundfloor.cloud.

In production Shell passes its real Host header. In local dev the browser runs on localhost:3000, which won't match — so pass the production-style host string built from the workspace slug:

# Dev: resolve by the workspace slug, even though Shell runs on localhost
curl -s "http://localhost:8088/v1/public/workspaces/by-host?host=${SLUG}.app.groundfloor.cloud" | jq .

Alternatively, in dev you may skip discovery and read the full config with a platform Keycloak token (this is the same data the Portal Authentication page uses):

GET /v1/workspaces/{workspace_id}/auth        # requires platform token + read

Resolution flow

flowchart TD
  A["Shell boot"] --> B["GET /v1/public/workspaces/by-host?host=…"]
  B --> C{mode?}
  C -->|none| D["Render app — no login"]
  C -->|external| E["OIDC login at oidc.issuer / oidc.client_id"]
  C -->|groundfloor| F{oidc present?}
  F -->|no, still provisioning| G["Show 'setting up' — retry by-host"]
  F -->|yes| E
  E --> H["Have end-user access token"]
  D --> I["Call Control Plane APIs"]
  H --> I

Mode 1 — none (start here in dev)

No identity provider. Shell renders the app directly; there is no end-user login and no end-user token.

Enable (default for new workspaces — usually nothing to do):

PUT /v1/workspaces/{workspace_id}/auth
Authorization: Bearer {platform_token}
Content-Type: application/json

{ "mode": "none" }

Shell behavior:

  1. by-host returns mode: "none", oidc: null.
  2. Skip OIDC entirely — do not initialize keycloak-js.
  3. Render the federated app.

Dataplane + Data Vault in none mode. End users have no OIDC token, so they cannot call /vault, /secrets, or /files from the browser. Control Plane still needs a per-workspace DATAPLANE_SERVICE_API_KEY in Secrets (minted via Portal → Authentication → Provision Dataplane or workspace provisioning). That key is used server-side only:

ApproachWhen to use
Control Plane /vault from a BFFPreferred — ReBAC + audit enforced. Shell API route, Coderunner, or Next.js server action calls GET/POST …/vault/… with a platform token in dev, or your deploy pipeline injects credentials.
Dataplane direct from a BFFAdvanced — read DATAPLANE_SERVICE_API_KEY from Secrets at deploy (never from browser). Same tenant boundary as the workspace id.
Browser → Control PlaneOnly public routes (by-host, shell bootstrap).

Do not paste the service key into client-side federated bundle code. Do not manually PUT the key in Secrets — use POST /v1/workspaces/{id}/dataplane/provision.

Dev test checklist (none):

  • Control Plane running on :8088; CORS_ORIGINS includes the Shell dev origin (http://localhost:3000)
  • Workspace auth mode is none (Portal → Authentication → Settings, or the PUT above)
  • by-host returns {"mode":"none","oidc":null} for ${SLUG}.app.groundfloor.cloud
  • Shell renders the app without redirecting to a login page

Mode 2 — groundfloor (managed Keycloak realm)

Control Plane provisions a dedicated realm gf-ws-{workspace_id} plus a public PKCE client for the Shell, and you manage end users from the Portal Authentication → Users tab (see 03-authentication.md).

Enable:

PUT /v1/workspaces/{workspace_id}/auth
Authorization: Bearer {platform_token}
Content-Type: application/json

{ "mode": "groundfloor" }

Provisioning is asynchronous. Poll until ready:

GET /v1/workspaces/{workspace_id}/auth
# auth.groundfloor.status: provisioning → active
# auth.groundfloor.issuer / public_client_id populated when active

Shell behavior:

  1. by-host returns mode: "groundfloor". If oidc is null, provisioning is still in progress — show a "setting up sign-in" state and retry.
  2. When oidc is present, run OIDC (PKCE) login against oidc.issuer with oidc.client_id (the managed shell client) — not the platform Portal client.
  3. Send the resulting access token as Authorization: Bearer … to Control Plane.

Local Keycloak: deploy/docker-compose.phase2-deps.yml --profile keycloak (see Phase 2 deps runbook). Provisioning needs the Admin API env (KEYCLOAK_ADMIN_CLIENT_ID/SECRET, KEYCLOAK_WORKSPACE_PROVISION_REALM, and WORKSPACE_AUTH_DEV_REDIRECT_ORIGINS so http://localhost:3000 is an allowed redirect).

Access-token claims (D-045): When provisioning completes, Control Plane adds a realm default client scope (groundfloor-external-identity) so every OIDC client in the workspace realm receives:

ClaimSource
external_tenant_idWorkspace UUID (hardcoded mapper)
external_user_idPortal users.id, via Keycloak user attribute portal_user_id

The attribute is set when users are invited or on first Control Plane JIT user create. Refresh the access token after first login if external_user_id is missing. Workspaces provisioned before this scope existed can re-run provisioning (toggle Authentication off/on to groundfloor, or call PUT with mode: "groundfloor" again) to attach the scope; existing users may need a fresh login so JIT/invite sync can write portal_user_id.


Mode 3 — external (bring your own IdP)

The workspace federates to its own OIDC provider. Control Plane stores the issuer + client id and exposes them via by-host; it provisions nothing.

Enable (issuer + client_id are required):

PUT /v1/workspaces/{workspace_id}/auth
Authorization: Bearer {platform_token}
Content-Type: application/json

{
  "mode": "external",
  "external": {
    "issuer": "https://acme.okta.com/oauth2/default",
    "client_id": "groundfloor-shell"
  }
}

A PUT with mode: "external" and no external block returns 400.

Shell behavior:

  1. by-host returns mode: "external" with the customer's oidc.issuer / oidc.client_id.
  2. Run OIDC (PKCE) login against that issuer.
  3. Send the token to Control Plane as a bearer.

Customer IdP setup (their side): register a public/PKCE client whose redirect URIs include https://{slug}.app.groundfloor.cloud/* (and your dev origin), and ensure the issued tokens carry the configured client_id in aud/azp.


Sending tokens to Control Plane (groundfloor / external)

On workspace-scoped APIs Control Plane accepts either a platform Keycloak token (Portal path) or the workspace-site OIDC token (workspace_scoped_actor). It inspects the token issuer to decide which validator to use, then JIT-creates a Portal user record keyed by idp_realm.

import {
  configureControlPlaneClient,
  setControlPlaneAuthProvider,
} from "@groundfloor/api-client";

configureControlPlaneClient({ baseURL: process.env.NEXT_PUBLIC_API_URL });
setControlPlaneAuthProvider(async () => keycloak.token ?? null); // refresh before expiry

Permissions are still enforced (ReBAC). A 403 means authenticated but lacking the relation; a 401 means a missing/invalid/expired token.


Reference: a single resolver for all three modes

type WorkspaceAuth =
  | { mode: "none"; workspaceId: string }
  | { mode: "external" | "groundfloor"; workspaceId: string; issuer: string; clientId: string }
  | { mode: "groundfloor"; workspaceId: string; pending: true }; // provisioning

export async function resolveWorkspaceAuth(host: string): Promise<WorkspaceAuth> {
  const base = (process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8088").replace(/\/$/, "");
  const res = await fetch(`${base}/v1/public/workspaces/by-host?host=${encodeURIComponent(host)}`);
  if (!res.ok) throw new Error(`auth discovery failed: ${res.status}`);
  const data = (await res.json()) as {
    workspace_id: string;
    mode: "none" | "external" | "groundfloor";
    oidc: { issuer: string; client_id: string } | null;
  };

  if (data.mode === "none") return { mode: "none", workspaceId: data.workspace_id };
  if (!data.oidc) return { mode: "groundfloor", workspaceId: data.workspace_id, pending: true };
  return {
    mode: data.mode,
    workspaceId: data.workspace_id,
    issuer: data.oidc.issuer,
    clientId: data.oidc.client_id,
  };
}

In dev, pass `${SLUG}.app.groundfloor.cloud` as host; in prod pass the real Host header.


Switching modes

Changing a workspace's mode is just another PUT /v1/workspaces/{id}/auth (requires administer). Shell picks up the change on its next by-host call — cache the discovery result briefly (e.g. 30–60s) rather than indefinitely so mode changes propagate.


Troubleshooting

SymptomLikely cause
by-host404 No workspace registered for this hostDev host doesn't match; pass ${SLUG}.app.groundfloor.cloud, or set auth.host.subdomain
mode: groundfloor, oidc: null foreverProvisioning failed — check GET …/auth groundfloor.status (error) and Admin API env
PUT …/auth preflight 400PUT must be allowed by API CORS (CORS_ORIGINS + methods); restart API after config change
401 from pillar APIs in none modeExpected — end users have no token; use a server-side BFF or public endpoints
403 after login (groundfloor/external)Authenticated but no SpiceDB relation on the workspace

See also 12-troubleshooting.md.

On this page