Skip to content

09 OIDC Authentication

Mark Todd edited this page Oct 9, 2026 · 2 revisions

OIDC Authentication

The MQTTProbe web host (self-hosted web binary and Docker) can sign users in through an OpenID Connect (OIDC) identity provider instead of a local password. mqttprobe reads one claim from the ID token and admits the user only when its value matches what you configured. There is no "allow everyone" fallback.

Authentication applies only to the web-based app. The Windows, macOS, Linux, and Android apps do not have app authentication. OIDC is one of the web host's sign-in options; the web host can also use the local password described in Getting Started: First-run setup.

Choose a web sign-in mode

Setting Behavior
Local (default) One web-host admin account created at first run on /Setup. The web login page takes a username and password.
OIDC The login page shows a single "Sign in with …" button and hands off to your provider. /Setup is skipped.

The mode value is case-insensitive: OIDC, Oidc, and oidc all work. Any other value stops the app at startup with Unknown authentication mode '<value>'. Expected 'Local' or 'OIDC'.

The two modes are independent. Turning on OIDC does not delete or change a stored local password, and turning it back off returns you to the same local account. In OIDC mode the login page never validates a local password and never redirects to /Setup.

How admission works

On sign-in, mqttprobe:

  1. Runs the authorization code flow with PKCE (S256) and requests exactly two scopes, openid and profile. It does not call the userinfo endpoint.
  2. Requires the ID token to carry non-empty iss (issuer) and sub (subject) claims. A token missing either one is denied.
  3. Reads the claim named by AdmissionClaim straight from the token payload.
  4. Compares the value against AcceptedValues, or checks every entry of AcceptedValues against the claim if the claim is an array.
  5. Admits the user when anything matches, and otherwise shows the Access Denied page.

Claim value rules:

Claim shape in the ID token Result
A string equal to an accepted value Admitted
An array of strings where any element matches Admitted
An array containing any non-string element Denied
Missing, null, a number, a boolean, or an object Denied

Both the claim name and the value comparison are case-sensitive and exact. groups and Groups are different claim names, and mqttprobe-admins does not match Mqttprobe-Admins.

A token that repeats the same top-level property name is also rejected. JSON parsers normally keep the last value silently, which would make the signed content ambiguous.

Signing in again while already signed in revokes the previous mqttprobe session first, so a browser never accumulates overlapping sessions.

The Access Denied page shows the user's display name, resolved in this order: name, then preferred_username, then sub. The URL carries a random single-use handle rather than the name or the token, and it stops resolving after five minutes or one use.

Settings

All OIDC settings live under the Authentication section.

appsettings.json

{
  "Authentication": {
    "Mode": "OIDC",
    "Oidc": {
      "Authority": "https://idp.example.com/realms/myrealm",
      "ClientId": "mqttprobe",
      "ClientSecret": "your-client-secret",
      "ProviderDisplayName": "Keycloak",
      "AdmissionClaim": "groups",
      "AcceptedValues": ["/mqttprobe-admins"],
      "PublicBaseUrl": "https://mqtt.example.com"
    }
  }
}

Docker Compose environment variables

In Docker Compose you can set these as environment variables instead of editing appsettings.json. Arrays use the double-underscore index syntax:

services:
  mqttprobe:
    image: bluegrassiot/mqttprobe
    environment:
      Authentication__Mode: OIDC
      Authentication__Oidc__Authority: https://idp.example.com/realms/myrealm
      Authentication__Oidc__ClientId: mqttprobe
      Authentication__Oidc__ClientSecret: your-client-secret
      Authentication__Oidc__ProviderDisplayName: Keycloak
      Authentication__Oidc__AdmissionClaim: groups
      Authentication__Oidc__AcceptedValues__0: /mqttprobe-admins
      Authentication__Oidc__PublicBaseUrl: https://mqtt.example.com

Increment the index for more accepted values:

      Authentication__Oidc__AcceptedValues__0: /mqttprobe-admins
      Authentication__Oidc__AcceptedValues__1: /mqttprobe-operators

Required settings

