Skip to content
Rather not run it yourself? OpenRMM Cloud hosts it for you.

Single sign-on (OpenID Connect)

The console has only ever known local users: a row in users with a bcrypt hash. That is fine for one operator and wrong for everyone else. An MSP already has an identity provider, and a second directory of technicians is a second place to forget to remove someone who left.

This adds OpenID Connect as a way to authenticate, and deliberately not as a way to authorise.

question answered by
who is this? the identity provider
what may they do? this instance — role and the access scope, exactly as before

Keeping the second answer local is the whole design. An identity provider that could also assign roles would be a provider able to make itself an admin of every instance it signs into. It cannot. An account arrives with the role an operator gave it, and a provider that claims otherwise is ignored — no claim is read for permissions, ever.

This also keeps account permissions under the control of the OpenRMM operator even when the identity provider is managed by another organization.

Terminal window
RMM_OIDC_ENABLED=true
RMM_OIDC_ISSUER=https://your-idp.example.com # exactly as the provider publishes it
RMM_OIDC_CLIENT_ID=openrmm-console
RMM_OIDC_CLIENT_SECRET=… # or RMM_OIDC_CLIENT_SECRET_FILE

Everything else has a working default:

variable default what it does
RMM_OIDC_SCOPES openid,profile,email openid is added back if you drop it — without it there is no ID token to verify
RMM_OIDC_REDIRECT_URL <RMM_PUBLIC_URL>/api/auth/oidc/callback must match what you registered with the provider
RMM_OIDC_USERNAME_CLAIM email which claim becomes the console username
RMM_OIDC_AUTO_PROVISION false create an account on first successful sign-in
RMM_OIDC_DEFAULT_ROLE readonly the role such an account is created with
RMM_OIDC_BUTTON_LABEL Single sign-on so the button can read “Sign in with Okta”
RMM_OIDC_ACR_VALUES empty comma-separated acceptable authentication contexts; sent as acr_values and checked against the signed acr claim (any match)
RMM_OIDC_REQUIRED_AMR empty comma-separated authentication methods; every entry must occur in the signed amr array

Register https://<your console>/api/auth/oidc/callback as a redirect URI at the provider, as a confidential client using the authorization code flow with PKCE.

The issuer must be https://. Discovery, the token exchange and the JWKS fetch all go over it; plain HTTP would put the client secret and the authorization code on the wire, so the server refuses to start rather than doing it.

Register a confidential client with your OIDC provider for each OpenRMM instance. Configure its exact callback URL and separate credentials. Control which users the provider allows to authorize the client; authorization at the provider does not grant an OpenRMM role.

For an authenticator-backed sign-in:

Terminal window
RMM_OIDC_ACR_VALUES=urn:example:acr:mfa
RMM_OIDC_REQUIRED_AMR=otp,mfa

Some providers report pwd or email for the initial proof and otp,mfa after an authenticator challenge. A recovery code may report recovery,mfa, not otp. To permit recovery-code login, require only mfa instead of otp,mfa. Merely enabling TOTP on the account does not prove MFA in an existing session.

An acr_values request alone is not enforcement: providers may return a weaker context. RMM rejects missing, malformed or insufficient claims before linking, provisioning or creating a console session. Empty settings preserve compatibility with providers that do not emit these claims. Replace the example ACR value with one your provider actually issues.

These controls apply to new SSO logins, not existing console sessions or local password login. Local TOTP and account permissions remain separate. Provider logout/suspension blocks new grants but does not revoke an already-created RMM session; revoke/disable it in the instance when immediate termination is needed.

Pass these variables into the server process/container, not only the host shell. Compose deployments need explicit environment entries or an env_file in the server service; restart the server after changing the configuration.

On a successful sign-in, in order:

  1. The identity is already linked — sign in. The link is (issuer, subject), never the email address: addresses get reassigned when people leave, and sub is the one claim OIDC guarantees is stable and unique within an issuer. Matching on email means whoever inherits alice@ inherits Alice’s console.
  2. A local account has the same username — link it, once. This is the migration path: create people the way you always have, and they start signing in through the provider without losing their role or scope. An account already linked to a different identity is never relinked; that is an account takeover, not a convenience.
  3. Nobody — create the account only if RMM_OIDC_AUTO_PROVISION=true.

