Single Sign-On (OIDC)

Mugnsoft supports standards-based Single Sign-On through OpenID Connect (OIDC), alongside the built-in local password and LDAP authentication. Any compliant identity provider works — Microsoft Entra ID, Okta, Auth0, Keycloak, Google Workspace — using only standard OIDC, no provider-specific APIs.

Overview

OIDC is a third, optional authentication method. It coexists with local and LDAP login — turning it on does not disable the others (unless you choose SSO-only mode). When enabled, the login page shows a Sign in with {provider} button.

Key behaviours:

  • Authorization-code flow with PKCE (S256) — the modern, secure browser flow.
  • Just-In-Time provisioning — the first time a user signs in via SSO on the control plane, a Mugnsoft account is created automatically from the ID-token claims. No pre-creation needed. (A read-only DMZ frontend does not provision — see SSO on a read-only DMZ frontend.)
  • Role mapping on every login — IdP groups are translated to a Mugnsoft group + account type and re-synced at each login, so access follows the IdP.
  • Anti-hijack guard — an SSO login can never silently take over an existing local or LDAP account with the same username.
  • SSO-only mode — optionally hide the username/password form so users authenticate exclusively through the IdP (with a built-in escape hatch).
  • Single Logout — optionally end the IdP session too when a user logs out, so the next sign-in prompts for credentials again.

See Managing SSO users for the day-to-day lifecycle (auth column, disabling vs deleting).

Architecture

sequenceDiagram participant B as Browser participant M as Mugnsoft participant I as Identity Provider B->>M: Click "Sign in with {provider}" M->>M: Generate state + nonce + PKCE verifier M->>B: 302 redirect to IdP (authorize) B->>I: Authenticate (login + consent) I->>B: 302 redirect to /auth/oidc/callback?code&state B->>M: GET /auth/oidc/callback M->>M: Validate state (cookie + store) M->>I: Exchange code (+ PKCE verifier) I->>M: ID token + access token M->>M: Verify signature, issuer, audience, expiry, nonce M->>M: Provision/update user + map groups M->>B: Set session cookie, redirect to app

The ID token is verified by the go-oidc library against the provider JWKS (signature, issuer, audience, expiration). Mugnsoft stores only its own short-lived session cookie in the browser — never the IdP access or ID tokens. The client secret is stored encrypted in the configuration KV store and is never logged or returned to the browser.

The provisioned account is a standard Mugnsoft user with auth type = oidc. OIDC users have no local password and cannot use the password form.

Configuration

Sign in as an admin and go to Settings → External services → AUTHENTICATION → SSO (OIDC).

Field Meaning
Enable (switch) Master on/off for OIDC.
SSO only (hide local login) Hides the username/password form on the login page (button-only). See SSO-only mode.
Single Logout (switch, default on) End the IdP session on logout via RP-initiated logout. Off = local-only logout (the IdP session stays, so re-login can be silent). See Logout.
Provider name Label shown on the login button (e.g. Okta).
Issuer URL The IdP issuer, e.g. https://login.microsoftonline.com/<tenant>/v2.0. Mugnsoft appends /.well-known/openid-configuration for discovery.
Client ID The application/client identifier registered at the IdP.
Client Secret The confidential client secret. Stored encrypted; shown as •••••• on re-open — leave blank to keep the stored value.
Redirect URL Must exactly match the redirect URI registered at the IdP: https://<mugnsoft-host>/auth/oidc/callback.
Post-logout redirect Where the IdP returns the browser after RP-initiated logout.
Scopes Requested scopes, default openid, profile, email.
Username claim ID-token claim used as the Mugnsoft username, default preferred_username (falls back to email, then sub). A username is only taken from the email claim when the IdP marks it verified (email_verified=true); otherwise the fallback continues to sub. Usernames are lower-cased and trimmed, so Alice and alice are the same account.
Groups claim Claim holding the user’s groups, default groups. Read from the ID token first, then the UserInfo endpoint as a fallback — so an IdP mapper that only adds groups to UserInfo still works.
Default group Mugnsoft group when no role-mapping entry matches, default viewer.
Default account type Account type when no mapping matches, default full.
Role mapping (JSON) Maps IdP group names to Mugnsoft group + accountType.