Setting Description
Mode OIDC (case-insensitive). Local or anything else disables OIDC.
Authority The provider's issuer URL. An absolute HTTPS URI. A path is allowed, for example https://idp.example.com/realms/myrealm. No userinfo, query string, or fragment.
ClientId The OIDC client ID registered with your provider.
ClientSecret The client secret for a confidential client.
ProviderDisplayName Name shown on the login and Access Denied pages, for example Keycloak. The login button reads "Sign in with …".
AdmissionClaim The ID token claim name to check. Case-sensitive.
AcceptedValues A non-empty array of strings. Blank or whitespace-only entries are rejected.

Optional settings

Setting Description
PublicBaseUrl The HTTPS origin browsers use to reach mqttprobe, for example https://mqtt.example.com. Origin only, no path. If you omit it, AllowedHosts must name your real hostnames instead of a wildcard.

Fail-fast rules

Settings are validated at startup. If any rule is broken the app refuses to start and logs each problem:

Message Cause
Unknown authentication mode '<value>'. Expected 'Local' or 'OIDC'. Mode is neither Local nor OIDC.
Authority is required in OIDC mode. Authority missing or blank.
ClientId is required in OIDC mode. ClientId missing or blank.
ClientSecret is required in OIDC mode. ClientSecret missing or blank.
ProviderDisplayName is required in OIDC mode. ProviderDisplayName missing or blank.
AdmissionClaim is required in OIDC mode. AdmissionClaim missing or blank.
At least one AcceptedValue is required in OIDC mode. AcceptedValues is empty.
AcceptedValues must not contain blank entries. An accepted value is empty or whitespace.
Authority '<value>' is not a valid absolute URI. Authority is not an absolute URI.
Authority '<value>' must use HTTPS. Authority uses http://.
Authority '<value>' must not contain userinfo. / … a query string. / … a fragment. Authority carries that URI part.
PublicBaseUrl '<value>' must use HTTPS. and the matching is not a valid absolute URI, must not contain userinfo, must not contain a query string, must not contain a fragment PublicBaseUrl breaks the same rules.
PublicBaseUrl '<value>' must not contain a path (subpath hosting is deferred). PublicBaseUrl includes a path. Hosting mqttprobe under a subpath is not supported.
AllowedHosts must be configured (not wildcard) when PublicBaseUrl is not set in OIDC mode. PublicBaseUrl omitted and AllowedHosts is blank, *, 0.0.0.0, ::, or [::].

HTTPS is mandatory in OIDC mode. Authority and PublicBaseUrl must be HTTPS and the authorization request must arrive over HTTPS, so put a TLS-terminating reverse proxy in front of mqttprobe. See Docker Deployment: Add TLS.

Public origin and reverse proxy

mqttprobe builds the exact redirect_uri and post_logout_redirect_uri values from its own public origin, so it needs to know that origin.

Option 1: PublicBaseUrl (recommended)

"PublicBaseUrl": "https://mqtt.example.com"

This works even when AllowedHosts is a wildcard, and it removes any ambiguity about which origin the proxy presents to browsers.

Option 2: derive it from the request

Omit PublicBaseUrl and mqttprobe derives the origin from the incoming request's scheme and Host header. That requires:

  • AllowedHosts set to your actual hostname(s), not a wildcard. Multiple hosts are semicolon-separated.
  • The request arriving over HTTPS. Plain HTTP fails at request time with Request-derived origin must use HTTPS in OIDC mode.
  • PublicBaseUrl absent and AllowedHosts non-restrictive, which fails at request time with PublicBaseUrl must be set or AllowedHosts must be configured when using OIDC mode.
"AllowedHosts": "mqtt.example.com;mqtt-backup.example.com"

Reverse proxy

mqttprobe reads X-Forwarded-Proto and X-Forwarded-Host through the standard ASP.NET Core forwarded-headers middleware. Make sure your proxy sets both, and tell mqttprobe which proxies to trust:

Setting Purpose
ForwardedHeaders:ForwardLimit How many proxy hops to unwrap. Defaults to 1.
ForwardedHeaders:KnownProxies Proxy IP addresses to trust.
ForwardedHeaders:KnownNetworks Proxy IP networks to trust.

docker-compose.prod.yml sets these for the Caddy network. mqttprobe has no separate OIDC-specific proxy setting.

Paths