Auto-provisioning is off by default and the reason is worth stating plainly: with it on, everyone your provider will authenticate has an account here, which for a Google Workspace or Entra tenant is the whole company. Leave it off unless the provider only holds people who should be in this console.

When the username is email-shaped, the first link or automatic provisioning is accepted only when the token carries the same email with email_verified=true. Identities already linked by (issuer, sub) do not depend on the provider continuing to send that optional claim.

RMM_OIDC_AUTO_PROVISION=true together with RMM_OIDC_DEFAULT_ROLE=admin is refused at startup rather than warned about.

An account created this way has no password: bcrypt cannot verify against the empty hash it is given, so local sign-in for it fails the way any wrong password does.

While SSO is on, an account linked to it has no password path at all — created by SSO or linked on its first SSO sign-in alike. Password sign-in (the console and the MCP OAuth form) is refused with sso_required, and password reset sends nothing. Otherwise a password, or a reset mail read by whoever holds the mailbox, would step around the provider’s MFA, conditional access and offboarding. Setting RMM_OIDC_ENABLED=false is the break-glass: linked accounts sign in with their passwords again.

Connecting an MCP client works through the provider too: the OAuth consent page offers the same single sign-on button, see 17-mcp-oauth.

Verification is delegated to github.com/coreos/go-oidc, which does the part that must not be improvised: fetching and caching the provider’s keys, checking the signature and algorithm, and validating issuer, audience and expiry.

On top of that, every sign-in carries three separate defences, and each one stops a different attack:

stops
state a callback that did not come from a sign-in this server started (CSRF)
nonce an ID token minted for a different sign-in being replayed at this callback
PKCE (S256) an authorization code being redeemed by anyone but the browser that asked for it, even if the code leaks through a log or a Referer

All three are stored in oidc_login_states rather than held in memory, so a sign-in survives a restart, and the row is deleted as it is read — a replayed callback finds nothing.

The state is stored as its SHA-256, like sessions and API tokens: a dump of that table must not hand anyone a usable in-flight login.

The callback sends a single-use code in the URL fragment (sso_code), valid for 60 seconds. The page strips the fragment and posts the code to /api/auth/oidc/exchange; only this response returns the normal bearer session. Legacy sso_token fragments are ignored and cannot replace a tab’s session.

The login starts by setting a host-only, HttpOnly, SameSite=Lax cookie (Secure on HTTPS). Both the provider callback and the code exchange require this browser binding. The exchange also checks the console Origin and consumes the code atomically with session creation. A copied callback or fragment cannot sign a different browser in; an invalid code preserves an existing tab session.

MCP sign-in first visits the console origin to set the same browser binding. Deploy the API and console together; migration 19 creates the handoff table. The console origin comes from RMM_OIDC_REDIRECT_URL when agent and console origins differ. The destination pathname already includes any frontend base path.

The redirect_to parameter accepts a path and nothing else. Anything absolute or protocol-relative is discarded rather than repaired: an open redirect on a login route is the classic ingredient of a phishing link that starts on the real console domain.

Failures send the browser back to the login screen with a generic message. The specific reason is logged and audited, never shown — “no account for bob@example.com” on a login page is an account enumerator.

Audit actions: user.sso.login, user.sso.linked, user.sso.provisioned, user.sso.denied.

If discovery fails at startup the server still starts, logs it, and shows no SSO button. Agents, the scheduler and local sign-in do not depend on the provider, and an RMM that will not boot because someone else’s identity service is down is an RMM that has adopted their outage.

  • Local sign-in still works for accounts not linked to SSO. Keep at least one local admin that is never used to sign in through the provider — the first SSO sign-in with a matching username links the account, and a linked account loses password sign-in while SSO is on. That admin is the way back in when the provider is unreachable or misconfigured; RMM_OIDC_ENABLED=false is the other.
  • Roles, scopes and permissions are unchanged and still local.
  • Disabling a user in the console is still enough. A disabled account is not returned by the SSO lookup, so someone removed here cannot sign back in through the provider.
  • Two-factor is the provider’s business for SSO accounts — in the console and when authorizing MCP clients alike. The console’s own TOTP still guards local sign-in. So require MFA at the provider: set RMM_OIDC_REQUIRED_AMR (for example mfa) or RMM_OIDC_ACR_VALUES, as described above. Without either the console accepts whatever the provider did, a single password included, and the server says so in its log at startup.