Skip to content

Configure OIDC single sign-on

LinkMesh authenticates operators in one of two modes. Local (the default) uses the built-in user database and a password form. External hands sign-in to your own OpenID Connect provider: the browser is redirected to your provider, comes back with an authorization code, and LinkMesh exchanges it server-side for an ID token, reads the claims, and mints its own session.

Nothing here is provider-specific. LinkMesh reads your provider’s discovery document and takes the authorization endpoint, token endpoint and signing keys from it, so any conformant provider — Okta, Entra ID, Auth0, Keycloak, Google Workspace, Ping, Dex — is configured the same way: an issuer, a client, and a redirect URI.

There is one worked example at the bottom of this page. It is an illustration, not the supported path — the steps above it are complete on their own.

You need Where it comes from Notes
Issuer URL Your provider The base URL whose /.well-known/openid-configuration returns the discovery document. Must be https:// — plain http:// is refused unless the host is localhost, 127.0.0.1 or ::1.
Client ID A client (application) you register for LinkMesh Any name; LinkMesh does not care.
Client secret The same client, if it is confidential Optional. Public (PKCE-only) clients leave it empty.
Redirect URI You choose it Always <your LinkMesh base URL>/auth/callback.
Scopes Your provider openid profile email covers it. The email claim is mandatory — see below.
A LinkMesh URL the browser trusts Your deployment https://…, or http://localhost. See Secure origins.

Your provider must support the authorization code flow with PKCE (S256) and sign ID tokens with RSA (RS256, RS384 or RS512). Both are the common default; a provider configured to issue EC-signed (ES256) tokens will not validate here.

Create a client (your provider may call it an application, an app registration, or a relying party) with these properties. The names differ per product; the meanings do not.

Property Value
Client type Web application / confidential — or public, if you prefer not to manage a secret
Grant / flow Authorization code
PKCE Enabled, method S256
Redirect URI (callback URL) https://linkmesh.example.com/auth/callback
Scopes openid, profile, email
ID token signing algorithm RS256 (or RS384 / RS512)

Two things that bite regardless of product:

  • The redirect URI must match exactly — scheme, host, port and path, with no trailing slash. Most providers do a literal string comparison and answer a mismatch with their own error page, before LinkMesh is ever reached.
  • If you want role mapping, make sure the claim you intend to map on (group membership, a role list, a department, anything) is actually present in the ID token. Many providers omit group claims unless you add them to the client’s token configuration.

Keep the issuer, client ID and (if confidential) client secret — the next step needs them.

External auth is server configuration, not a UI setting: it is read at startup from /etc/linkmesh/config.yaml on the host running linkmesh-server, or from environment variables.

auth:
  mode: external
  external:
    issuer: "https://idp.example.com/realms/company"
    clientId: "linkmesh"
    clientSecret: "CHANGEME"          # omit for a public (PKCE-only) client
    scope: "openid profile email"
    redirectUrl: "https://linkmesh.example.com/auth/callback"
    defaultRole: ""                    # "" = deny anyone no mapping matches
    groupsClaim: "groups"
    requireApproval: false
  local:
    adminEmail: "admin"                # the break-glass account — see below

The same settings as environment variables, for a container deployment:

AUTH_MODE=external
AUTH_EXTERNAL_ISSUER=https://idp.example.com/realms/company
AUTH_EXTERNAL_CLIENT_ID=linkmesh
AUTH_EXTERNAL_CLIENT_SECRET=CHANGEME
AUTH_EXTERNAL_SCOPE="openid profile email"
AUTH_EXTERNAL_REDIRECT_URL=https://linkmesh.example.com/auth/callback
AUTH_EXTERNAL_DEFAULT_ROLE=

Every key, its type and its default is in the configuration reference.

Restart the server and read the first lines of its log:

sudo systemctl restart linkmesh-server
sudo journalctl -u linkmesh-server -n 30

A working configuration logs the discovery result and what it found:

