osctrl-api supports three authentication methods simultaneously: local password, OIDC, and SAML 2.0. This guide covers IdP-side configuration for the two federated protocols and documents the non-obvious gotchas for each tested provider.
- Configuration modes
- Database-backed provider editor
- Environment variables reference
- Username rules
- Linking existing local accounts
- Multi-factor authentication
- OIDC
- SAML 2.0
- Logout and IdP session termination
- Running OIDC and SAML simultaneously
- Troubleshooting
osctrl-api resolves startup settings from either flags/environment
variables or a YAML file — not both. When --config is passed, the YAML
file is the only service-configuration source; environment variables are not
merged into it.
So pick the one that matches how you run the service:
- Provisioned / systemd deployments — edit the
saml:andoidc:sections of the deployed API YAML file. Native packages install it at/opt/osctrl/config/api.yml; source provisioning commonly usesconfig/osctrl-api.yml. Runosctrl-api config-generateto emit a fresh file with both sections present, or copy them fromdeploy/config/api.yml. - Containers or a hand-rolled invocation with no
--config— use the environment variables below.
The YAML keys are the camelCase equivalents of the flags
(OIDC_ISSUER_URL → oidc.issuerUrl, SAML_ACS_URL → saml.acsUrl,
and so on). A complete annotated example of both sections lives in
deploy/config/api.yml.
At startup, enabled YAML or flag-based OIDC and SAML definitions are also
seeded into the auth_providers table with create-if-missing semantics.
The API builds its provider registry from those rows, but the public login
routes described in this guide remain gated by the resolved oidc.enabled
and saml.enabled service settings.
The SPA exposes an administrator-only provider editor when
service.authProvidersEnabled / AUTH_PROVIDERS_ENABLED is true (the
default). It can create, validate, redact, reveal, update, revert, and delete
OIDC or SAML rows independently of the general service-configuration UI.
The current runtime integration is incomplete:
- Provider discovery advertises database rows with ID-scoped login URLs, but
osctrl-apidoes not currently register those ID-scoped public login, callback, ACS, or metadata routes. POST /api/v1/auth-providers/applyqueuesreload-auth-providersforosctrl-tls; TLS does not consume that action, and the API has no service-command consumer.
Consequently, use the YAML/flag configuration documented below for working federated login. Database edits persist, but an API restart alone does not make new ID-scoped providers usable until the public route wiring is completed. This limitation is also recorded in ARCHITECTURE.md.
| Variable | Required | Description |
|---|---|---|
OIDC_ENABLED |
yes | Set true to enable the OIDC login surface |
OIDC_ISSUER_URL |
yes | Issuer URL (realm root); /.well-known/openid-configuration is appended automatically |
OIDC_CLIENT_ID |
yes | Client ID registered with the IdP |
OIDC_CLIENT_SECRET |
unless PKCE | Client secret; may be empty only when OIDC_USE_PKCE=true |
OIDC_REDIRECT_URL |
yes | Must match the IdP's allowed callback and end with /api/v1/auth/oidc/callback |
OIDC_SCOPES |
no | Comma-separated list (default: openid,profile,email) |
OIDC_USERNAME_CLAIM |
no | id_token claim to use as the osctrl username (default: preferred_username; see Username rules) |
OIDC_GROUPS_CLAIM |
no | id_token claim containing group memberships (default: groups) |
OIDC_REQUIRED_GROUPS |
no | Comma-separated group names; login is denied unless the user belongs to at least one |
OIDC_JIT_PROVISION |
no | Set true to auto-create osctrl users on first login (as non-admin) |
OIDC_LINK_LOCAL_ACCOUNTS |
no | Set true to let an OIDC login claim an existing local password account with the same username (see Linking existing local accounts) |
OIDC_USE_PKCE |
no | Set true to enable PKCE (S256) for the authorization code flow |
| Variable | Required | Description |
|---|---|---|
SAML_ENABLED |
yes | Set true to enable the SAML login surface |
SAML_IDP_METADATA_URL |
yes | URL to the IdP's SAML metadata XML — fetched once at startup |
SAML_ENTITY_ID |
yes | SP Entity ID — must match what the IdP has registered (typically the metadata URL) |
SAML_ACS_URL |
yes | Assertion Consumer Service URL — must end with /api/v1/auth/saml/acs |
SAML_USERNAME_ATTRIBUTE |
no | SAML attribute name whose value becomes the osctrl username; empty = use NameID |
SAML_JIT_PROVISION |
no | Set true to auto-create osctrl users on first login (as non-admin) |
SAML_LINK_LOCAL_ACCOUNTS |
no | Set true to let a SAML login claim an existing local password account with the same username (see Linking existing local accounts) |
SAML_FORCE_AUTHN |
no | Force re-authentication at the IdP on every login (default: true) |
SAML_SIGNING_CERT |
no | Path to PEM certificate for signing AuthnRequests |
SAML_SIGNING_KEY |
no | Path to PEM RSA private key for signing AuthnRequests |
SAML_LOGOUT_URL |
no | IdP session-termination URL; returned to the SPA so it can end the IdP session on logout |
A username may take either of two shapes:
- Plain handle —
^[a-zA-Z0-9_-]{1,64}$ - Email address —
local@domain.tld, up to 254 characters
Email is accepted because most IdPs identify people by mailbox. Anything
carrying a character that could survive a sanitization boundary and reach an
audit log, an API path or a query — newlines, NULs, quotes, semicolons,
slashes, spaces, angle brackets — is rejected in both shapes, as are values
that merely look like an email (al..ice@corp.com, alice@corp.com/../root).
Email usernames are stored lowercased. IdPs are not consistent about the
casing they emit for the same mailbox, and without canonicalization
Jane@corp.com and jane@corp.com would be two separate accounts on
PostgreSQL but collide on MySQL's default collation. Plain handles keep their
case, so existing mixed-case accounts continue to match on login.
| IdP claim/attribute | Typical value | Passes? |
|---|---|---|
preferred_username |
alice |
yes |
nickname |
alice |
yes |
email |
alice@example.com |
yes — stored as alice@example.com |
| NameID (email format) | Alice@Example.com |
yes — stored as alice@example.com |
sub (Auth0) |
auth0|6a0a... |
no — contains | |
sub (Keycloak) |
a1b2c3d4-... |
yes — a 36-char UUID fits the plain shape |
Using email as the username claim: osctrl only accepts the email claim
when the IdP also sets email_verified to true. An unverified address falls
back to sub, so a user who has not proven control of a mailbox cannot claim
an account belonging to whoever owns it.
Changing this setting on a live deployment creates new accounts rather
than renaming existing ones — the username is the identity. Moving from
nickname to email means alice and alice@example.com are two different
users with separate permissions. Migrate deliberately.
A federated login whose username matches an existing local password account is refused by default:
username "jane@corp.com" is a local password account; set linkLocalAccounts on the oidc provider to let federated login claim it
This is deliberate. If a same-name match were enough, anyone who can make the
IdP assert the username admin would inherit the local admin row and all its
privileges. Accounts that were already created by federated login carry an
auth source stamp and are matched unconditionally, including across protocols
(a user provisioned via OIDC can sign in via SAML from the same IdP).
To let the IdP adopt accounts you pre-created locally, opt in per provider:
oidc:
linkLocalAccounts: true
saml:
linkLocalAccounts: trueor OIDC_LINK_LOCAL_ACCOUNTS=true / SAML_LINK_LOCAL_ACCOUNTS=true.
What this delegates. With it on, whoever controls the IdP's username namespace can claim any same-named local account, admin rows included. Enable it only when you trust the IdP to be authoritative over usernames — which is usually true for a corporate IdP you administer, and usually false for one that allows self-registration or spans domains you do not control.
What happens on the first such login. The account is stamped with the protocol that claimed it, the event is written to the audit log and logged at WARN, and the stored password is left alone. Linking never grants privileges: a non-admin stays a non-admin, and environment permissions are unchanged.
Because the row is stamped on first use, the flag is only needed for that initial login — you can turn it back off afterwards and already-linked accounts keep working.
Password (db) logins can require a second factor. This applies only to
local password accounts — federated logins are the identity provider's
responsibility, and service accounts authenticate with a long-lived token
rather than interactively, so both are exempt.
Three factor types are supported:
| Factor | What it covers |
|---|---|
| Authenticator app (TOTP) | Google Authenticator, Authy, 1Password, Aegis — RFC 6238, 6 digits, 30s |
| Passkeys and security keys (WebAuthn) | YubiKey and other roaming keys, Touch ID / Windows Hello, password-manager passkeys |
| Recovery codes | Ten single-use codes, issued when the first factor is enrolled |
The password step no longer creates a session on its own. When the user has
a factor, POST /api/v1/login answers with a challenge instead of a token:
{"mfa_required": true, "challenge": "…", "methods": ["totp", "webauthn", "recovery"]}The client then answers it with one of:
POST /api/v1/login/mfa—{"challenge": "…", "method": "totp"|"recovery", "code": "…"}POST /api/v1/login/mfa/webauthn/beginthen…/finish— the WebAuthn assertion ceremony
Only that second request returns the JWT and sets the session cookies. Challenges are single-use and expire after five minutes, and TOTP time steps are burned on use, so a code cannot be replayed inside its window. Both steps sit behind the same per-IP login rate limit.
Users enroll from their profile page: Two-factor authentication → Set up for an authenticator app, or Register for a passkey or security key. Removing a factor, or regenerating recovery codes, requires re-entering the account password so a hijacked session cannot strip protection off an account.
Recovery codes are shown once, at enrollment. Regenerating them invalidates the previous set.
| Setting | Flag | Environment variable |
|---|---|---|
service.mfaRequired |
--mfa-required |
SERVICE_MFA_REQUIRED |
service.mfaIssuer |
--mfa-issuer |
SERVICE_MFA_ISSUER |
service.mfaRPID |
--mfa-rpid |
SERVICE_MFA_RPID |
service.mfaOrigins |
--mfa-origins |
SERVICE_MFA_ORIGINS |
With mfaRequired on, a user who has no factor is not locked out: the
login returns an enrollment challenge, they scan a QR code and confirm a
code, and the session is issued together with their recovery codes. The
last remaining factor on an account cannot be removed while this is on.
Passkeys and security keys need a Relying Party ID and the exact origins
the SPA is served from. mfaRPID defaults to service.host and
mfaOrigins to https://<rp id>, which is right for a deployment served
at its own hostname over TLS. Set them explicitly when the SPA is served
on a non-default port or under a different hostname:
service:
host: osctrl.example.com
mfaRequired: true
mfaRPID: osctrl.example.com
mfaOrigins: "https://osctrl.example.com,https://osctrl.example.com:8443"If neither can be resolved, WebAuthn stays off — TOTP and recovery codes
keep working and the SPA hides passkey registration. Browsers also refuse
WebAuthn on plain HTTP other than localhost, so a non-TLS deployment is
TOTP-only in practice.
Changing mfaRPID invalidates every registered credential: the keys are
bound to the domain they were created for. Users have to register again.
There is no self-service reset. An administrator with database access clears the affected user's rows:
DELETE FROM user_mfa_totp WHERE username = 'someone';
DELETE FROM user_mfa_credentials WHERE username = 'someone';
DELETE FROM user_mfa_recovery_codes WHERE username = 'someone';The next login then goes through enrollment again (or straight through,
if mfaRequired is off).
- Register a Regular Web Application (or "Confidential Client") in your IdP.
- Set the grant type to
authorization_code. - Add the callback URL:
https://<your-osctrl-host>/api/v1/auth/oidc/callback. - Add the allowed logout URL:
https://<your-osctrl-host>/login. - Ensure the id_token includes the claim you configure as
OIDC_USERNAME_CLAIM. - If using group-based access control, ensure the id_token includes a
groupsclaim (or whatever you setOIDC_GROUPS_CLAIMto).
Keycloak works with default settings after creating a client. Key points:
Client configuration:
- Client type: OpenID Connect
- Client authentication: ON (confidential)
- Valid redirect URIs:
https://<host>/api/v1/auth/oidc/callback - Valid post logout redirect URIs:
https://<host>/login
Username claim: Keycloak populates preferred_username by default,
which is osctrl's default OIDC_USERNAME_CLAIM. No extra configuration
needed.
Groups claim: Add a "Group Membership" mapper to the client:
- Mapper type: Group Membership
- Token claim name:
groups - Full group path: OFF (otherwise you get
/group-nameinstead ofgroup-name)
osctrl environment variables:
OIDC_ENABLED=true
OIDC_ISSUER_URL=https://keycloak.example.com/realms/your-realm
OIDC_CLIENT_ID=<client-id>
OIDC_CLIENT_SECRET=<client-secret>
OIDC_REDIRECT_URL=https://<osctrl-host>/api/v1/auth/oidc/callback
OIDC_JIT_PROVISION=true
OIDC_USE_PKCE=true
Auth0 requires two non-default changes that will cause silent failures if missed.
1. Switch id_token signing to RS256 (critical)
Auth0 defaults new "Regular Web Application" clients to HS256 (symmetric
signing). osctrl's OIDC library (go-oidc) validates tokens using the IdP's
JWKS (public keys) and rejects HS256 tokens.
Symptom if missed: OIDC callback silently fails; the API log shows
oidc: id_token verification failed.
Fix: Applications > your app > Settings > Advanced Settings > OAuth tab > JsonWebToken Signature Algorithm > select RS256 > Save.
2. Set OIDC_USERNAME_CLAIM=nickname
Auth0's default sub claim looks like auth0|6a0a4280... which contains
| and fails osctrl's username validation. Auth0 populates the nickname
claim by default from the user's username (the part before @).
Symptom if missed: OIDC login succeeds at Auth0 but the user sees a
redirect back to / with no session. The API log shows
oidc: username failed character validation.
3. Groups claim requires an Auth0 Action
Auth0 does not include group/role information in id_tokens by default. If you want group-based access control, create a post-login Action:
Actions > Flows > Login > Add Action > Build from Scratch:
exports.onExecutePostLogin = async (event, api) => {
const groups = (event.authorization?.roles) || [];
api.idToken.setCustomClaim('groups', groups);
};Deploy the Action and add it to the Login flow.
4. Allowed callback and logout URLs
Applications > your app > Settings:
- Allowed Callback URLs:
https://<osctrl-host>/api/v1/auth/oidc/callback - Allowed Logout URLs:
https://<osctrl-host>/login
osctrl environment variables:
OIDC_ENABLED=true
OIDC_ISSUER_URL=https://<tenant>.auth0.com/
OIDC_CLIENT_ID=<client-id>
OIDC_CLIENT_SECRET=<client-secret>
OIDC_REDIRECT_URL=https://<osctrl-host>/api/v1/auth/oidc/callback
OIDC_USERNAME_CLAIM=nickname
OIDC_JIT_PROVISION=true
OIDC_USE_PKCE=true
Username claim: Okta commonly populates preferred_username with the
user's email address. Valid email usernames are supported and stored
lowercased. Use a short identifier claim only when that better matches your
existing osctrl account names.
Logout requirement: Okta REQUIRES id_token_hint when chaining a
post_logout_redirect_uri. osctrl handles this automatically — the
logout endpoint returns the id_token_hint from the session and the
SPA includes it in the IdP logout URL.
Username claim: Entra ID commonly uses an email-shaped
preferred_username or upn. Valid email usernames are
supported and stored lowercased. Select a custom claim only when you need to
match pre-existing short account names.
Groups claim: Entra ID can emit groups as object IDs or display names. Configure: Enterprise Applications > your app > Token Configuration > Add groups claim > select "Security groups" > emit as "sAMAccountName" (or display name) rather than object IDs.
- Register osctrl as a Service Provider (SP) in your IdP.
- Point the IdP at the SP metadata URL:
https://<osctrl-host>/api/v1/auth/saml/metadata(or download the XML from that URL and upload it to the IdP). - Configure the IdP to include a username attribute in the assertion.
- Set
SAML_USERNAME_ATTRIBUTEto the exact attribute name the IdP sends.
SP signing (recommended): Generate a certificate/key pair and
configure SAML_SIGNING_CERT and SAML_SIGNING_KEY. This causes osctrl
to sign every AuthnRequest, which some IdPs require and all should
support.
openssl req -x509 -newkey rsa:2048 -keyout saml-sp.key -out saml-sp.crt \
-days 3650 -nodes -subj "/CN=osctrl-saml-sp"Client configuration:
- Client type: SAML
- Client ID: the
SAML_ENTITY_IDvalue (typically the metadata URL) - Root URL:
https://<osctrl-host> - Valid redirect URIs:
https://<osctrl-host>/api/v1/auth/saml/acs - Master SAML Processing URL:
https://<osctrl-host>/api/v1/auth/saml/acs
Username attribute: Keycloak sends preferred_username in a standard
SAML attribute by default. Set:
SAML_USERNAME_ATTRIBUTE=preferred_username
Or add a "User Attribute" mapper to send a custom attribute.
SP signing: If providing a signing cert, upload saml-sp.crt to the
client's Keys tab > Client Signature Required: ON > import the cert.
osctrl environment variables:
SAML_ENABLED=true
SAML_IDP_METADATA_URL=https://keycloak.example.com/realms/your-realm/protocol/saml/descriptor
SAML_ENTITY_ID=https://<osctrl-host>/api/v1/auth/saml/metadata
SAML_ACS_URL=https://<osctrl-host>/api/v1/auth/saml/acs
SAML_USERNAME_ATTRIBUTE=preferred_username
SAML_JIT_PROVISION=true
SAML_SIGNING_CERT=/path/to/saml-sp.crt
SAML_SIGNING_KEY=/path/to/saml-sp.key
Auth0 SAML has two significant gotchas compared to Keycloak.
1. Attribute namespace differs from the standard
Auth0 publishes SAML attributes under http://schemas.auth0.com/ instead
of the standard http://schemas.xmlsoap.org/ws/2005/05/identity/claims/
namespace. You must use the Auth0 URI as the attribute name.
Symptom if missed: SAML login completes at Auth0 but the user sees a
redirect back to / with no session. The API log shows
saml: username failed character validation or the NameID is an email
address that fails the username regex.
Common Auth0 SAML attributes:
| Auth0 attribute | Value |
|---|---|
http://schemas.auth0.com/nickname |
alice |
http://schemas.auth0.com/email |
alice@example.com |
http://schemas.auth0.com/name |
Alice Smith |
http://schemas.auth0.com/identities/default/connection |
Username-Password-Authentication |
Recommended setting:
SAML_USERNAME_ATTRIBUTE=http://schemas.auth0.com/nickname
2. Enable the SAML2 Web App addon
Applications > your app > Addons > SAML2 Web App > toggle ON.
Configure:
- Application Callback URL:
https://<osctrl-host>/api/v1/auth/saml/acs - Settings (JSON): leave defaults unless you need to customize attribute mappings
The metadata URL is:
https://<tenant>.auth0.com/samlp/metadata/<client-id>
3. Logout URL
Auth0's generic /v2/logout endpoint terminates the IdP session regardless
of which protocol (OIDC or SAML) created it. Set SAML_LOGOUT_URL so
the SPA can navigate there on logout:
SAML_LOGOUT_URL=https://<tenant>.auth0.com/v2/logout
Also add the osctrl login page to Auth0's allowed logout URLs:
Applications > your app > Settings > Allowed Logout URLs:
https://<osctrl-host>/login
osctrl environment variables:
SAML_ENABLED=true
SAML_IDP_METADATA_URL=https://<tenant>.auth0.com/samlp/metadata/<client-id>
SAML_ENTITY_ID=https://<osctrl-host>/api/v1/auth/saml/metadata
SAML_ACS_URL=https://<osctrl-host>/api/v1/auth/saml/acs
SAML_USERNAME_ATTRIBUTE=http://schemas.auth0.com/nickname
SAML_JIT_PROVISION=true
SAML_LOGOUT_URL=https://<tenant>.auth0.com/v2/logout
SAML_SIGNING_CERT=/path/to/saml-sp.crt
SAML_SIGNING_KEY=/path/to/saml-sp.key
osctrl implements a two-step logout:
- Server-side:
POST /api/v1/logoutclears the session cookies and revokes the JWT in the database. - IdP-side: The SPA navigates to the IdP's logout endpoint to terminate the IdP session. Without this, the next SSO login silently re-authenticates against the still-valid IdP session cookie.
OIDC logout uses the standard RP-Initiated Logout flow
(end_session_endpoint from the IdP's discovery document). osctrl
discovers this URL automatically. The SPA passes post_logout_redirect_uri,
id_token_hint, and client_id as query parameters.
SAML logout does not use SAML SLO (Single Logout) in v1. Instead,
when SAML_LOGOUT_URL is configured, the SPA navigates to the IdP's
generic session termination endpoint (e.g. Auth0's /v2/logout) with
returnTo and client_id parameters. This terminates the IdP session
the same way OIDC logout does.
If SAML_LOGOUT_URL is not set, SAML users are logged out of osctrl
only. The IdP session remains active, which means the next SSO login
will silently re-authenticate. To mitigate this without setting a logout
URL, set SAML_FORCE_AUTHN=true (the default) — this forces the IdP
to prompt for credentials on every login even when an IdP session exists.
osctrl supports enabling both OIDC and SAML at the same time. The login page shows separate buttons for each: "Continue with SSO (OIDC)" and "Continue with SSO (SAML)". Both can point to the same IdP (e.g. Auth0 or Keycloak) or to different IdPs.
When both are enabled against the same IdP, use the same OIDC_CLIENT_ID
for both protocols. This ensures the client_id parameter on logout
URLs works correctly for both flows.
Users who were originally provisioned via OIDC can later log in via SAML (or vice versa) as long as the resolved username matches. The session's authentication method is tracked per-login, not per-user — logout terminates the correct IdP session regardless of which method was used to create the osctrl user account.
Check the osctrl-api logs for one of:
oidc: id_token verification failed— the id_token signing algorithm is likely HS256; switch to RS256 in the IdP.oidc: username failed character validation— the configured claim is neither a valid short handle nor a valid email address. Select a supported claim such aspreferred_username, verifiedemail, ornickname.oidc: state mismatch— the state cookie expired (10-minute TTL) or the callback URL doesn't matchOIDC_REDIRECT_URL.
Check the osctrl-api logs for one of:
saml: assertion validation failed— signature verification, audience, or time window check failed. Verify that the IdP metadata URL is correct and that theSAML_ENTITY_IDmatches the IdP's expected audience.saml: username failed character validation— the username attribute is neither a valid short handle nor a valid email address. Make sureSAML_USERNAME_ATTRIBUTEpoints to an appropriate identity value (see Username rules).saml: state cookie missing or invalid— the state cookie expired or the ACS URL doesn't matchSAML_ACS_URL.
- OIDC: Verify the IdP's discovery document includes
end_session_endpoint. Check thathttps://<osctrl-host>/loginis in the IdP's allowed logout/redirect URLs. - SAML: Set
SAML_LOGOUT_URLto the IdP's session termination endpoint and addhttps://<osctrl-host>/loginto the IdP's allowed logout URLs.
JIT provisioning is disabled by default. Set OIDC_JIT_PROVISION=true
and/or SAML_JIT_PROVISION=true to auto-create users on first login.
JIT-provisioned users are created as non-admin; an existing admin must
grant elevated permissions.
If OIDC_REQUIRED_GROUPS is set, the user must belong to at least one of
the listed groups. Verify:
- The IdP includes the groups claim/attribute in the token/assertion.
- The group name matches exactly (case-sensitive).
- For Auth0: a post-login Action is required to inject the
groupsclaim (see Auth0 OIDC). - For Keycloak: a "Group Membership" mapper is configured on the client with "Full group path" OFF.