Click the test (bolt) icon next to the Issuer URL — or the bolt icon on the SSO tile — to verify discovery against the IdP. SSO settings are saved together with the rest of the Webserver settings using the main save (floppy) button at the top of the dialog; there is no separate SSO save button. Configuration takes effect immediately — no restart.

Note:

If the Webserver runs over HTTPS, the Redirect URL (and the URI registered at the IdP) must use https://. Pointing an http:// redirect at the HTTPS port causes the browser to abort the callback with an empty-response error.

Role mapping

Role mapping translates an IdP group onto Mugnsoft’s authorization model (groupadmin/user/viewer, plus an accountType, and an optional perimeter). It is re-applied at every login.


{
  "Mugnsoft_Admins":   { "group": "admin",  "accountType": "full" },
  "Mugnsoft_NetTeam":  { "group": "user",   "tags": "network,core", "apps": "vpn,dns" },
  "Mugnsoft_Viewers":  { "group": "viewer", "tags": "public" }
}

When a user belongs to several mapped groups, the highest privilege wins (admin > user > viewer). If no group matches, the Default group / Default account type apply.

Perimeter (tags / apps)

Each mapping entry may carry optional tags and apps (comma-separated) that scope what the user can see — the same perimeter fields a local user has. The effective perimeter is the union across every group the user matches, and it applies to all groups including admin (an admin can be scoped too). Both are re-synced from the mapping on every login.

A dimension you leave out is not wiped — if a mapping entry omits tags, the user keeps whatever tags were already on their record (e.g. set manually, or carried from a previous login). To grant full visibility, simply define no perimeter for that group. On a read-only DMZ frontend the perimeter is computed live from the login’s groups, falling back to the replicated record when the mapping defines none.

Note:

The target group must be one of admin, user or viewer. Saving a mapping (or a Default group) that targets any other value is rejected with an invalid group message — this prevents a typo from being written onto user records and silently breaking authorization. The accountType is free-form (default full).

Note:

Matching is case-insensitive and ignores a leading path slash, so an IdP that prefixes group paths (a claim value like /Mugnsoft_Admins) still matches a Mugnsoft_Admins mapping key. You do not need to mirror the IdP’s slash or casing. However, the name itself must still correspond: some providers send opaque object IDs instead of names (see each provider’s setup below).

SSO-only mode

With SSO only enabled, the login card hides the username/password form and shows just the SSO button — the page otherwise looks identical. To prevent lock-out, two escape hatches always restore the local form:

  • Append ?local=1 to the login URL.
  • Any failed SSO attempt returns to the login page with the local form visible.

Note:

Before turning on SSO-only mode, complete at least one successful SSO login that maps to an admin group. Otherwise you may have no admin account reachable through the IdP.

Managing SSO users

SSO accounts appear in Settings → Users alongside local and LDAP users. The user table has an Auth column showing each user’s source — SSO, LDAP or Local — so you can tell at a glance how someone signs in.

Disabling vs deleting an SSO user

Because SSO accounts are provisioned just-in-time, the two actions behave differently:

Action Effect on an SSO user
Disable (toggle the state to disabled) Blocks sign-in. The next SSO attempt is refused with ?ssoerror=userdisabled and the account stays disabled. This is the right way to lock out an SSO user while keeping SSO enabled.
Delete Removes the local record, but it is re-created automatically the next time that person signs in through the IdP (as long as they still exist and are enabled at the IdP). Deleting is therefore not a durable block.

To remove someone permanently, disable or remove them at the identity provider (or disable them in Mugnsoft). Group/account-type are re-synced from the IdP on every login, so changing a user’s group in Mugnsoft directly is overwritten at the next sign-in — change the mapping or the IdP group instead.

Note:

Sessions are token-based. Disabling or deleting a user takes effect on their next sign-in; an already-signed-in user keeps access until their current session token expires — up to 15 minutes idle, or up to 12 hours if the browser stays open and keeps the session refreshed. This applies to all user types, not just SSO.

SSO on a read-only DMZ frontend