Authentication mode: external (OIDC)  issuer=https://idp.example.com/realms/company
OIDC discovery complete  jwks_uri=… authorization_endpoint=… token_endpoint=…
OIDC discovery complete; external auth ready

If discovery fails, the server exits rather than starting. That is deliberate — a server that booted anyway could never validate a token, and would lock every operator out with no explanation. The log line before the exit names the issuer and the reason.

A server configured for external mode without a licence that includes single sign-on still starts. It logs a warning naming the licence requirement and serves local password login only, until you upload an Enterprise licence — no restart needed after the upload.

An ID token proves who someone is. It does not say what they may do in LinkMesh. Before the first external sign-in, decide one of two things:

  • Map claims to roles (recommended). In Roles → claim mappings, map a claim value to a LinkMesh role: claim path (groups, roles, department, or a dotted path such as account.roles to reach into a nested object), the value to match, the role to grant, and a priority that breaks ties when several rules match. Matching works on a string claim or on any entry of a list claim. The full walkthrough is in Manage users, roles, and permissions.
  • Set a default role (auth.external.defaultRole), which everyone who authenticates receives when no mapping matches.

With neither, every external sign-in is refused with “No role mapping matched your account — access denied”. That is the safe default, and it is also the single most common reason a correctly configured provider still cannot log anybody in.

Claim mappings are administered through the LinkMesh API and UI, which you cannot reach until someone is signed in — hence the break-glass account below.

By default a matching user is created on first sign-in (just-in-time provisioning). Set auth.external.requireApproval: true to require that an administrator creates each user first; unknown addresses are then refused even with a valid token and a matching claim.

In external mode, while the licence covers single sign-on, the login page shows only the single sign-on button. The built-in password login still works at the API:

curl -X POST https://linkmesh.example.com/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin","password":"…"}'

Only accounts that hold a password can use it — externally provisioned users have none — so in practice this is the seeded local administrator. It is how you configure claim mappings before any external user can be granted a role, and how you get back in when the provider is unreachable, the client secret was rotated, or a mapping was deleted.

Keep that account, keep its password somewhere you can reach without LinkMesh, and if it is lost, reset it on the host:

sudo linkmesh-server reset-password --email admin

If the licence stops covering single sign-on — it expired past its grace period into read-only, or it was replaced by a Community licence — every sign-in through your provider is refused, including provider tokens that were issued before the change, and existing single sign-on sessions end on their next request. The refusal is HTTP 403 with the code license_sso_required.

Local password login is never disabled by licence state, and uploading a licence always stays open to the administrator. That is the way back in.

In the browser, the login page says that single sign-on requires an Enterprise licence and shows the password form instead of the sign-on button. Sign in as the local administrator, open Settings → License, and upload a current licence file.

The same works from the API, which needs no browser and does not depend on what the login page shows:

  1. Sign in as the local administrator and keep the token from the response:

    curl -X POST https://linkmesh.example.com/api/v1/auth/login \
      -H 'Content-Type: application/json' \
      -d '{"email":"admin","password":"…"}'

    The response is {"token":"…"}.

  2. Upload a current licence file with that token:

    curl -X PUT https://linkmesh.example.com/api/v1/license \
      -H 'Authorization: Bearer <token>' \
      -F [email protected]

    The response describes the licence now in force.

  3. Sign in through your provider again. Single sign-on works as soon as the new licence is accepted — the server does not need a restart.

Claim mappings are kept while single sign-on is unavailable — Roles → claim mappings shows them read-only — so they apply again unchanged once the licence covers it.

  1. Open your LinkMesh URL. The login card now offers a single sign-on button instead of the password form.

  2. You are redirected to your provider, authenticate there, and come back to /auth/callback.

  3. LinkMesh exchanges the code server-side, validates the ID token against your provider’s signing keys, resolves your role from the claim mappings, and drops you on the dashboard.

The browser never receives your provider’s tokens or the client secret: the code exchange happens on the server. Afterwards every request carries a LinkMesh session token whose lifetime is auth.tokenExpiry (default 24 hours) — independent of your provider’s own session.

