Skip to content

Identity Provider

Authentication illustration

The SCF Controls Platform can authenticate users through OpenID Connect (OIDC) single sign-on. This is optional and off by default — with VITE_OIDC_ENABLED unset or false, the platform keeps using the existing Google Sign-In / API-key authentication and nothing on this page applies.

When you turn it on, you have two paths:

  • Bundled Keycloak — the stack can bring up a self-hosted Keycloak identity provider for you. No external IdP account required; users live in a dedicated schema on the bundled Postgres container. It requires the bundled database — see the warning below.
  • Bring your own OIDC — point the platform at Okta, Microsoft Entra ID, Auth0, Google Workspace, or any standards-compliant OIDC provider. The generic relying-party layer (authorization code + PKCE, issuer discovery, JWKS) works with any of them.

The bundled IdP lives behind a Docker Compose profile, so it stays off until you ask for it. Keycloak stores its realm and users in a dedicated keycloak schema on the existing Postgres (KC_DB_SCHEMA=keycloak) and auto-imports its realm from idp/scf-realm.json on first boot.

  1. Set the required variables in .env. At minimum, turn on OIDC and give Keycloak its admin credentials (the IdP containers exit loudly if KC_ADMIN_USER / KC_ADMIN_PASSWORD are empty):

    Terminal window
    VITE_OIDC_ENABLED=true
    KC_ADMIN_USER=admin
    KC_ADMIN_PASSWORD=<a-strong-password>
    OIDC_CLIENT_SECRET=<openssl rand -hex 32>
    BOOTSTRAP_ADMIN_EMAIL=you@example.com # seeds a first platform admin (optional)

    See the environment variables table below for the full list and defaults.

  2. Start the stack with the idp profile:

    Terminal window
    docker compose --profile idp up -d

    This brings up Keycloak 26.3 (quay.io/keycloak/keycloak:26.3) plus a one-shot idp-init container. idp-init writes OIDC_CLIENT_SECRET into the realm’s client and — if BOOTSTRAP_ADMIN_EMAIL is set — creates that admin user with a random temporary password. It is idempotent, so re-running the profile is safe.

  3. Read the one-time temporary password from the idp-init logs. The password is printed once, only to that container’s logs:

    Terminal window
    docker compose logs idp-init

    Copy the temporary password for the bootstrapped admin before continuing.

  4. Sign in for the first time. Open the app at http://localhost:5173, click Sign in, and you are redirected to Keycloak (http://localhost:8081). Enter your BOOTSTRAP_ADMIN_EMAIL and the temporary password. Keycloak then forces two required actions on this first login:

    1. Update Password — replace the temporary password.
    2. Update Account Information — set your first and last name.

    You are then redirected back through /api/auth/callback and land in the app.

  5. (Optional) Reach the Keycloak admin console at http://localhost:8081/admin, signing in with KC_ADMIN_USER / KC_ADMIN_PASSWORD. This is where you create additional users, enable self-registration, or adjust the realm.

The setup examples above use localhost, which is correct only when your browser runs on the same machine as the Docker stack. If the stack runs on a different machine (a home server, a VM, a cloud host), the sign-in redirect and token validation both involve URLs your browser must reach — localhost won’t resolve there, and the first login fails.

Set these three in .env to the host’s reachable address (IP or DNS name):

8080/realms/scf
KC_HOSTNAME=http://<docker-host>:8081 # what Keycloak advertises
OIDC_ISSUER=http://<docker-host>:8081/realms/scf # must byte-match the token `iss`
OIDC_REDIRECT_URI=http://<docker-host>:5173/api/auth/callback # where the browser returns to
  • OIDC_DISCOVERY_URL does not change. The backend fetches it server-side over the Docker network; it is never browser-facing.
  • If you remapped KEYCLOAK_PORT or FRONTEND_PORT, use the remapped ports in the URLs above.
  • Behind a reverse proxy with TLS, use the public https:// URL for all three and add KC_PROXY_HEADERS=xforwarded to the keycloak service.

After changing them, restart the stack (docker compose --profile idp up -d) and verify discovery returns the public issuer: curl http://<docker-host>:8081/realms/scf should return HTTP 200 and the JSON’s issuer should match your OIDC_ISSUER exactly.

These variables configure the OIDC relying-party layer and the bundled Keycloak. KC_* values only apply when you run the idp profile.

