Authentication
Keycloak OIDC integration for federated apps calling Control Plane APIs.
Audience: Shell host and federated remote developers
IdP: Keycloak (platform realm) — not Clerk
Control Plane validates Keycloak-issued access tokens on workspace-scoped APIs. Federated bootstrap (by-slug) is public and needs no token.
Two auth planes (do not mix them)
| Plane | Who | Issuer | Used for |
|---|---|---|---|
| Platform (Control Plane) | Portal admins, operators, devs building apps | Keycloak platform realm | /v1/workspaces/… pillar APIs |
| Workspace site (optional) | End users of a customer app | Per-workspace OIDC realm (gf-ws-{workspace_id}) | Shell on {slug}.app.groundfloor.cloud |
Phase 1 federated bootstrap is unauthenticated. Data APIs (secrets, vault, files, LLM) require a platform Keycloak bearer token unless workspace site auth is enabled and configured for that workspace.
Keycloak configuration
Control Plane API (backend .env)
IDP_PROVIDER=keycloak
KEYCLOAK_ISSUER=https://dev-auth.groundfloor.co/realms/groundfloor_dev
KEYCLOAK_AUDIENCE=groundfloor-portal,groundfloor-portal-admin
KEYCLOAK_JWT_LEEWAY=60KEYCLOAK_ISSUERmust match the tokenissclaim (no trailing slash).KEYCLOAK_AUDIENCEis a comma-separated list of client ids whose tokens the API accepts. Browser tokens often haveaud=account; the SPA client id appears inazp— include every public client your Shell or Portal uses (e.g.groundfloor-portal,groundfloor-shell).
Clerk (IDP_PROVIDER=clerk) remains supported for legacy deployments only. New Shell and app work should target Keycloak.
Browser apps (Shell host, Portal, federated remote)
NEXT_PUBLIC_KEYCLOAK_URL=https://dev-auth.groundfloor.co
NEXT_PUBLIC_KEYCLOAK_REALM=groundfloor_dev
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=groundfloor-portal # or a dedicated Shell public client
NEXT_PUBLIC_API_URL=http://localhost:8088Register a public OIDC client in Keycloak (PKCE, no client secret) for each front-end that calls Control Plane. Add its client id to KEYCLOAK_AUDIENCE on the API.
Obtaining an access token
In a Next.js app (keycloak-js)
The Customer Portal uses keycloak-js behind a vendor-neutral @/lib/auth surface. Shell should follow the same pattern:
import Keycloak from "keycloak-js";
const keycloak = new Keycloak({
url: process.env.NEXT_PUBLIC_KEYCLOAK_URL!,
realm: process.env.NEXT_PUBLIC_KEYCLOAK_REALM!,
clientId: process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID!,
});
await keycloak.init({ onLoad: "login-required", pkceMethod: "S256" });
const token = keycloak.token; // refresh via keycloak.updateToken() before expiryCalling Control Plane
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/v1/workspaces/${workspaceId}/secrets`,
{ headers: { Authorization: `Bearer ${token}` } },
);Using @groundfloor/api-client
import {
configureControlPlaneClient,
setControlPlaneAuthProvider,
useListSecretsV1WorkspacesWorkspaceIdSecretsGet,
} from "@groundfloor/api-client";
configureControlPlaneClient({ baseURL: process.env.NEXT_PUBLIC_API_URL });
setControlPlaneAuthProvider(async () => keycloak.token ?? null);Wire setControlPlaneAuthProvider at app boot with a function that returns the current Keycloak access token (refresh first if near expiry).
Programmatic / CLI access
The Control Plane does not issue login tokens. Obtain access tokens from Keycloak's OIDC token endpoint and pass them as Authorization: Bearer … on every API call.
Token URL (platform realm):
POST {KEYCLOAK_ISSUER}/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencodedOption A — User token via refresh (recommended for scripts)
- Sign in once in the Portal or Shell (authorization code + PKCE).
- Capture the refresh token from your Keycloak session (browser devtools / adapter).
- Exchange it for a fresh access token:
KEYCLOAK_ISSUER="https://dev-auth.groundfloor.co/realms/groundfloor_dev"
CLIENT_ID="groundfloor-portal" # must be in API KEYCLOAK_AUDIENCE
curl -s -X POST "$KEYCLOAK_ISSUER/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "client_id=$CLIENT_ID" \
-d "refresh_token=$REFRESH_TOKEN" | jq -r .access_tokenUse the access token:
export CP_TOKEN="$(… above …)"
curl -s -H "Authorization: Bearer $CP_TOKEN" \
"$NEXT_PUBLIC_API_URL/v1/workspaces/$WORKSPACE_ID/apps" | jq .On first successful call, Portal JIT-provisions the user row from JWT claims (sub, email). No webhook is involved.
Option B — Client credentials (automation / CI)
Create a confidential Keycloak client with service accounts enabled. Add its client id to the API's KEYCLOAK_AUDIENCE. Then:
curl -s -X POST "$KEYCLOAK_ISSUER/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$SERVICE_CLIENT_ID" \
-d "client_secret=$SERVICE_CLIENT_SECRET" | jq -r .access_tokenThe service account must still have workspace membership and ReBAC roles in Portal (same as any other caller). Client-credentials tokens act as the service account user, not an arbitrary human.
Option C — Direct grant (dev only)
If the realm allows Direct Access Grants on a public client (often disabled in production):
curl -s -X POST "$KEYCLOAK_ISSUER/protocol/openid-connect/token" \
-d "grant_type=password" \
-d "client_id=$CLIENT_ID" \
-d "username=user@example.com" \
-d "password=$PASSWORD" | jq -r .access_tokenRequirements for any token
| Check | Detail |
|---|---|
| Issuer | Token iss must match API KEYCLOAK_ISSUER |
| Audience | Token aud or azp must match an entry in KEYCLOAK_AUDIENCE |
| Membership | User (or service account) needs workspace membership for scoped routes |
| Expiry | Refresh before expiry; access tokens are short-lived |
Legacy POST /v1/webhooks/clerk is not used with Keycloak and is hidden from OpenAPI when IDP_PROVIDER=keycloak.
Federated remote inside Shell
When the remote runs inside the Shell host:
- Preferred: Shell exposes the platform token to remotes (e.g. React context,
@groundfloorcloud/shelluseAuth, or Module Federation shared auth module). The remote callssetControlPlaneAuthProvideror setsAuthorizationon fetch before pillar API calls. - Alternative (dev only): Remote runs its own Keycloak public client with the same realm — heavier, duplicates login.
Do not embed long-lived API keys in the federated bundle. Use Keycloak session tokens for user-scoped calls; store service keys in Secrets and reveal them server-side only.
Permissions (ReBAC)
Control Plane checks SpiceDB permissions on workspace-scoped routes:
| Permission | Typical use |
|---|---|
read | List secrets (keys only), vault query, files list/download, LLM models/usage |
write | Upsert secrets, file upload, vault create/update |
delete | Delete secrets, files, vault records |
administer | Mint LLM virtual key, workspace auth config, DDL |
A 403 means the user is authenticated but lacks the relation on that workspace. Ensure the user has a membership with the right role in the Portal.
Workspace-scoped tokens (future)
When workspace site auth is enabled (GET/PUT /v1/workspaces/{id}/auth), Control Plane also accepts tokens from the workspace OIDC issuer on workspace APIs (workspace_scoped_actor). Platform Keycloak tokens are tried first (Portal UI path).
Public auth discovery (for Shell login redirects):
GET /v1/public/workspaces/by-host?host={hostname}
GET /v1/public/workspaces/{workspace_id}/auth/publicWorkspace site auth (opt-in)
Enable on a workspace (requires administer on the workspace):
PUT /v1/workspaces/{workspace_id}/auth
Content-Type: application/json
Authorization: Bearer {platform_token}
{ "mode": "groundfloor" }Control Plane sets auth.host.subdomain to the workspace slug, provisions realm gf-ws-{workspace_id}, and exposes public OIDC metadata for Shell:
GET /v1/public/workspaces/by-host?host={slug}.app.groundfloor.cloudShell should OIDC-login against the returned issuer / client_id, not the platform Portal client. Until provision completes (groundfloor.status → active), poll GET /v1/workspaces/{id}/auth.
Local Keycloak: deploy/docker-compose.phase2-deps.yml --profile keycloak (see deploy/PHASE2-DEPS.md).
What not to do
| Anti-pattern | Why |
|---|---|
| Forward browser JWT to Dataplane | Use Control Plane /vault proxy; service keys are server-side |
Hardcode DATAPLANE_API_KEY in front-end | Tenant isolation break |
| Use Clerk SDK for new Shell work | Platform IdP is Keycloak |
Call GET …/secrets/{key} from untrusted browser code | Reveal is audited; values belong on server or trusted BFF |
| Skip token refresh | Keycloak access tokens expire; call updateToken(minValidity) before API calls |
Local dev checklist
- Keycloak realm and public client exist for your Shell app
- Client id listed in API
KEYCLOAK_AUDIENCE - User is a member of the workspace (Portal → workspace → members)
-
NEXT_PUBLIC_API_URLpoints at Control Plane (defaulthttp://localhost:8088) - CORS: API
CORS_ORIGINSincludes Shell dev origin (e.g.http://localhost:3000)
See 11-local-dev-recipes.md for curl examples with a token.