PKCE needs Web Crypto, which browsers expose only on a secure origin: HTTPS, or http://localhost. Opening LinkMesh over plain HTTP on a LAN address or a public hostname makes single sign-on impossible in that browser, and the login card says so:

Single sign-on needs a secure connection. Open LinkMesh over HTTPS (or over http://localhost) and sign in again.

This is a browser rule — no LinkMesh setting turns it off. The server itself serves plain HTTP on one port by design; terminate TLS in front of it, as described in Run behind a reverse proxy. When you do, externalUrl, the redirect URI you registered, and the URL operators type must all be the public HTTPS one.

Beyond the browser flow, an RSA-signed token issued by the configured provider is accepted directly as Authorization: Bearer <token> on the REST API; the same claim mapping resolves the role, and the same licence requirement applies — without it, such a token is refused with license_sso_required. For unattended automation prefer a service account — its token does not expire on your provider’s schedule and carries its own scoped permissions.

Symptom Cause Fix
Login page says single sign-on requires an Enterprise licence, you are signed out with that message, or a request is refused with HTTP 403 license_sso_required The server’s licence does not cover single sign-on: none uploaded, Community, or expired to read-only Sign in as the local administrator and upload a current Enterprise licence — see Locked out after a licence change
Server exits at startup, log names the issuer Discovery URL wrong, unreachable, or not HTTPS Confirm curl {issuer}/.well-known/openid-configuration returns HTTP 200 from the server, not just your laptop
Provider shows its own error before LinkMesh loads Redirect URI mismatch Registered value must equal auth.external.redirectUrl character for character
“Single sign-on needs a secure connection” Page opened over plain HTTP Use HTTPS, or http://localhost
“Authorization code exchange failed” Wrong or missing client secret, or a confidential client configured as public Check auth.external.clientSecret against the client
“Invalid ID token from identity provider” Token signed with a key LinkMesh will not use Provider must sign with RSA and publish the key with "use": "sig" in its JWKS
“Identity provider did not return an email claim” email not in the ID token Add the email scope, and add the claim to the token if your provider needs that separately
“No role mapping matched your account” No claim mapping matched and defaultRole is empty Add a mapping, or set a default role. Check the claim is actually in the token
“Your account is not provisioned” requireApproval: true and no user record Create the user first, matching the email exactly
Sign-in works, but the role is wrong A higher-priority mapping matched Roles resolve on every login and overwrite what is stored — the mapping wins, not the Users page

Everything above is what you need. This section exists only to show the generic steps against one concrete product; if you use something else, the mapping to your own console’s vocabulary is the table in Register LinkMesh with your provider.

Keycloak — one realm, one client, one group mapping

In the Keycloak admin console, in your realm:

  1. Clients → Create client. Client type OpenID Connect, client ID linkmesh.
  2. Capability config: Standard flow on (that is the authorization code flow); Direct access grants off. Turn Client authentication on for a confidential client — leave it off for a public, PKCE-only one.
  3. Login settings → Valid redirect URIs: https://linkmesh.example.com/auth/callback.
  4. Advanced → Proof Key for Code Exchange Code Challenge Method: S256.
  5. Credentials tab (confidential clients only): copy the client secret.
  6. Client scopes: the default email and profile scopes are assigned already; if you want group-based roles, add a Group Membership mapper to the dedicated client scope, claim name groups, and turn Add to ID token on.

The issuer is https://<keycloak-host>/realms/<realm> — for the master realm on auth.example.com, https://auth.example.com/realms/master. Its discovery document is at that URL plus /.well-known/openid-configuration.

Then, in LinkMesh:

auth:
  mode: external
  external:
    issuer: "https://auth.example.com/realms/master"
    clientId: "linkmesh"
    clientSecret: "…"
    scope: "openid profile email"
    redirectUrl: "https://linkmesh.example.com/auth/callback"

Keycloak group names arrive with a leading slash, so a claim mapping matching the groups claim uses the value /linkmesh-admins, not linkmesh-admins. Realm roles live under the nested claim path realm_access.roles instead.