Path Purpose
/Login Login page. Anonymous.
/Logout Logout. POST only, antiforgery-protected.
/Setup Local-mode first-run password setup. Never reached in OIDC mode.
/AccessDenied Shown when the admission claim does not match.
/health Health probe. Anonymous.
/signin-oidc Authorization callback. Register this as a redirect URI.
/signout-callback-oidc Post-logout callback. Register this as a post-logout redirect URI.
/signout-oidc Provider-initiated front-channel sign-out. Browser-facing, provided by the OIDC middleware. Do not register it as a redirect URI.
/oidc/backchannel-logout Provider-initiated back-channel sign-out. Server-to-server POST. See below.

Every other page requires authentication.

Sessions and cookies

Cookie behavior differs by mode:

OIDC mode Local mode
HttpOnly Yes Yes
Secure Always Same as request
SameSite Lax Strict
Expiry 8 hours, absolute 8 hours, sliding

Lax in OIDC mode is what lets the cookie survive the cross-site redirect back from the provider. The OIDC correlation and nonce cookies are SameSite=None with Secure for the same reason.

OIDC mode also keeps a server-side session record keyed by an ID in the cookie. Every request revalidates the cookie principal against that record, and a record that is gone or altered rejects the principal and signs the user out. Signing in again, signing out, or a back-channel logout revokes the record and stops that session's MQTT connections and emulators.

Login POSTs are rate limited per client IP: 5 per minute, then HTTP 429 with Too many login attempts. Please try again later.

Logging out

Signing out is a POST to /Logout and happens in this order:

  1. The app session is revoked, which stops that session's MQTT connections and emulators.
  2. The local cookie is cleared before any call to the provider, so an unreachable provider can never leave an authenticated browser session.
  3. mqttprobe reads the provider's discovery document. If that fails, or the provider publishes no end_session_endpoint, the user lands back on /Login with a message saying they are signed out of mqttprobe but the provider session could not be ended.
  4. Otherwise the browser is redirected to the provider's end-session endpoint. mqttprobe sends post_logout_redirect_uri set to your origin plus /signout-callback-oidc, an id_token_hint when an ID token is present in the session, and client_id otherwise so the provider can still identify the relying party.

mqttprobe does not retain refresh tokens or access tokens, so there is no background token refresh and no provider-side revocation to wait on.

Provider-initiated logout

Two separate paths handle the provider ending a session.

Front channel (/signout-oidc)

The provider redirects the browser to /signout-oidc after a user-initiated logout at the provider. This is the OIDC middleware's own browser-facing route and is separate from the callbacks above. It is not a redirect URI and not a post-logout redirect URI.

Back channel (/oidc/backchannel-logout)

The provider makes a server-to-server POST to revoke sessions without involving a browser. This endpoint is registered only in OIDC mode.

Request shape:

Property Value
Method POST only. Anything else returns 405.
Content type Form-encoded. Anything else returns 415.
Body field Exactly one logout_token form value.
Body size 64 KiB maximum, enforced while reading. Larger returns 413.

The endpoint is anonymous and skips antiforgery, so every decision comes from the signed logout token. It accepts a token only when all of the following hold:

Requirement Detail
Signature Verified against the provider's published signing keys. Asymmetric algorithms only: RS256/384/512, PS256/384/512, ES256/384/512. none and all HS* algorithms are refused outright.
iss Matches the trusted issuer from the discovery document.
aud Contains your ClientId.
iat and exp Both present. exp must be in the future, later than iat, within 30 minutes of iat, and iat no more than 10 minutes old. Two minutes of clock skew is allowed.
jti Present and non-empty. Used for replay protection.
events Contains a member whose type is http://schemas.openid.net/event/backchannel-logout.
nonce Must be absent. A logout token carrying a nonce is rejected.
Session claim At least one of sub or sid.
JSON shape No duplicate property names at any level.

An accepted token revokes every matching mqttprobe session, matching on sid when present and falling back to sub. The jti is then remembered until the token's exp, so the same token cannot be replayed. Metadata is refreshed at most once a minute, so a token with an unknown key ID cannot turn every POST into a fetch against your provider.

Responses:

