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.
What you need before you start
Section titled “What you need before you start”| 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.
Register LinkMesh with your provider
Section titled “Register LinkMesh with your provider”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.
Configure the server
Section titled “Configure the server”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.
Decide who gets which role
Section titled “Decide who gets which role”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 asaccount.rolesto 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.
Keep a way back in
Section titled “Keep a way back in”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
Locked out after a licence change
Section titled “Locked out after a licence change”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:
-
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":"…"}. -
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.
-
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.
Sign in
Section titled “Sign in”-
Open your LinkMesh URL. The login card now offers a single sign-on button instead of the password form.
-
You are redirected to your provider, authenticate there, and come back to
/auth/callback. -
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.
Secure origins are not optional
Section titled “Secure origins are not optional”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.
Using provider tokens against the API
Section titled “Using provider tokens against the API”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.
When it does not work
Section titled “When it does not work”| 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 |
Worked example: Keycloak
Section titled “Worked example: Keycloak”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:
- Clients → Create client. Client type OpenID Connect, client ID
linkmesh. - 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.
- Login settings → Valid redirect URIs:
https://linkmesh.example.com/auth/callback. - Advanced → Proof Key for Code Exchange Code Challenge Method:
S256. - Credentials tab (confidential clients only): copy the client secret.
- Client scopes: the default
emailandprofilescopes are assigned already; if you want group-based roles, add a Group Membership mapper to the dedicated client scope, claim namegroups, 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.
What’s next
Section titled “What’s next”- Manage users, roles, and permissions — roles, scoped assignments, and the claim-mapping screen in detail
- Run behind a reverse proxy — terminating TLS, so the browser treats your install as a secure origin
- Configuration reference — every authentication key, type and default
- Authenticate with a service account — non-interactive API access