Groundfloor Docs

App sign-in library

Check app sign-in tokens in a Node.js backend and look up a standalone app's sign-in settings in the browser.

Coming soon on npm

@groundfloorcloud/app-auth isn't published on npm yet, so npm install doesn't find it. Until it is, check tokens as shown in Who can reach your Service. App sign-in itself is rolling out.

@groundfloorcloud/app-auth checks, in your backend, that a request comes from a user signed in to your app, and gives your front end its standalone app's sign-in settings. It's for your app's users, not your team.

npm install @groundfloorcloud/app-auth

It's open source under the MIT licence. It needs Node.js 20.19 or later and works with ESM and CommonJS. Its one dependency is jose; the browser entry, @groundfloorcloud/app-auth/browser, doesn't include it.

Check tokens in your backend

import { requireAppUser, verifyAppToken, AppAuthError } from "@groundfloorcloud/app-auth";

app.use("/api", requireAppUser()); // 401 without a valid app token
app.post("/api/posts", requireAppUser({ roles: ["editor"] }), handler); // 403 without the role

try {
  const user = await verifyAppToken(token);
} catch (err) {
  if (err instanceof AppAuthError) res.status(err.status).json({ error: err.code });
}
FunctionWhat it does
requireAppUser(options?)Express-style middleware. Reads Authorization: Bearer <token>, checks it, and puts the user on res.locals.appUser and req.appUser. Answers 401 with {"error": "<code>"} and WWW-Authenticate: Bearer, or 403 for a missing role or company
createAppTokenVerifier(options?)Returns a function that checks a token and resolves to the user. Reads its settings once, so a missing setting fails when your server starts
verifyAppToken(token, options?)Checks one token in a single call
verifyShellToken(token, options?), createShellTokenVerifier(options?)The same for a Shell app's backend, which gets the Shell site token (azp shell) with Groundfloor sign-in. Every check applies except the audience. Pass issuer. See Every app on your Shell site can use the user's token
hasAnyRole(user, roles)true if the user has at least one of the roles
bearerToken(header)The token from an Authorization header, or null
readRoles(claims), readOrganization(claims)Read roles, and companies as { aliases, ids }, from a token's claims

An app token is accepted only if it's signed with RS256 by a key from the JWKS URL, iss equals the issuer exactly, aud contains the app's client ID, azp equals it (a token without azp is refused), exp hasn't passed (with 30 seconds of clock leeway by default, at most 60; nbf and iat, when present, get the same leeway), typ (when present) is Bearer, sub is present, and there's no x-client-key or x-userid claim. Those claims mark Groundfloor's own Coderunner management and developer tokens, which are refused with wrong_token_type. The keys are cached for 10 minutes and fetched again, at most every 30 seconds, when a token names a key the cache doesn't have.

The user

FieldWhat it is
subThe user's ID. Store and match their records by it, not by email
rolesThe user's roles, without offline_access, uma_authorization and default-roles-*. A malformed claim reads as no roles
organizationThe aliases of the user's companies (tenants), with Multi-tenant on; otherwise empty
organizationIdsThe companies' IDs, when the token has them
emailThe user's email address, or null
clientIdThe client the token was issued to (azp)
claimsEvery claim in the token

Any user of the workspace can sign in to any of its apps, so always check roles. With Multi-tenant on, a role applies in every company the user belongs to: filter reads and writes by organization too, or use requireOrganization.

Options

Options you pass win over environment variables.

OptionDefault
issuerGROUNDFLOOR_AUTH_ISSUER
audienceGROUNDFLOOR_AUTH_AUDIENCE (the app's client ID, app-<app-id>)
jwksUrlGROUNDFLOOR_AUTH_JWKS_URL, else {issuer}/protocol/openid-connect/certs. https only, or http on localhost
requireOrganizationfalse. true needs any company; a tenant's alias or ID needs that company. Otherwise 403 organization_required
clockToleranceSeconds30. Any value from 0 to 60; more than 60 is refused
jwksCooldownMs, jwksCacheMaxAgeMs, jwksTimeoutMs30000, 600000, 5000
envprocess.env

requireAppUser also takes roles (the user needs at least one), shell: true to check Shell site tokens instead, and onError(err, req) for logging. Never log the token itself.

Environment variables

Groundfloor sets these on a standalone app's running main Service when it deploys, unless you set them yourself, while app sign-in is switched on. They're your backend's runtime environment, not variables in your front end's build. See Find your issuer and client ID.

VariableValue
GROUNDFLOOR_AUTH_ISSUERThe issuer of the app's tokens
GROUNDFLOOR_AUTH_JWKS_URL{issuer}/protocol/openid-connect/certs
GROUNDFLOOR_AUTH_AUDIENCEapp-<app-id>
GROUNDFLOOR_AUTH_CLIENT_IDapp-<app-id>, for the front end's sign-in
GROUNDFLOOR_APP_IDThe app's ID

Errors

AppAuthError has a code and the HTTP status to answer with:

Statuscode
401missing_token, invalid_token, token_expired, wrong_issuer, wrong_audience, wrong_client, wrong_token_type, missing_subject
403role_required, organization_required
500config: a setting is missing or wrong in your backend
503jwks_unavailable: the signing keys couldn't be fetched

Get sign-in settings in the browser

import { getAppAuthConfig } from "@groundfloorcloud/app-auth/browser";

const cfg = await getAppAuthConfig({ controlPlaneUrl: "https://platform.groundfloor.cloud" });
// { issuer, clientId, realm, redirectUri: "https://<this host>/auth/callback" }, or null

getAppAuthConfig asks Groundfloor for the sign-in settings of the standalone app on the page's own host, and resolves to null when that host has no app sign-in. Pass controlPlaneUrl, for example from your build config. The lookup accepts calls from any web address, so it works on your custom domains. Hand the result to an OpenID Connect library with PKCE; see Sign users in from the browser.

OptionDefault
controlPlaneUrlGroundfloor's main API address. Pass https://platform.groundfloor.cloud so it matches the cell your workspace is on
hostThe page's host (location.hostname). On a local dev server, pass the app's real host; redirectUri is then https://<host>/auth/callback, so use your dev server's own callback address instead
callbackPath/auth/callback
fetch, signalThe browser's fetch, no abort signal

getAppToken() is for Shell apps, once per-app tokens for Shell apps arrive. Until then it throws "not available yet".

Next steps

On this page