Status Meaning
200 Token accepted and sessions revoked (including the case where nothing matched).
400 Token rejected. The reason is logged, not returned, because the caller is unauthenticated.
405 / 413 / 415 Wrong method, body too large, or wrong content type.
500 The token was valid but session revocation did not complete.

Register the endpoint with your provider as a back-channel logout URI:

  • Keycloak: set the client attribute Backchannel logout URL to the endpoint, and turn on Backchannel logout session required.
  • Authentik: set the provider's Logout URI to the endpoint and Logout Method to backchannel.

The provider reaches this URL server to server, so it must be reachable from the provider, not only from a user's browser. In Docker, use the in-network address such as http://mqttprobe:8080/oidc/backchannel-logout. Keep the browser-facing redirect and post-logout URLs on the public HTTPS origin.

Pick an admission claim

Most providers can put group membership, a role, or a user attribute into the ID token. All three work; they differ in what the value looks like.

Source Typical claim Value shape Watch out for
Group membership groups Array of strings Keycloak's group mapper with Full group path on emits a leading slash, /mqttprobe-admins.
User attribute anything you choose, for example mqttprobe_access String or array You maintain the value per user by hand.
Expression or role mapping anything you choose String or array Best fit when admission depends on more than one fact.

Because mqttprobe requests only openid and profile, any custom mapping has to be attached to one of those scopes. A mapping hung on a scope mqttprobe never asks for is silently never evaluated.

Keycloak recipe

These notes describe Keycloak 26.x. Field names move between versions, so match on the label rather than the exact wording.

Create the realm and client

  1. In the admin console, select or create a realm, for example mqttprobe.
  2. Go to Clients and click Create client.
  3. Set Client type to OpenID Connect and Client ID to mqttprobe, then click Next.
  4. On the Capability config step turn Client authentication on, leave Authorization off, and under Authentication flow check only Standard flow. Leave Direct access grants and Service account roles off.
  5. On the Login settings step set:
    • Valid redirect URIs: https://mqtt.example.com/signin-oidc
    • Valid post logout redirect URIs: https://mqtt.example.com/signout-callback-oidc
    • Web origins: https://mqtt.example.com
  6. Click Save.
  7. Open the Advanced tab and set Proof Key for Code Exchange Code Challenge Method to S256. Leave Implicit flow off.
  8. Open the Credentials tab and copy the Client Secret.

Authority URL

The Authority is the realm issuer URL:

https://keycloak.example.com/realms/mqttprobe

Confirm it by opening https://keycloak.example.com/realms/mqttprobe/.well-known/openid-configuration and reading the issuer field. It has to match exactly.

Claim option A: group membership

  1. Go to Groups and create a group, for example mqttprobe-admins.
  2. Add the users who should have access.
  3. Go to Clients > mqttprobe > Client scopes > the dedicated scope > Configure a new mapper.
  4. Choose Group Membership (oidc-group-membership-mapper) and set:
    • Token Claim Name: groups
    • Full group path: on
    • Add to ID token: on
    • Add to access token: off
    • Add to userinfo: off
  5. Save the mapper.

With Full group path on, the claim value carries a leading slash, so the accepted value is /mqttprobe-admins.

Do not use a built-in admin or role mapper that grants access to every user. That defeats the point of the check.

Claim option B: a user attribute

Useful when group paths are awkward or you want a value unrelated to your directory structure.

  1. Give each user a custom attribute, for example mqttprobe_access, with value admin.
  2. Add a User Attribute mapper (oidc-usermodel-attribute-mapper) on the client's dedicated scope:
    • User Attribute: mqttprobe_access
    • Token Claim Name: mqttprobe_access
    • Add to ID token: on
  3. Set AdmissionClaim to mqttprobe_access and AcceptedValues to ["admin"].

This is what the bundled Keycloak lab uses, so it is the fastest path to a working end-to-end test.

mqttprobe configuration for Keycloak

