Groundfloor Docs

Who can reach your Service

Every Service is public. Decide who may use it, and check the signed-in user's token when it's the backend for a Shell app.

Every Service is on the public internet: anyone who knows its address can send it a request. Groundfloor delivers those requests to your container, but it doesn't sign anyone in or check who they are. Your Service decides who gets in.

Choose what your Service needs

Your Service is…ExampleWhat you do
Open to everyoneA website, blog, docs, public API or webhook receiverNothing. That's the default
An app with its own loginsWordPress, Ghost, Metabase, or your own app with user accountsNothing from Groundfloor: the app's own login protects it. Finish its installer as soon as it's deployed, and keep admin passwords strong
The backend for a Shell appAn API your Shell app calls with __GF_GET_ACCESS_TOKEN__()Check the user's token on every request, as below

Signing in to your Shell app doesn't protect your Service

Users sign in to your Shell site, not to the Services behind it. Anyone can call a Service's address directly, without signing in. If your Service is the backend for a Shell app, it must check the token on every request itself.

Check the token in a Shell app's backend

Your Shell app sends the signed-in user's token as Authorization: Bearer <token> (see Secrets and backends). The token is a JWT signed by your workspace's sign-in. Your backend checks it, then decides what the user may do.

Find your issuer and client ID

Ask Groundfloor for your Shell site's sign-in settings. No token is needed:

curl "https://platform.groundfloor.cloud/v1/public/workspaces/by-host?host=<workspace-slug>.app.groundfloor.cloud"

Use your own domain as host if your Shell site has one. The answer looks like this:

{
  "workspace_id": "3f6c…",
  "mode": "groundfloor",
  "oidc": {
    "issuer": "https://auth.groundfloor.cloud/realms/gf-ws-3f6c…",
    "client_id": "shell"
  },
  "site_host": "<workspace-slug>.app.groundfloor.cloud"
}
  • Check that workspace_id is your workspace's ID, so you know you asked about the right site.
  • oidc.issuer is your issuer. With Groundfloor sign-in it's unique to your workspace; with External sign-in it's your identity provider's issuer.
  • oidc.client_id is your client ID: shell with Groundfloor sign-in, or the client ID you entered for External sign-in.
  • oidc is null when the sign-in mode is None: there are no tokens to check.

Look these up once, when you set up the Service. Copy both values exactly as they appear, including any trailing / on the issuer, and give them to your Service as AUTH_ISSUER and AUTH_CLIENT_ID, for example as ENV lines in its Dockerfile. Neither is secret. Look them up again if you change your sign-in mode.

Never look them up at runtime from the request's Host header: whoever sends the request chooses that header, so they could point your Service at a site whose tokens they control.

What to check

