Groundfloor Docs

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)

PlaneWhoIssuerUsed for
Platform (Control Plane)Portal admins, operators, devs building appsKeycloak platform realm/v1/workspaces/… pillar APIs
Workspace site (optional)End users of a customer appPer-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=60
  • KEYCLOAK_ISSUER must match the token iss claim (no trailing slash).
  • KEYCLOAK_AUDIENCE is a comma-separated list of client ids whose tokens the API accepts. Browser tokens often have aud=account; the SPA client id appears in azp — 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:8088

Register 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 expiry

Calling 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-urlencoded
  1. Sign in once in the Portal or Shell (authorization code + PKCE).
  2. Capture the refresh token from your Keycloak session (browser devtools / adapter).
  3. 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_token

Use 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_token

The 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_token

Requirements for any token

CheckDetail
IssuerToken iss must match API KEYCLOAK_ISSUER
AudienceToken aud or azp must match an entry in KEYCLOAK_AUDIENCE
MembershipUser (or service account) needs workspace membership for scoped routes
ExpiryRefresh 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:

  1. Preferred: Shell exposes the platform token to remotes (e.g. React context, @groundfloorcloud/shell useAuth, or Module Federation shared auth module). The remote calls setControlPlaneAuthProvider or sets Authorization on fetch before pillar API calls.
  2. 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:

PermissionTypical use
readList secrets (keys only), vault query, files list/download, LLM models/usage
writeUpsert secrets, file upload, vault create/update
deleteDelete secrets, files, vault records
administerMint 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/public

Workspace 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.cloud

Shell should OIDC-login against the returned issuer / client_id, not the platform Portal client. Until provision completes (groundfloor.statusactive), 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-patternWhy
Forward browser JWT to DataplaneUse Control Plane /vault proxy; service keys are server-side
Hardcode DATAPLANE_API_KEY in front-endTenant isolation break
Use Clerk SDK for new Shell workPlatform IdP is Keycloak
Call GET …/secrets/{key} from untrusted browser codeReveal is audited; values belong on server or trusted BFF
Skip token refreshKeycloak 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_URL points at Control Plane (default http://localhost:8088)
  • CORS: API CORS_ORIGINS includes Shell dev origin (e.g. http://localhost:3000)

See 11-local-dev-recipes.md for curl examples with a token.

On this page