{
  "Authentication": {
    "Mode": "OIDC",
    "Oidc": {
      "Authority": "https://keycloak.example.com/realms/mqttprobe",
      "ClientId": "mqttprobe",
      "ClientSecret": "paste-the-client-secret-here",
      "ProviderDisplayName": "Keycloak",
      "AdmissionClaim": "groups",
      "AcceptedValues": ["/mqttprobe-admins"],
      "PublicBaseUrl": "https://mqtt.example.com"
    }
  }
}
services:
  mqttprobe:
    image: bluegrassiot/mqttprobe
    environment:
      Authentication__Mode: OIDC
      Authentication__Oidc__Authority: https://keycloak.example.com/realms/mqttprobe
      Authentication__Oidc__ClientId: mqttprobe
      Authentication__Oidc__ClientSecret: paste-the-client-secret-here
      Authentication__Oidc__ProviderDisplayName: Keycloak
      Authentication__Oidc__AdmissionClaim: groups
      Authentication__Oidc__AcceptedValues__0: /mqttprobe-admins
      Authentication__Oidc__PublicBaseUrl: https://mqtt.example.com

Test the Keycloak flow

Admitted user:

  1. Add a user to the accepted group (or give them the accepted attribute value).
  2. Open https://mqtt.example.com.
  3. Click Sign in with Keycloak and enter the user's credentials.
  4. You land back on the MQTTProbe main page.

Denied user:

  1. Use a user who authenticates successfully but lacks the accepted value.
  2. Sign in through the provider.
  3. You land on the Access Denied page showing the user's display name.

Logout:

  1. While signed in, use the logout button.
  2. The provider's logout page appears, then /signout-callback-oidc, then the login page.
  3. The browser Back button does not restore the session.

Authentik recipe

Create the provider

  1. Go to Applications > Providers and click Create.
  2. Choose OAuth2/OpenID Provider.
  3. Configure:
    • Name: mqttprobe
    • Authorization flow: the default provider authorization flow is fine
    • Client type: Confidential
    • Client ID: mqttprobe
    • Client Secret: copy the generated secret
    • Redirect URIs: two entries
      • https://mqtt.example.com/signin-oidc, type Authorization
      • https://mqtt.example.com/signout-callback-oidc, type Logout
    • Signing Key: the default signing key
  4. Under Advanced protocol settings leave Include claims in ID token on and keep the openid and profile scope mappings selected.
  5. Click Create.

The Logout-typed redirect URI is the post_logout_redirect_uri used after a sign-out you started from mqttprobe. The provider's separate Logout URI field is for back-channel logout and wants the /oidc/backchannel-logout endpoint instead.

Create the application

  1. Go to Applications > Applications and click Create.
  2. Set Name to mqttprobe and Slug to mqttprobe. The slug appears in the issuer URL.
  3. Select the provider you just created and click Create.

Authority URL

Note the Issuer URL on the provider detail page:

https://authentik.example.com/application/o/mqttprobe/

That trailing slash matters. This is your Authority.

Claim: what Authentik emits

Authentik's built-in profile scope mapping includes a groups claim holding group names as a JSON array with no leading slash:

{
  "groups": ["mqttprobe-admins", "other-group"]
}

So with the group approach, AcceptedValues is ["mqttprobe-admins"], not ["/mqttprobe-admins"].

To gate on something else, add a custom scope mapping and attach it to the profile scope. This is required, not optional: mqttprobe requests only openid and profile, so a mapping on any other scope name is never evaluated. An expression mapping works well:

if ak_is_group_member(request.user, name="mqttprobe Users"):
    return {
        "mqttprobe_access": "admin",
    }
return {}

Return an empty result for users who should be denied. They still authenticate successfully at the provider; mqttprobe denies them afterwards.

Whichever claim you choose, remember to log out and back in (or use a private window) after changing it, because the claim lives in a token issued at sign-in.

mqttprobe configuration for Authentik

{
  "Authentication": {
    "Mode": "OIDC",
    "Oidc": {
      "Authority": "https://authentik.example.com/application/o/mqttprobe/",
      "ClientId": "mqttprobe",
      "ClientSecret": "paste-the-client-secret-here",
      "ProviderDisplayName": "Authentik",
      "AdmissionClaim": "groups",
      "AcceptedValues": ["mqttprobe-admins"],
      "PublicBaseUrl": "https://mqtt.example.com"
    }
  }
}
services:
  mqttprobe:
    image: bluegrassiot/mqttprobe
    environment:
      Authentication__Mode: OIDC
      Authentication__Oidc__Authority: https://authentik.example.com/application/o/mqttprobe/
      Authentication__Oidc__ClientId: mqttprobe
      Authentication__Oidc__ClientSecret: paste-the-client-secret-here
      Authentication__Oidc__ProviderDisplayName: Authentik
      Authentication__Oidc__AdmissionClaim: groups
      Authentication__Oidc__AcceptedValues__0: mqttprobe-admins
      Authentication__Oidc__PublicBaseUrl: https://mqtt.example.com