Reject the request with 401 unless all of these hold:

  • Signature: the token is signed with RS256 by one of the keys at {issuer}/protocol/openid-connect/certs. That address holds more than one key, so pick the one whose kid matches the token's header. With External sign-in, use your provider's key address instead (its jwks_uri).
  • Issuer: iss equals your issuer exactly.
  • Expiry: exp is in the future.
  • Client: the token was issued to your client ID.
    • With Groundfloor sign-in, azp equals your client ID. Don't require aud to name your app: tokens don't carry an audience for your app yet.
    • With External sign-in, the token must name your client ID, either in azp (or your provider's equivalent, such as cid or appid) or in aud. Never accept a token that names it in neither.

Then return 403 if the user is signed in but their role doesn't allow what they asked for.

With External sign-in, check whether your provider lets anyone sign up. If it does, "any signed-in user" means anyone at all, so check a role for everything that isn't public.

Read the user and their role

ClaimWhat it is
realm_access.rolesThe user's roles: the built-in viewer, user and admin, and roles you created on App authentication → Roles. The list also holds roles your app can ignore, such as offline_access, uma_authorization and names starting with default-roles-
organizationThe user's companies (tenants), when Multi-tenant is on in App authentication

With External sign-in, roles come from your identity provider, in whatever claim it uses.

To tie records to a user, use the user ID described in Know who the user is.

With Multi-tenant on, roles don't separate companies

A role applies across every company: an admin in one company is an admin for your whole app. So filter every read and write by the user's organization claim as well as their role. The claim can hold more than one company, so check that the record's company is one of them. Check its exact form in a token from your own workspace; the examples below accept a list of company names or an object keyed by company name.

Example: FastAPI

Uses PyJWT (pip install "pyjwt[crypto]" fastapi). PyJWKClient caches the keys and picks the one named by the token's kid. When a token names a kid it doesn't have, it fetches the keys again, every time; the timeout stops a slow fetch from holding up requests.

main.py
import os

import jwt
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

ISSUER = os.environ["AUTH_ISSUER"]  # exactly as by-host returned it
CLIENT_ID = os.environ["AUTH_CLIENT_ID"]  # "shell" with Groundfloor sign-in
JWKS_URL = os.environ.get(
    "AUTH_JWKS_URL", ISSUER.rstrip("/") + "/protocol/openid-connect/certs"
)

jwks = jwt.PyJWKClient(JWKS_URL, timeout=5)
bearer = HTTPBearer(auto_error=False)

app = FastAPI()


def issued_to_us(claims: dict) -> bool:
    if CLIENT_ID in (claims.get("azp"), claims.get("cid"), claims.get("appid")):
        return True
    aud = claims.get("aud")  # External sign-in only; Groundfloor tokens use azp
    return aud == CLIENT_ID or (isinstance(aud, list) and CLIENT_ID in aud)


def current_user(creds: HTTPAuthorizationCredentials | None = Depends(bearer)) -> dict:
    unauthorized = HTTPException(401, "Sign in first", headers={"WWW-Authenticate": "Bearer"})
    if creds is None:
        raise unauthorized
    try:
        key = jwks.get_signing_key_from_jwt(creds.credentials)
        claims = jwt.decode(
            creds.credentials,
            key.key,
            algorithms=["RS256"],
            issuer=ISSUER,
            options={"require": ["exp", "iss"], "verify_aud": False},
            leeway=30,
        )
    except jwt.PyJWKClientConnectionError:
        raise HTTPException(503, "Can't reach the sign-in service")
    except jwt.PyJWTError:
        raise unauthorized
    if not issued_to_us(claims):
        raise unauthorized
    return claims


def user_roles(claims: dict) -> set[str]:
    access = claims.get("realm_access")
    roles = access.get("roles") if isinstance(access, dict) else None
    return {r for r in roles if isinstance(r, str)} if isinstance(roles, list) else set()


def user_companies(claims: dict) -> set[str]:
    org = claims.get("organization")
    if isinstance(org, dict):
        return {k for k in org if isinstance(k, str)}
    if isinstance(org, list):
        return {o for o in org if isinstance(o, str)}
    return set()


def require_role(*roles: str):
    def check(claims: dict = Depends(current_user)) -> dict:
        if user_roles(claims).isdisjoint(roles):
            raise HTTPException(403, "You don't have access to this")
        return claims

    return check


@app.get("/api/orders")
def list_orders(claims: dict = Depends(require_role("user", "admin"))):
    companies = user_companies(claims)  # Multi-tenant: only this user's companies
    return {"orders": [], "companies": sorted(companies)}


@app.post("/api/refunds")
def create_refund(claims: dict = Depends(require_role("admin"))):
    return {"ok": True}  # admins only

verify_aud is off because Groundfloor tokens can carry an aud that isn't yours, and PyJWT rejects any aud it wasn't told to expect; issued_to_us checks the client instead. Set AUTH_JWKS_URL only with External sign-in. A malformed realm_access or organization claim counts as no roles and no companies, so the request gets 403, not an error.

Example: Node.js

Uses jose (npm install jose), as Express middleware. createRemoteJWKSet caches the keys and picks the one named by the token's kid. It fetches the keys again for an unknown kid at most every 30 seconds, and gives up on a fetch after 5 seconds.

auth.mjs
import { createRemoteJWKSet, jwtVerify } from "jose";

const ISSUER = process.env.AUTH_ISSUER; // exactly as by-host returned it
const CLIENT_ID = process.env.AUTH_CLIENT_ID; // "shell" with Groundfloor sign-in
if (!ISSUER || !CLIENT_ID) throw new Error("Set AUTH_ISSUER and AUTH_CLIENT_ID");
const JWKS = createRemoteJWKSet(
  new URL(process.env.AUTH_JWKS_URL ?? `${ISSUER.replace(/\/$/, "")}/protocol/openid-connect/certs`),
);

function issuedToUs(p) {
  if ([p.azp, p.cid, p.appid].includes(CLIENT_ID)) return true;
  return Array.isArray(p.aud) ? p.aud.includes(CLIENT_ID) : p.aud === CLIENT_ID; // External only
}

// req.user holds the token's claims, or the request ends with 401.
export async function requireUser(req, res, next) {
  const [scheme, token] = (req.headers.authorization ?? "").split(" ");
  if (scheme?.toLowerCase() !== "bearer" || !token) return res.status(401).end();
  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: ISSUER,
      algorithms: ["RS256"],
      requiredClaims: ["exp"],
      clockTolerance: 30,
    });
    if (!issuedToUs(payload)) return res.status(401).end();
    req.user = payload;
    next();
  } catch {
    res.status(401).end();
  }
}

export function requireRole(...roles) {
  return (req, res, next) => {
    const r = req.user?.realm_access?.roles;
    const userRoles = Array.isArray(r) ? r : [];
    if (!roles.some((role) => userRoles.includes(role))) return res.status(403).end();
    next();
  };
}

// Multi-tenant: the companies whose records this user may see.
export function userCompanies(user) {
  const org = user?.organization;
  if (Array.isArray(org)) return org.filter((o) => typeof o === "string");
  if (org && typeof org === "object") return Object.keys(org);
  return [];
}
app.get("/api/orders", requireUser, requireRole("user", "admin"), listOrders);
app.post("/api/refunds", requireUser, requireRole("admin"), createRefund);

Allow calls from your Shell site

Your Shell site and your Service are on different addresses, so the browser asks the Service first (a CORS preflight) before sending a request with an Authorization header. Unless the Service answers it, the browser blocks the call. Allow your Shell site's address, and your own domain if it has one:

main.py
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://<workspace-slug>.app.groundfloor.cloud"],
    allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
    allow_headers=["Authorization", "Content-Type"],
)

In Node.js, use the cors package with the same settings. Don't use a bare cors(), which allows every origin:

import cors from "cors";

app.use(cors({
  origin: ["https://<workspace-slug>.app.groundfloor.cloud"],
  allowedHeaders: ["Authorization", "Content-Type"],
}));

The middleware answers the preflight before your token check runs, so preflights don't get 401. The token travels in a header, not a cookie, so you don't need to allow credentials. Never combine * as the allowed origin with credentials.

Every app on your Shell site can use the user's token

Only install apps you trust

Every app on your Shell site, including apps you didn't write, can get the signed-in user's token and call your backends as that user. A token a user gets in one of your apps is also accepted by your other apps' backends in the workspace. Only install apps you trust, and check roles for anything sensitive.

Tokens issued for one app only, sign-in settings passed to your Service automatically, and a helper that checks tokens for you are coming soon.

Next steps

On this page