67 docs indexed

Dashboard SSO

Configure OIDC single sign-on (Globus, CILogon, or any discovery-capable provider) for the obleth control-plane dashboard, alongside the break-glass local admin account.

The obleth dashboard supports two authentication methods that coexist: an OIDC SSO path for institutional identity providers and a local email/password admin that always works even when the IdP is unreachable.

Overview

obleth acts as an OIDC Relying Party. When SSO is configured, users sign in via their institution's IdP (Globus, CILogon, or any provider that exposes a discovery document). New SSO users land in a pending state with no access until an admin assigns them a role and a tenant on the dashboard's Users screen.

Two roles are available:

RoleAccess
adminFull dashboard: models, tenants, keys, usage, settings
userSelf-service portal: read-only model list, manage own API keys, view own usage

The local admin bypasses SSO and is always available as a break-glass account.

Configuring OIDC providers

SSO is configured through environment variables on the control plane. Where you set them depends on how you run it:

  • Docker Compose: deploy/docker/.env (read only by the Docker stack).
  • Control plane on the host (npm run dev): control-plane/.env.local.
  • Helm: the controlPlane.* values (see Kubernetes below).

Set the OIDC_PROVIDERS environment variable to a JSON array. Each entry describes one provider:

[
  {
    "providerId": "globus",
    "displayName": "Globus",
    "discoveryUrl": "https://auth.globus.org/.well-known/openid-configuration",
    "clientId": "YOUR_CLIENT_ID",
    "clientSecret": "YOUR_CLIENT_SECRET",
    "scopes": ["openid", "email", "profile"]
  }
]
FieldDescription
providerIdShort identifier used in the callback URL (alphanumeric + hyphens)
displayNameLabel shown on the sign-in button
discoveryUrlOIDC discovery document URL for the provider
clientIdClient ID from the IdP registration
clientSecretClient secret from the IdP registration
scopesOAuth scopes to request; openid email profile is sufficient for obleth

Four optional fields cover providers that need more than the defaults:

FieldDefaultDescription
authenticationbody credentialsToken-endpoint client authentication: "basic" (HTTP Basic) or "post" (credentials in the request body)
claimsstandard claimsMap email, name, or image to a non-standard claim name
overrideUserInfofalseRe-apply the profile (and the claim mapping) to existing users on every sign-in, not only at sign-up
pkcefalseSend a PKCE code challenge with the authorization request and the verifier at the token exchange (RFC 7636)

An unrecognised authentication value or an unknown claims field is rejected with an explicit error rather than silently ignored.

Redirect URI to register with the IdP

When registering an obleth client in your IdP's developer console, use this redirect URI:

https://<dashboard-host>/api/auth/oauth2/callback/<providerId>

For example, with providerId: "globus" and a dashboard at https://dashboard.example.edu:

https://dashboard.example.edu/api/auth/oauth2/callback/globus

Note:

The host in the callback comes from BETTER_AUTH_URL — set it to the external, browser-facing URL of the dashboard (e.g. https://dashboard.example.edu). The redirect URI you register with the IdP must match it exactly, including scheme and port.

Globus

Discovery URL: https://auth.globus.org/.well-known/openid-configuration

Register a confidential client in the Globus Developer Console. Set the redirect URL above as an allowed redirect URI and request the openid email profile scopes.

{
  "providerId": "globus",
  "displayName": "Globus",
  "discoveryUrl": "https://auth.globus.org/.well-known/openid-configuration",
  "clientId": "YOUR_GLOBUS_CLIENT_ID",
  "clientSecret": "YOUR_GLOBUS_CLIENT_SECRET",
  "scopes": ["openid", "email", "profile"]
}

CILogon

Discovery URL: https://cilogon.org/.well-known/openid-configuration

Register a client at cilogon.org/oauth2/register. Set the callback URL and request the openid email profile scopes.

{
  "providerId": "cilogon",
  "displayName": "CILogon",
  "discoveryUrl": "https://cilogon.org/.well-known/openid-configuration",
  "clientId": "cilogon:/client_id/YOUR_ID",
  "clientSecret": "YOUR_CLIENT_SECRET",
  "scopes": ["openid", "email", "profile"]
}

Multiple providers

Pass multiple objects in the array to show a sign-in button for each:

[
  {
    "providerId": "globus",
    "displayName": "Globus",
    "discoveryUrl": "https://auth.globus.org/.well-known/openid-configuration",
    "clientId": "...",
    "clientSecret": "...",
    "scopes": ["openid", "email", "profile"]
  },
  {
    "providerId": "cilogon",
    "displayName": "CILogon",
    "discoveryUrl": "https://cilogon.org/.well-known/openid-configuration",
    "clientId": "...",
    "clientSecret": "...",
    "scopes": ["openid", "email", "profile"]
  }
]

Mapping user fields from non-standard claims

Institutional IdPs routinely release an email that is not the identifier the institution keys accounts on. Globus is a clear case: for a university identity it can send email: "Ada.Lovelace@example.edu" (a display alias) while preferred_username carries the canonical alovelace@example.edu. Without a mapping, obleth keys the account on the alias, so the same person arrives as a second, unrecognised user.

{
  "providerId": "globus",
  "displayName": "Globus",
  "discoveryUrl": "https://auth.globus.org/.well-known/openid-configuration",
  "clientId": "YOUR_CLIENT_ID",
  "clientSecret": "YOUR_CLIENT_SECRET",
  "scopes": ["openid", "email", "profile"],
  "claims": { "email": "preferred_username" },
  "overrideUserInfo": true,
  "pkce": true
}

Only email, name, and image are mappable — they are the only profile fields better-auth writes onto a user. A missing, empty, or non-string mapped claim falls back to the standard claim, so a mapping can never blank out a field the IdP did supply.

Note:

Changing claims.email changes the identity users are keyed on. Existing accounts are not merged: a returning user is found by the provider's sub, so they keep their original row and — unless overrideUserInfo is set — their original email. Set overrideUserInfo: true when correcting a mapping after users already exist.

Token-endpoint authentication

Omit authentication for the default, which sends client_id/client_secret in the request body (client_secret_post). Set "basic" only for a provider that genuinely refuses body credentials — most accept both regardless of what their discovery document advertises. Globus, for example, advertises only client_secret_basic and still authenticates body credentials.

The symptom that points here is distinctive: the IdP login succeeds, the browser returns to the dashboard, and then the callback fails — the failure is in the server-to-server code exchange, not in anything visible in the browser flow. If you see that, check the provider's token_endpoint_auth_methods_supported and set authentication to match. Only "basic" and "post" are accepted; the discovery-document spellings (client_secret_basic, client_secret_post) are rejected with an error.

PKCE

pkce: true sends a code challenge with the authorization request and the matching verifier at the token exchange, so an intercepted authorization code cannot be redeemed on its own. It is off by default; turn it on for any provider that supports it.

Reaching the dashboard from another host

Sign-in validates the browser's Origin header against BETTER_AUTH_URL, which defaults to http://localhost:3000 (the Docker stack sets http://localhost:3002). Browsing to the dashboard by LAN IP or an alternate hostname therefore fails with an invalid-origin error until you say the other origin is expected. This affects the local admin login as much as SSO.

Either point BETTER_AUTH_URL at the exact URL you browse to:

BETTER_AUTH_URL=http://192.168.1.50:3002

or list extra origins in TRUSTED_ORIGINS — a comma-separated list of origins (scheme, host, and port), trusted in addition to BETTER_AUTH_URL:

TRUSTED_ORIGINS=http://192.168.1.50:3002,http://obleth.lan:3002

A single * trusts every origin. That is convenient on a trusted private network and unsafe on anything internet-reachable; prefer an explicit list.

BETTER_AUTH_URL still determines the host in OIDC redirect URIs, so it must match what you registered with the IdP regardless of what TRUSTED_ORIGINS allows. In Docker Compose both are set in deploy/docker/.env; under Helm they are controlPlane.betterAuthUrl and controlPlane.trustedOrigins.

Break-glass local admin

A local email/password admin account is seeded on first boot and always works regardless of IdP availability:

VariableDefaultNotes
DASHBOARD_ADMIN_EMAILadmin@example.comEmail used to sign in
DASHBOARD_PASSWORDMust be at least 8 characters; no default in production

Set both in your deployment's environment (or .env for Docker Compose). The local admin has the admin role and is not subject to the pending-approval flow.

Change DASHBOARD_ADMIN_EMAIL and DASHBOARD_PASSWORD from any dev defaults before exposing the dashboard to a network.

Upgrading from the username login

The dashboard sign-in form now requires an email address, not a username. Two requirements must be met before the break-glass admin can be seeded on the upgraded control plane:

  1. DASHBOARD_ADMIN_EMAIL must be a real email address. A bare value like admin fails validation and no admin account will be created.
  2. DASHBOARD_PASSWORD must be at least 8 characters. A shorter password causes the admin seed to fail; the error is logged and no account is created.

Upgrade steps for an existing install:

  1. Set DASHBOARD_ADMIN_EMAIL to a real email address (e.g. admin@your-org.example).
  2. Ensure DASHBOARD_PASSWORD is 8 or more characters.
  3. Start the upgraded control plane — the admin account is created from those values on first boot (when no admin exists yet).
  4. Sign in at the dashboard with that email and password.

Institutional users who sign in via OIDC SSO are unaffected by this change; they sign in through their provider as before and land in the pending state until an admin assigns them a role and tenant (see Admin approval flow below).

Admin approval flow

When a user signs in via SSO for the first time, their account is created with a pending status. Pending users cannot access the dashboard or the self-service portal.

To grant access:

  1. Sign in as an admin.
  2. Navigate to the Users screen.
  3. Find the pending user and assign them a role (admin or user) and a tenant.
  4. Save — the user can now sign in normally.

Users assigned the user role land in the self-service portal rather than the full dashboard. They can view available models, manage their own API keys, and review their own usage. They cannot see other tenants' data or modify gateway configuration.

Try it locally with Dex

The Docker Compose stack ships a Dex container that acts as a local test identity provider, gated behind the sso-dev profile so it never starts in production. It comes preloaded with one test user:

  • researcher@example.edu / password

Run the whole stack in Docker (default)

The Dex issuer must resolve to the same address from both your browser and the control-plane container. localhost cannot do that — inside the container it points at the container itself, not Dex — so the shipped config uses host.docker.internal, which the compose file gives Dex as a network alias.

Set the profile and provider in deploy/docker/.env (this file is read only by the Docker stack):

COMPOSE_PROFILES=sso-dev
OIDC_PROVIDERS=[{"providerId":"dex","displayName":"Dev SSO (Dex)","discoveryUrl":"http://host.docker.internal:5556/dex/.well-known/openid-configuration","clientId":"obleth-control-plane","clientSecret":"dev-dex-secret","scopes":["openid","email","profile"]}]
cd deploy/docker
docker compose up -d

Open the dashboard at http://localhost:3002 — you'll see a Sign in with Dev SSO (Dex) button. Sign in as the test user above; you'll land on an "awaiting approval" screen (expected — see Admin approval flow). That screen has a Sign out button so you can switch between the break-glass admin and SSO accounts while testing.

Note:

If you change the dashboard's published port from 3002, also update Dex's redirectURIs in deploy/docker/dex/config.docker.yaml and BETTER_AUTH_URL to match.

Run the control plane on the host instead

If you run the control plane on the host with npm run dev (port 3000) while only Dex runs in Docker, both the browser and the Node server reach Dex at plain localhost:5556. Put the config in control-plane/.env.local (the Docker .env is not read in this mode):

# control-plane/.env.local
BETTER_AUTH_URL=http://localhost:3000
OIDC_PROVIDERS=[{"providerId":"dex","displayName":"Dev SSO (Dex)","discoveryUrl":"http://localhost:5556/dex/.well-known/openid-configuration","clientId":"obleth-control-plane","clientSecret":"dev-dex-secret","scopes":["openid","email","profile"]}]
cd deploy/docker && docker compose --profile sso-dev up -d dex postgres
cd ../../control-plane && npm run dev   # dashboard on :3000

Add more local test users

Dex's users live in deploy/docker/dex/config.docker.yaml under staticPasswords. Add an entry (the password is a bcrypt hash) and restart Dex:

node -e "console.log(require('bcryptjs').hashSync('yourpassword',10))"
docker compose -f deploy/docker/docker-compose.yml restart dex

Dex storage is in-memory, so the static list is the source of truth and resets when the container restarts. In production, users live in your real provider (Globus/CILogon) — obleth never creates SSO identities itself, it only records an account the first time someone signs in.

Kubernetes (Helm)

The Helm chart exposes the same settings under controlPlane:

controlPlane:
  # Break-glass admin (always works, even when the IdP is down)
  dashboardAdminEmail: "admin@your-org.example"
  dashboardPassword: ""          # >= 8 chars; supply via --set or an external Secret
  dashboardSessionSecret: ""     # >= 32 chars; reused as BETTER_AUTH_SECRET

  # Extra login origins beyond betterAuthUrl (comma-separated; "*" = all)
  trustedOrigins: ""             # e.g. https://dash.internal,http://10.0.0.5:3002

  # OIDC SSO (optional; omit or leave empty for break-glass only)
  betterAuthUrl: "https://dashboard.example.com"
  oidcProviders: |
    [{"providerId":"globus","displayName":"Globus","discoveryUrl":"https://auth.globus.org/.well-known/openid-configuration","clientId":"ID","clientSecret":"SECRET","scopes":["openid","email","profile"]}]

For production, store these in a pre-created Secret and reference it with controlPlane.existingSecret. Because the chart then renders no control-plane Secret of its own, that Secret must carry every key: DASHBOARD_PASSWORD, DASHBOARD_SESSION_SECRET, DATABASE_URL, DASHBOARD_ADMIN_EMAIL, BETTER_AUTH_URL, TRUSTED_ORIGINS, and OIDC_PROVIDERS. See deploy/k8s/obleth/examples/values-production.yaml and the Helm values reference.

Promoting to production (no code change required)

Switching from local Dex to a live Globus or CILogon deployment requires only an environment variable swap — no code change:

  1. Register obleth as a client with your IdP (see above for the redirect URI).
  2. Update OIDC_PROVIDERS to the real discovery URL and client credentials.
  3. Restart the control plane.

Everything else — the approval flow, roles, the break-glass admin — stays the same.