67 docs indexed
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.
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:
| Role | Access |
|---|---|
admin | Full dashboard: models, tenants, keys, usage, settings |
user | Self-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.
SSO is configured through environment variables on the control plane. Where you set them depends on how you run it:
deploy/docker/.env (read only by the Docker stack).npm run dev): control-plane/.env.local.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"]
}
]
| Field | Description |
|---|---|
providerId | Short identifier used in the callback URL (alphanumeric + hyphens) |
displayName | Label shown on the sign-in button |
discoveryUrl | OIDC discovery document URL for the provider |
clientId | Client ID from the IdP registration |
clientSecret | Client secret from the IdP registration |
scopes | OAuth scopes to request; openid email profile is sufficient for obleth |
Four optional fields cover providers that need more than the defaults:
| Field | Default | Description |
|---|---|---|
authentication | body credentials | Token-endpoint client authentication: "basic" (HTTP Basic) or "post" (credentials in the request body) |
claims | standard claims | Map email, name, or image to a non-standard claim name |
overrideUserInfo | false | Re-apply the profile (and the claim mapping) to existing users on every sign-in, not only at sign-up |
pkce | false | Send 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.
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.
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"]
}
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"]
}
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"]
}
]
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.
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: 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.
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.
A local email/password admin account is seeded on first boot and always works regardless of IdP availability:
| Variable | Default | Notes |
|---|---|---|
DASHBOARD_ADMIN_EMAIL | admin@example.com | Email used to sign in |
DASHBOARD_PASSWORD | — | Must 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.
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:
DASHBOARD_ADMIN_EMAIL must be a real email address. A bare value like admin fails validation and no admin account will be created.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:
DASHBOARD_ADMIN_EMAIL to a real email address (e.g. admin@your-org.example).DASHBOARD_PASSWORD is 8 or more characters.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).
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:
admin or user) and a tenant.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.
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 / passwordThe 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.
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
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.
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.
Switching from local Dex to a live Globus or CILogon deployment requires only an environment variable swap — no code change:
OIDC_PROVIDERS to the real discovery URL and client credentials.Everything else — the approval flow, roles, the break-glass admin — stays the same.