We said Stalwart's WebUI had no SSO. We were wrong — here's why it looked that way
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 andrequireAudience
were correct and are kept, at the bottom.
Authentication.directoryIdmust point at the OIDC directory and the server must be restarted. Changing it on a running server has no effect and logs nothing.- The admin SPA lives at
/account, not/login./loginis Stalwart's own OAuth server page. Grepping it for "oidc" proves nothing. - Keycloak needs a public client named
stalwart-webuiwith PKCE. The SPA hard-codes that ID. It is not the confidential client you created for mail clients. - 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
1bashOne 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 # 16bashThere 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}`;
}jsTextbook 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" }jsonRelative 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/stalwartbash{ "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", ...] }jsonNo 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'jsNot 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"]'bashAnd 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'bashBlocker 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. SetdirectoryIdon the Domain and onAuthentication, then restart.- A
302to 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-webuipublic 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.