Authentik logout behavior

Authentik's default logout is app-scoped: the user leaves mqttprobe but stays signed in to Authentik and any other application.

To end the Authentik session too, give the provider an invalidation flow that includes a user logout stage. That is an Authentik-side setting. See the Authentik guide on enabling full single logout for RP-initiated logout.

Test the Authentik flow

  1. Add a user to the accepted group.
  2. Open https://mqtt.example.com and click Sign in with Authentik.
  3. Enter the user's credentials; you land back on the MQTTProbe main page.
  4. Sign out, confirm you pass through the Authentik logout page and return to /signout-callback-oidc, and confirm Back does not restore the session.
  5. Sign in as a user outside the accepted group: authentication succeeds and you land on the Access Denied page.

Local Compose labs

The repository ships two ready-made labs for developing and testing OIDC locally. Each starts mqttprobe configured for that provider, behind a Caddy reverse proxy with its own local CA.

Keycloak lab Authentik lab
Directory deploy/keycloak/ deploy/authentik/
Also starts Keycloak (dev database), Caddy, CA init Authentik server and worker, PostgreSQL, Caddy, CA init
Identity provider https://keycloak.localhost:8443 https://authentik.localhost:9443
mqttprobe https://localhost:5001 https://localhost:5001
Authority https://keycloak.localhost:8443/realms/mqttprobe-test https://authentik.localhost:9443/application/o/mqttprobe/
Admission claim mqttprobe_access (user attribute) mqttprobe_access (expression mapping)
Accepted value admin admin
Back-channel logout Configured Configured

Each lab has a README with the quick start, test credentials, CA trust steps for your platform, smoke script, and cleanup. Run the smoke test with bash smoke.sh, or .\smoke.ps1 on Windows.

cd deploy/keycloak      # or deploy/authentik
cp .env.example .env
docker compose up --build -d

Two things to know before you start:

  • Authentik takes 30 to 90 seconds to finish migrations on first start. Wait for the server service to report healthy.
  • Trust the Caddy root CA or every browser request fails. The lab README has the certutil, security add-trusted-cert, and update-ca-certificates commands.

Test accounts:

Lab Account Password Result
Keycloak admitted-user test-password Admitted: the attribute value is admin.
Keycloak denied-user test-password Denied: the attribute value is viewer, which is not accepted.
Keycloak admin console admin from .env, default admin Provider administration at https://keycloak.localhost:8443/admin.
Authentik none pre-created - Use akadmin from your .env, then create users yourself. Add admitted-user to the mqttprobe Users group, or leave denied-user out of it.

Both labs are development-only. They bind to 127.0.0.1, use test secrets, and generate an ephemeral CA that is recreated whenever you wipe the volumes.

Troubleshooting

The app will not start

Read the startup log. Each broken rule is reported on its own line, and the table in Fail-fast rules maps every message to its cause.

The login button is missing, or the page asks for a username and password

Authentication:Mode is not OIDC, or it did not take effect. In Docker, environment variable names are case-sensitive and use double underscores: Authentication__Mode.

Startup fails with AllowedHosts must be configured (not wildcard) when PublicBaseUrl is not set in OIDC mode

Either set PublicBaseUrl to your HTTPS origin, or set AllowedHosts to your real hostname(s). *, 0.0.0.0, ::, and [::] all count as non-restrictive.

Startup fails with PublicBaseUrl '…' must not contain a path (subpath hosting is deferred)

Serve mqttprobe from the root of its own hostname. https://host/mqttprobe is not supported.

The provider redirects to mqttprobe and returns an error

The registered redirect URI does not match. It has to be exactly https://your-host/signin-oidc, including scheme, host, port, and path. Trailing slashes matter. Check Valid redirect URIs in the provider.

Sign-in succeeds at the provider but MQTTProbe shows Access Denied