VariableDefaultPurpose
VITE_OIDC_ENABLEDfalseFrontend: use redirect-based OIDC sign-in instead of Google. Build-time (baked into the frontend image)
OIDC_ISSUERhttp://localhost:8081/realms/scfPUBLIC issuer; must byte-match the token iss claim and be browser-reachable
OIDC_DISCOVERY_URLhttp://keycloak:8080/realms/scfINTERNAL realm base the backend fetches (bare base, no /.well-known suffix)
OIDC_CLIENT_IDscf-platformOIDC client ID
OIDC_CLIENT_SECRETchangeme-generate-a-real-oidc-secretConfidential client secret; idp-init writes it into Keycloak at boot
OIDC_REDIRECT_URIhttp://localhost:5173/api/auth/callbackOAuth callback URI (must be registered on the client)
OIDC_SCOPESopenid email profileRequested scopes
KC_ADMIN_USERadminKeycloak admin user — REQUIRED with --profile idp. The backend also reads it to create Keycloak accounts at invite time — see User provisioning
KC_ADMIN_PASSWORDchangeme-keycloak-adminKeycloak admin password — REQUIRED with --profile idp. Also read by the backend for invite-time provisioning
KC_HOSTNAMEhttp://localhost:<KEYCLOAK_PORT>Public base URL Keycloak advertises; default follows a remapped KEYCLOAK_PORT. Set explicitly for remote hosts or a reverse proxy
BOOTSTRAP_ADMIN_EMAIL(blank)If set, idp-init seeds this platform admin with a one-time random temp password printed to the idp-init logs

OIDC_ISSUER and OIDC_DISCOVERY_URL look similar but serve different roles, and confusing them is the classic footgun — especially behind a reverse proxy.

A freshly-provisioned OIDC user — bundled or bring-your-own — authenticates successfully but has zero organization memberships. Until an admin adds them to an organization, the app has no workspace to show them and they land on a loading state rather than data.

This applies to the bootstrapped BOOTSTRAP_ADMIN_EMAIL user too: it is a platform admin but still needs an organization membership before a workspace appears.

To grant membership, an existing admin adds the user to an organization via Admin → User Management (or the org members API). See User Management for the workflow.

What an invite does depends on which identity provider the platform is pointed at.

When the platform runs the bundled Keycloak (docker compose --profile idp) and the backend has KC_ADMIN_USER, KC_ADMIN_PASSWORD and OIDC_DISCOVERY_URL / OIDC_ISSUER configured, inviting somebody from Settings › User Management › Invite User also creates their Keycloak account.

The backend looks the email up in the realm first:

  • Not in the realm — it creates the user (username = the email address, enabled, email marked verified, required action Update Password) and sets a generated temporary password. The password is temporary, so Keycloak forces a new one at first sign-in.
  • Already in the realm — nothing is changed and no password is set. The existing identity is left exactly as it is.

When the invite created the account, the invitee’s first sign-in accepts the invitation: after Keycloak has made them replace the temporary password they arrive in the organisation with the role and employment type on the invite, and they appear in User Management straight away. They do not need to open the invitation link, though the link still works if they do. The platform ties this to the Keycloak account it created (the subject on the invite), never to an email address, so an account the platform did not create is not joined this way — those invitees, like everyone on the paths below, join through the link.

Cancelling an invite deletes the Keycloak account only when the invite created it and the person has never signed in to the platform. In every other case the identity is left alone.

Every Keycloak account the platform creates or deletes is written to the platform audit log under entity type idp_user.

Nothing changes on these paths. The platform does not create accounts in an external identity provider: an admin still creates the account in the IdP as usual, and the invite links that identity to the organisation the first time the person signs in.

Each outstanding invitation in the pending-invite list carries a badge saying which of the above applied:

BadgeMeaning
IdP accountAn account exists in the bundled Keycloak — created by the invite, or already there before it
No IdP accountThe bundled Keycloak is enabled, but no account exists for this email
External IdPProvisioning is disabled — a bring-your-own OIDC or Google install, where the IdP owns accounts

The BOOTSTRAP_ADMIN_EMAIL user seeded by idp-init is created independently of any invite, and still is.

SymptomCauseFix
Login fails with a 404 fetching discoveryOIDC_DISCOVERY_URL has a /.well-known/openid-configuration suffix, giving a doubled pathSet OIDC_DISCOVERY_URL to the bare realm base — the backend appends the suffix itself
Signed in but stuck on the loading spinner, no workspaceThe user has no organization membershipAn admin adds them to an organization (see above)
User can’t sign in at the IdP at allNo account exists for them in the IdPCheck the badge on their pending invite. No IdP account on a bundled-Keycloak install means KC_ADMIN_USER / KC_ADMIN_PASSWORD were not configured for the backend when they were invited — cancel the invite and re-invite, or create the user in the Keycloak admin console. On External IdP installs, create the user in your own IdP
idp-init or Keycloak container exits immediatelyKC_ADMIN_USER / KC_ADMIN_PASSWORD are emptySet both in .env; they are required with --profile idp
Login works locally but fails behind a reverse proxyOIDC_ISSUER does not byte-match the public token issSet OIDC_ISSUER to the exact public issuer; keep OIDC_DISCOVERY_URL internal
Sign-in redirects to localhost from another machine, or iss validation failsStack runs on a remote host but the browser-facing URLs still say localhostSet KC_HOSTNAME, OIDC_ISSUER, and OIDC_REDIRECT_URI to the host’s reachable address — see Deploying on a remote host