A Mugnsoft frontend deployed in a DMZ is a read-only replica fed by one-way replication from the control plane. SSO sign-in works there, but just-in-time provisioning is disabled: the frontend never creates or modifies user records. It only admits SSO users that already exist in the replicated user store (and re-applies their role from the current login’s groups).

The practical consequence: a brand-new SSO user must sign in on the control plane first (which provisions them and lets replication carry the record to the DMZ). Signing in on the DMZ before the account has been replicated is refused with ?ssoerror=provision.

The Front authenticates as a PKCE public client (no secret on the DMZ)

You do not configure OIDC on the frontend. The control plane keeps the confidential configuration (issuer, client ID, client secret, role mapping) and never replicates the secret — the setting bucket stays internal. Instead, when you save OIDC settings the control plane publishes a secret-free public config that replication carries to the frontend automatically.

Because a client secret must never sit on an internet-facing DMZ host, the frontend signs in as a separate OAuth public client using Authorization Code + PKCE — no secret. The PKCE code_verifier protects the exchange; the IdP needs only the public client ID and the frontend’s own redirect URI.

To enable it:

  1. At your IdP, register a second client for the frontend as a public client (PKCE required, no client secret), with redirect URI https://<front-host>/auth/oidc/callback. See the public-client note under each provider.
  2. In Mugnsoft → Settings → SSO (OIDC), set:
    • Front client ID (public) — the public client’s ID. (Leave empty only if your main client is itself public/PKCE-capable.)
    • Front redirect URLhttps://<front-host>/auth/oidc/callback.
  3. Save. The public config replicates to the frontend within the normal replication cadence (and the Sign in with SSO button appears there once it arrives).

Why a separate public client

Keeping the confidential secret on the internal control plane and giving the DMZ frontend its own public PKCE client follows least-privilege: if the DMZ host is compromised there is no IdP credential to steal, only a public client ID. Separate clients also have distinct redirect URIs and can be revoked independently. This is the OAuth 2.1 / RFC 8252 recommendation for exposed clients.

Provider setup

The values to register at every provider are the same:

  • Redirect URI: https://<mugnsoft-host>/auth/oidc/callback
  • Flow: Authorization Code (+ PKCE)
  • Scopes: openid profile email (plus a groups claim/scope)

Optional: a public client for the DMZ frontend

If you run a read-only DMZ frontend, register a second, public client for it (no secret) in addition to the confidential client above:

  • Client type: public — PKCE required, no client secret.
  • Redirect URI: https://<front-host>/auth/oidc/callback.
  • Same scopes and groups claim as the main client.

Provider hints: Entra ID — a registration with no client secret and a SPA/public redirect; OktaApplication type → Single-Page Application (PKCE, no secret); Auth0Application type → Single-Page Application (PKCE, no secret); Keycloak — a client with Client authentication OFF (public) and Standard flow ON; Google — not applicable (Google has no group claim and is rarely DMZ-fronted). Put this client’s ID in Front client ID (public) in Mugnsoft.

Microsoft Entra ID

  1. Entra admin center → App registrations → New registration.
    • Redirect URI (Web): https://<mugnsoft-host>/auth/oidc/callback.
  2. Certificates & secrets → New client secret → copy the value.
  3. Token configuration → Add groups claim → select Security groups (or Groups assigned to the application). Add it to the ID token.
  4. Note the Application (client) ID and the Directory (tenant) ID.
  5. In Mugnsoft:
    • Issuer URL: https://login.microsoftonline.com/<tenant-id>/v2.0
    • Client ID / Secret: from steps 1–2.
    • Groups claim: groups.

Note:

Entra emits group object IDs (GUIDs) in the groups claim, not display names. Your role-mapping keys must be those GUIDs, e.g. "a1b2c3d4-...": { "group": "admin", "accountType": "full" }. To use names instead, configure group naming via the optional claims / group-name emission for synced groups.

Okta

  1. Okta admin → Applications → Create App Integration → OIDC → Web Application.
  2. Sign-in redirect URI: https://<mugnsoft-host>/auth/oidc/callback. Sign-out redirect URI: your post-logout URL.
  3. Copy Client ID and Client secret.
  4. Add a groups claim: Security → API → Authorization Servers → default → Claims → Add claim:
    • Name: groups, Include in: ID token, Value type: Groups, Filter: Matches regex .* (or a tighter filter).
  5. In Mugnsoft:
    • Issuer URL: https://<your-org>.okta.com/oauth2/default (or your custom auth-server issuer).
    • Client ID / Secret from step 3.
    • Groups claim: groups.

