~/blog/stalwart-keycloak-oidc-directory-realities
#stalwart#mail-server#keycloak#oidc#sso#kubernetes#ai-agents#correction

We said Stalwart's WebUI had no SSO. We were wrong — here's why it looked that way

KraftWare Platform Team·July 9, 2026·

A correction. Stalwart v0.16's WebUI does full OIDC login against Keycloak — it's identity-first, so there's no SSO button to find. Four independent blockers make it look like a missing feature: a setting that needs a restart, a login page that isn't the login page, a hard-coded public client nobody tells you to create, and a well-meaning auth proxy that blocks the real thing.

Correction — 2026-07-10. The original version of this post claimed that Stalwart's
OIDC directory "does not redirect anyone to Keycloak" and that the v0.16 WebUI has no
external-SSO sign-in path. That is false. The WebUI performs a full
authorization-code + PKCE flow against an external IdP. This post has been rewritten
around what actually happened — including how we talked ourselves into a false negative
and published it. The claims about bearer tokens, app passwords and requireAudience
were correct and are kept, at the bottom.
  1. Authentication.directoryId must point at the OIDC directory and the server must be restarted. Changing it on a running server has no effect and logs nothing.
  2. The admin SPA lives at /account, not /login. /login is Stalwart's own OAuth server page. Grepping it for "oidc" proves nothing.
  3. Keycloak needs a public client named stalwart-webui with PKCE. The SPA hard-codes that ID. It is not the confidential client you created for mail clients.
  4. Never put an auth proxy in front of the vhost. It intercepts GET /api/discover/<username> — the exact request that tells the browser to go to Keycloak.

How to be confidently wrong

We set Authentication.directoryId to our Keycloak directory with stalwart-cli, reloaded the admin UI, and got a username-and-password form. No SSO button anywhere.

So we investigated properly. We fetched /login and grepped the HTML for oidc, openid, provider, sso — zero matches. We searched upstream and found a discussion titled "WebUI authentication fails when using an external OIDC directory", and a webadmin issue about OIDC compatibility. We concluded the feature didn't exist yet, and wrote it up.

Every step was wrong, and each wrong step confirmed the one before it.

When we finally grepped the real bundle, we nearly fooled ourselves a second time:

$ grep -oic 'oidc' assets/index-4bQTQ0fA.js
1
bash

One match! Except it was case-insensitive, and it matched inside avoidCollisions — a Radix UI prop. Meanwhile Sign in with matched twice, both inside the setup wizard's prose ("…sign in with the credentials above…"). A case-sensitive search for tokens that cannot occur by accident tells the truth:

$ grep -oF 'authorization_endpoint' assets/index-*.js | wc -l   # 1
$ grep -oF 'openid'                 assets/index-*.js | wc -l   # 2
$ grep -oF 'oauth'                  assets/index-*.js | wc -l   # 16
bash

There it is.

What the WebUI actually does

From the shipped bundle, de-minified by hand:

async function discover(username) {
  const r = await fetch(`${base()}/api/discover/${encodeURIComponent(username)}`);
  if (!r.ok) throw Error(t('oauth.discoveryFailed', ...));
  return r.json();
}

async function beginLogin(username, returnTo) {
  const { authorization_endpoint, token_endpoint,
          end_session_endpoint, scopes_supported } = await discover(username);

  const verifier = newVerifier();
  const { challenge, method } = await pkce(verifier);        // S256

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: 'stalwart-webui',                             // hard-coded
    redirect_uri: `${window.location.origin}${base()}/oauth/callback`,
    code_challenge: challenge,
    code_challenge_method: method,
    state: newState(),
    login_hint: username,
    prompt: 'login',
  });

  // ask only for scopes the IdP advertises
  const scope = scopes_supported?.includes('openid')
    ? ['openid','email','profile','offline_access']
        .filter(s => scopes_supported.includes(s)).join(' ')
    : '';
  if (scope) params.set('scope', scope);

  window.location.href = `${authorization_endpoint}?${params}`;
}
js

Textbook authorization-code + PKCE. The email box is the SSO button. There is nothing else to look for.

Our operator's complaint — "it's asking for my email address instead of using SSO" — was a description of the SSO flow. They were looking straight at it.

Blocker 1 — the setting needs a restart

Everything hinges on what GET /api/discover/<username> returns. With no auth directory configured:

{ "issuer": "",
  "authorization_endpoint": "/login",
  "token_endpoint": "/auth/token" }
json

Relative paths, empty issuer: Stalwart's internal OAuth server. The SPA dutifully redirects to /login and you get a password form. Everything is behaving correctly — you are simply not configured for external auth.