The ID token's claim does not match. Check, in order:

  1. AdmissionClaim is spelled and cased correctly (groups, not Groups).
  2. The value matches an accepted value exactly, including case.
  3. Leading slashes line up. Keycloak with Full group path on emits /mqttprobe-admins; Authentik's groups claim and user-attribute values are bare.
  4. The claim is actually in the ID token. A mapping on a scope mqttprobe does not request is never evaluated, and mqttprobe asks only for openid and profile.
  5. The token carries iss and sub, and does not repeat a top-level property name.

Inspect the token with your provider's own tooling: Keycloak has Client scope > Evaluate, and Authentik has a provider Test view. If you paste a token into an online decoder, remember that an ID token is a credential.

The login page says the identity provider is unavailable

mqttprobe fetches the provider's discovery document before it redirects you, so an unreachable or slow provider fails fast instead of bouncing you to a broken login page. Check that Authority resolves from the mqttprobe container or host, and that the provider's TLS certificate is trusted there. The same page also shows a timeout message and a generic authentication-failed message.

Logout does not reach the provider

mqttprobe needs the provider's discovery document to advertise an end_session_endpoint. If discovery fails or the endpoint is missing, you are signed out of mqttprobe and the login page says the provider session could not be ended. Open the provider's .well-known/openid-configuration and check for end_session_endpoint.

A back-channel logout is ignored, and the log says why

Rejected tokens are logged with a reason and never returned to the caller. The usual ones:

Logged reason Meaning
duplicate_token That jti was already used. Expected on retries after a successful call.
invalid_signature The signature did not verify. An unknown key ID triggers one metadata refresh, so a normal key rotation recovers on its own; a persistent failure means the signature really does not match the provider's keys.
invalid_issuer / invalid_audience The iss or aud does not match what mqttprobe trusts. Check Authority and ClientId.
disallowed_algorithm The provider signed with none or an HS* algorithm, which mqttprobe refuses. Use an RSA, ECDSA, or PSS key.
token_too_old / token_lifetime_too_long iat is over 10 minutes old, or the token lives longer than 30 minutes.
token_expired exp has passed. Check clock skew between mqttprobe and the provider.
missing_session_claims Neither sub nor sid is present, so mqttprobe cannot tell which session to end.
nonce_present Logout tokens must not carry a nonce. This usually means the wrong token was sent.
invalid_events The events member is missing or does not contain the back-channel logout event type.
claim_cache_full Too many unexpired jti values cached. Usually a provider loop.

If the provider cannot reach the endpoint at all, nothing is logged by mqttprobe. Check that the logout URI you registered is server-reachable, and use the in-network address in Docker rather than a browser-facing one.

Security notes

  • Admission is all or nothing. Every admitted user is granted the built-in Admin role. There is no role mapping from provider claims to MQTTProbe roles, so treat the admission claim as the single gate: whoever matches it can do everything an administrator can, including reading broker credentials and client certificates held by the server. Size your admitted group accordingly.
  • Deny by default. No claim match means no access. There is no allow-all path and no fallback to the local account while OIDC mode is on.
  • Exact redirect URIs. mqttprobe builds redirect_uri and post_logout_redirect_uri from your configured origin, never from user input, which prevents open redirects into the OIDC handshake.
  • Local cookie cleared first. Sign-out clears the mqttprobe cookie before contacting the provider, so a broken provider cannot leave a usable session.
  • No tokens retained. mqttprobe keeps no refresh or access tokens, so there is no background refresh and nothing to leak from a stolen cookie beyond the session itself.
  • An 8-hour stale-authorization ceiling. The session cookie is absolute, not sliding. Removing a user from the accepted group or value does not cut off an existing session; it ends at the next sign-in, sign-out, or back-channel logout.
  • Server-side session registry. Every request revalidates the cookie against a server-side record, so revoking the record signs the user out everywhere, not just in one tab.
  • Provider logout is best-effort. It depends on the provider's discovery document and end-session endpoint being reachable. When it is not, mqttprobe says so instead of pretending.
  • Secrets. ClientSecret comes from appsettings.json or the environment. In Docker prefer an environment variable or a secrets manager over baking it into an image. See Security and Storage.

Related

Clone this wiki locally