Auth0

  1. Auth0 Dashboard → Applications → Create Application → Regular Web Application (confidential client with a secret).
  2. Settings → Allowed Callback URLs: https://<mugnsoft-host>/auth/oidc/callback. Allowed Logout URLs: your post-logout URL.
  3. Copy the Domain, Client ID and Client Secret.
  4. Auth0 does not emit groups or roles in the ID token by default, and Auth0 drops any custom claim that is not a namespaced URI. Add a post-login Action (Actions → Library → Build Custom, add it to the Login flow) that injects the user’s roles/groups as a namespaced claim:

exports.onExecutePostLogin = async (event, api) => {
  const ns = "https://mugnsoft/";
  const roles = event.authorization?.roles || []; // requires RBAC enabled
  api.idToken.setCustomClaim(ns + "groups", roles);
};

  1. In Mugnsoft:
    • Issuer URL: https://<tenant>.<region>.auth0.com/keep the trailing slash; it must match the iss value Auth0 puts in the token.
    • Client ID / Secret from step 3.
    • Groups claim: https://mugnsoft/groups — the full namespaced name from the Action, not a bare groups.

Note:

Because Auth0 silently discards non-namespaced custom claims, a plain groups claim never reaches Mugnsoft. Set Groups claim to the exact namespaced name you used in the Action (e.g. https://mugnsoft/groups), and use event.authorization.roles (RBAC) or event.user.app_metadata as the source.

Keycloak

A full local walkthrough (Docker, realm, client, group mapper, test user) lives in the repository tutorial. In short:

  1. Create a realm; issuer becomes http://<host>:8080/realms/<realm>.
  2. Create an OpenID Connect client, Client authentication ON (confidential), Standard flow ON. Redirect URI https://<mugnsoft-host>/auth/oidc/callback.
  3. Copy the secret from the Credentials tab.
  4. Add a Group Membership mapper on the client’s dedicated scope (<client>-dedicated), not an Optional client scope. Token Claim Name must be set to groups — Keycloak emits the claim under Token Claim Name, not under the mapper Name, so leaving it blank produces no groups claim at all even with every toggle ON. Turn Add to ID token and/or Add to userinfo ON (at least one). Full group path can be on or off — Mugnsoft ignores the leading slash either way. A dedicated-scope mapper is always emitted; an Optional-scope mapper is only sent if the client requests that scope by name.
  5. In Mugnsoft set Issuer URL, Client ID = client, the secret, Groups claim groups.

Google Workspace

Google issues ID tokens with email, name and sub, but does not include group membership in the standard ID token. Basic login works out of the box; for group-based role mapping you must surface groups separately (Cloud Identity / a directory sync), otherwise all users fall back to the Default group.

  1. Google Cloud Console → APIs & Services → Credentials → Create OAuth client ID → Web application.
  2. Authorized redirect URI: https://<mugnsoft-host>/auth/oidc/callback.
  3. Use Issuer URL https://accounts.google.com, Username claim email. Google sets email_verified=true for managed Workspace accounts, which Mugnsoft requires before accepting an email as the username.

Logout

Logout always clears the Mugnsoft session. What happens to the IdP session depends on the Single Logout setting:

  • Single Logout ON (default) — when the IdP discovery document exposes an end_session_endpoint, Mugnsoft performs an RP-initiated logout: the browser is redirected through the IdP to end its session, then back to the Post-logout redirect. The next sign-in prompts for credentials again. The logout is seamless — no IdP “are you sure you want to log out?” confirmation screen.
  • Single Logout OFF — only the local Mugnsoft session is cleared. The IdP session is kept, so clicking Sign in with {provider} again can log the user straight back in without a prompt.

This works with any IdP that advertises an end_session_endpoint (Entra ID, Okta, Keycloak, Auth0). Google Workspace does not provide one, so logout always falls back to local-only regardless of the toggle.

Troubleshooting

Failed SSO attempts return to the login page with a ?ssoerror=<code> query parameter. Codes:

Code Meaning / fix
disabled OIDC not enabled, or the provider failed to initialize. Check the Issuer URL, that the IdP is reachable from the Mugnsoft host, and the server logs around initOIDCProvider.
userdisabled The IdP authenticated the user, but their Mugnsoft account is disabled. Re-enable it in Settings → Users, or remove the disable.
unreachable The IdP is not reachable from the Mugnsoft host (DNS, firewall, IdP down, or TLS failure). The Sign in with {provider} button preflights the provider and reports this instead of dropping you on the browser’s connection-error page. Check network/IdP and the server logs around oidcLogin.
idp The IdP returned an error to the callback (consent denied, misconfigured client). Check the IdP-side application logs.
state The oidc_state cookie was missing or mismatched (stale callback, cross-site cookie blocking). Start fresh from the login button.
expired The ID token was expired — usually clock skew. Ensure the host clock is correct (NTP).
exchange The authorization-code exchange failed (wrong secret, wrong redirect URI, PKCE mismatch). Verify Client Secret and that the registered redirect URI matches exactly.
token ID-token verification failed (signature, issuer, or audience mismatch). Ensure Client ID matches the token audience and the Issuer URL is exact.
nonce Nonce mismatch — a replayed or stale callback. Start fresh.
claims The ID-token claims could not be parsed.
username No usable username could be resolved. Either the configured Username claim was absent, or it resolved only to an unverified email (Mugnsoft refuses an unverified email as a username). Confirm the claim is emitted (default preferred_username) or that the IdP sends email_verified=true.
provision User provisioning was refused — typically the username collides with an existing local/LDAP account (anti-hijack guard), or, on a read-only DMZ frontend, the user has not yet been replicated (see note below). Use a distinct username.
session The application session token could not be minted. Check server logs.
internal Unexpected server error. Check server logs.

Common non-error issues:

  • Group/role not applied — the IdP isn’t emitting the groups claim at all (no group/role mapping configured, or it’s attached to a scope the client doesn’t request), the claim name doesn’t match the configured Groups claim, or the group keys in role mapping don’t match what the IdP sends (full path or opaque ID vs name). Mugnsoft reads groups from the ID token first and falls back to the UserInfo endpoint, so emitting the claim on either is enough — but if neither carries it, every user lands on the Default group. When no groups arrive, the server logs a WARN line listing the claims the IdP actually sent — oidcCallback - <user>: groups claim "groups" absent. ID-token claims=[...] UserInfo claims=[...] — so you can see whether the claim is missing entirely or simply under a different name (set Groups claim to that name). On success the log shows oidcCallback - <user> groups=[...]. See your provider’s setup section for where to add the group claim.
  • Discovery test fails — wrong issuer (trailing slash, wrong realm/tenant) or the IdP is unreachable from the server host. Open the .well-known/openid-configuration URL from the host to confirm.
  • Callback aborts with an empty response — the Redirect URL scheme doesn’t match the server. On an HTTPS server, use an https:// redirect both in Mugnsoft and at the IdP.
  • A deleted SSO user keeps coming back — that’s expected (just-in-time re-provisioning). Disable them in Mugnsoft, or remove them at the IdP. See Managing SSO users.

Security notes

  • ID tokens are verified for signature, issuer, audience and expiration against the provider JWKS; unsigned tokens are never trusted.
  • State + nonce validation and PKCE (S256) protect against CSRF and code interception.
  • TLS verification is always on.
  • The client secret is stored encrypted and never written to logs or returned to the browser.
  • Only the Mugnsoft session cookie (HTTPOnly, SameSite=Lax, Secure on HTTPS) is placed in the browser — no IdP tokens.
  • A username is never derived from an unverified email claim, and usernames are normalized (lower-cased, trimmed) so case variants cannot create duplicate accounts.
  • Role-mapping targets are validated to admin/user/viewer on save; an invalid group can never be written onto a user record.
  • A read-only DMZ frontend performs no user writes during SSO login — it cannot create, modify or re-enable accounts.

Translations