Now set Authentication.directoryId to the OIDC directory and query again. Identical response.

This is where we gave up the first time.

The datastore had the new value. The running process had not re-read it.

kubectl rollout restart deploy/stalwart
bash
{ "issuer": "https://sso.example.org/realms/xl-ecosystem",
  "authorization_endpoint": "https://sso.example.org/realms/xl-ecosystem/protocol/openid-connect/auth",
  "scopes_supported": ["openid","email","profile","offline_access", ...] }
json

No error. No warning. No log line. A live config change that silently requires a restart, whose failure mode is indistinguishable from "unsupported feature", is a genuinely nasty trap — and it is the entire reason this post existed in its original, wrong form.

Upstream is clear that the OIDC directory must be the directoryId on both the Domain and the Authentication singleton (discussion #3049). What we could not find stated anywhere: restart afterwards.

Blocker 2 — the client Keycloak has never heard of

Read that JS again:

client_id: 'stalwart-webui'
js

Not stalwart. If you followed the OIDC-directory docs, you created a confidential client for mail-client bearer tokens. The WebUI wants a public client, PKCE, named exactly stalwart-webui, with redirect URI <origin>/account/oauth/callback. Nothing tells you this. Miss it and the browser lands on Keycloak's "We are sorry — client not found".

kcadm.sh create clients -r "$REALM" \
  -s clientId=stalwart-webui \
  -s publicClient=true -s standardFlowEnabled=true \
  -s directAccessGrantsEnabled=false \
  -s 'attributes."pkce.code.challenge.method"=S256' \
  -s 'redirectUris=["https://mail-admin.example.org/account/oauth/callback"]' \
  -s 'webOrigins=["https://mail-admin.example.org"]'
bash

And because the OIDC directory sets requireAudience, a token minted for stalwart-webui must still carry that audience or the server rejects it after a successful Keycloak login:

kcadm.sh create "clients/$CID/protocol-mappers/models" -r "$REALM" \
  -s name=stalwart-audience -s protocol=openid-connect \
  -s protocolMapper=oidc-audience-mapper \
  -s 'config."included.client.audience"=stalwart' \
  -s 'config."access.token.claim"=true'
bash

Blocker 3 — the workaround that blocked the fix

Having "established" that Stalwart couldn't do SSO, we did the reasonable thing: we put oauth2-proxy in front of the vhost, so at least Keycloak guarded the door.

That workaround intercepts every path on the host — including GET /api/discover/<username>, the single request that would have told the browser to go to Keycloak.

$ curl -sI https://mail-admin.example.org/api/discover/user%40example.org
HTTP/2 302
location: https://authgw.example.org/oauth2/start?rd=...

We built a fence around a door we had declared didn't exist, and the fence held the door shut.

If you ever work around a missing feature, leave a note in the workaround naming the thing it replaces. The next person to trust your conclusion will be you.

Blocker 4 — HTTPS and the origin

What was right the first time

The original post's other claims held up, and they still matter. They are about the mail protocols, not the browser.

The one-line diagnostic

Ask the server. It answers immediately and it does not lie:

curl -s "https://mail-admin.example.org/api/discover/$(python3 -c \
  'import urllib.parse;print(urllib.parse.quote("[email protected]"))')" \
  | jq '.issuer, .authorization_endpoint'
bash
  • "" and "/login" → internal auth. Set directoryId on the Domain and on Authentication, then restart.
  • A 302 to your auth proxy → you have a proxy in front of the discovery endpoint. Remove it.
  • Your IdP's real issuer URL → the server is configured. Anything still failing is on the Keycloak side: the stalwart-webui public client, its redirect URI, or the audience mapper.

The part that isn't about Stalwart

An AI agent ran this integration, hit a wall, gathered evidence, reasoned carefully, and published a false conclusion.

The evidence was real. The reasoning was clean. The conclusion was wrong because the first observation — grepping /login — examined the wrong object, and every later step was interpreted through it. Upstream discussions about OIDC problems read as corroboration. A workaround got built. The workaround made the true state unreachable, which made the false conclusion permanent and self-sealing.

None of that was fixed by trying harder. It was fixed by one question: what does the server return when I ask it directly?

A tidy write-up is not evidence. This correction is a small down payment on remembering that.


$ ./agent
Chat with the assistant
Click the assistant in the corner.
$ apply
Get early access
Tell us what you're trying to build.
We said Stalwart's WebUI had no SSO. We were wrong — here's why it looked that way | KraftWare Blog