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:
| Mode | End-user login | OIDC issuer | Who manages users |
|---|---|---|---|
none | No login — app is open / handles its own access | — | n/a |
groundfloor | OIDC against a Groundfloor-managed realm gf-ws-{workspace_id} | provisioned by Control Plane | Control Plane → Authentication area |
external | OIDC against the customer's own IdP (Okta, Azure AD, Auth0, …) | customer-provided | customer's IdP |
Dev plan: start with
none(no IdP needed), confirm the Shell renders and the resolution flow works, then layer ingroundflooronce a local Keycloak is running, andexternallast.
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" }
}oidcisnullfornone, and alsonullwhilegroundflooris 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_domain → auth.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 + readResolution 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 --> IMode 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:
by-hostreturnsmode: "none",oidc: null.- Skip OIDC entirely — do not initialize
keycloak-js. - 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:
| Approach | When to use |
|---|---|
Control Plane /vault from a BFF | Preferred — 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 BFF | Advanced — read DATAPLANE_SERVICE_API_KEY from Secrets at deploy (never from browser). Same tenant boundary as the workspace id. |
| Browser → Control Plane | Only 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_ORIGINSincludes the Shell dev origin (http://localhost:3000) - Workspace auth mode is
none(Portal → Authentication → Settings, or thePUTabove) -
by-hostreturns{"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 activeShell behavior:
by-hostreturnsmode: "groundfloor". Ifoidcisnull, provisioning is still in progress — show a "setting up sign-in" state and retry.- When
oidcis present, run OIDC (PKCE) login againstoidc.issuerwithoidc.client_id(the managedshellclient) — not the platform Portal client. - 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:
| Claim | Source |
|---|---|
external_tenant_id | Workspace UUID (hardcoded mapper) |
external_user_id | Portal 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
PUTwithmode: "external"and noexternalblock returns400.
Shell behavior:
by-hostreturnsmode: "external"with the customer'soidc.issuer/oidc.client_id.- Run OIDC (PKCE) login against that issuer.
- 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 expiryPermissions 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
| Symptom | Likely cause |
|---|---|
by-host → 404 No workspace registered for this host | Dev host doesn't match; pass ${SLUG}.app.groundfloor.cloud, or set auth.host.subdomain |
mode: groundfloor, oidc: null forever | Provisioning failed — check GET …/auth groundfloor.status (error) and Admin API env |
PUT …/auth preflight 400 | PUT must be allowed by API CORS (CORS_ORIGINS + methods); restart API after config change |
401 from pillar APIs in none mode | Expected — 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.