Your question
TL;DR
We run Stalwart with a single @type: Oidc directory as the only auth backend (IdP = Authentik), provisioned entirely by database via the JMAP management API. Webmail + IMAP OAUTHBEARER work.
Now facing three issues:
- First boot:
x:Authentication.directoryIdis silently cleared tonullon restart for ~10–20 min after a cold boot, then sticks permanently. We can’t see why. - Web-UI /
/accountlogin: the self-service portal’s OAuth token (client_id=stalwart-webui) is rejected by the directory, which is pinned to a different client (bulwark). Users can’t reach/accountto mint app passwords. - App passwords: the
@type: Oidcdirectory has nostorefield (per your reference schema) — so it’s unclear whether app passwords can work for an OIDC-backed principal at all, or where the secret would live.
Understood the following from the docs
- Apple Mail / Thunderbird / K-9 can’t do third-party
OAUTHBEARER; app passwords are the documented workaround. That’s exactly what we’re trying to stand up. requireAudience(default"stalwart") gatesaud;claimUsername+usernameDomain(withemail-claim fallback) handle our full-email usernames. Working.- OIDC accounts must be pre-created before first sign-in. Done.
- Tracing: we will run a
Tracer(@type: Stdout,level: trace, capturingauth.*+config.build-error/config.fetch-error) to capture the errors below.
Shared environment
- Single node, Docker Compose: postgres + Authentik (server/worker) + Stalwart + webmail (Bulwark) + Caddy on one bridge network. Caddy terminates TLS and reverse-proxies the IdP and Stalwart JMAP/
:8080on:443. - Stalwart v0.16.8; the only on-disk file is
config.json={"@type":"RocksDb","path":"/var/lib/stalwart/"}. All provisioning via JMAPx:*methods on loopback:8080(admin basic-auth);stalwart-cliis not in the image. - One
x:Directory(@type: Oidc) is the default directory (x:Authentication.directoryId):issuerUrl: https://<idp>/application/o/bulwark/(Authentik per-provider issuer)requireAudience: bulwark(the OAuthclient_idour webmail uses)requireScopes: ["openid","email"],claimUsername: preferred_username,claimName,claimGroups.
- No internal directory alongside the OIDC one. Webmail JMAP + IMAP
OAUTHBEARERwithbulwark-issued tokens (iss=.../o/bulwark/,aud=bulwark) work perfectly.
Happy to share full x:Directory / x:Authentication JSON, the authorize request, and decoded (non-secret) token claims, or run any diagnostic.
Group 1 — x:Authentication.directoryId won’t persist on cold first boot
Symptom
On a cold first boot:
- We provision in order:
x:Domain→x:SystemSettings(defaultHostname+defaultDomainId) → the OIDCx:Directory→x:Authentication/set{ directoryId: "<oidc-dir-id>", defaultUserRoleIds: {…} }. The set succeeds; an immediategetshowsdirectoryIdcorrect. - We restart Stalwart (a restart appears required for a new
directoryIdto take effect — setting it live leaves auth at 401). - After the restart,
directoryIdreads back asnull(→ per your reference, that falls back to the internal directory, which is empty → all valid OIDC bearer tokens rejected with401 "You have to authenticate first."). - This repeats on every restart for ~10–20 min after first boot (8 automated set+restart cycles over ~8.5 min — all reverted to null).
- After that window, the exact same set+restart persists, OIDC auth returns JMAP 200, and stays stable across all later restarts — it even survives the IdP being stopped entirely afterwards.
What we’ve ruled out (via live JMAP probing)
- Not directory-id churn — the OIDC
x:Directoryid is stable across all restarts; onlyx:Authentication.directoryIdclears. - Not the payload —
directoryIdalone, or withdefaultUserRoleIds, both persist fine once settled. - Not “a failed build clears it” — on a settled node we stopped the IdP (discovery → 502), set
directoryId, restarted → it stayed set. A directory that merely fails discovery does not lose the binding. - Not network reachability — during the failing window,
.well-known/openid-configurationreturns 200 both externally and from inside the Stalwart container (~0.5s, validissuer+jwks_uri). - Only reproducible on a genuine cold first boot; not reproducible on a settled node by any restart / IdP-down combination.
Working hypothesis: the OIDC directory must complete a first successful build (discovery + JWKS) with the IdP fully ready, and until then the default-directory reference is dropped on each restart — but we can’t confirm without visibility into the build state.
Questions
- What clears
x:Authentication.directoryIdon restart? Is the binding dropped when the referenced OIDC directory hasn’t yet successfully built/validated against the IdP at startup? - Is there a deterministic readiness signal — an API field on
x:Directory, ahealthzprobe, or a specific log event — indicating the OIDC directory has successfully built (discovery + JWKS), so provisioning can wait before binding it? - Do repeated restarts during IdP warm-up hurt? Does a restart abort an in-progress async directory build, so frequent restarts prevent the first successful build from completing?
- Is there negative caching of a failed discovery/JWKS fetch (and a TTL)? Any supported way to force a refresh without a restart?
- What is the supported way to provision OIDC as the default directory on first boot so the binding is reliable (ordering, readiness wait, or a mechanism other than setting
x:Authentication.directoryId)?
Group 2 — web admin / /account self-service login against an external IdP
Goal: users open /account/ to mint app passwords for native clients (the documented workaround).
Symptom
Opening https://<mail-host>/account/ redirects to the IdP with client_id=stalwart-webui (PKCE/S256, no secret, redirect_uri=.../account/oauth/callback). We had to create an IdP client with exactly that client_id for the authorize step to succeed. The user authenticates (password + 2FA) and the IdP reports success — but Stalwart then returns HTTP 401 + WWW-Authenticate: Basic and establishes no session.
The stalwart-webui token doesn’t match what the single default directory requires:
| Directory requires | stalwart-webui token carries |
|
|---|---|---|
| issuer | https://<idp>/application/o/bulwark/ |
https://<idp>/ (IdP global issuer) |
| audience | bulwark |
stalwart-webui |
i.e. the interactive web-UI login appears to be validated against the same single directory that validates IMAP/JMAP, which is pinned to bulwark.
Questions
- Is the web-UI OAuth
client_idconfigurable, and if so which setting key? (We provision by DB via JMAP;config.jsonis only the RocksDb pointer — we can’t find wherestalwart-webuiis defined.) Or isstalwart-webuia fixed built-in? - Which directory / issuer / audience validates the interactive web-UI login token — always
x:Authentication.directoryId, or can the web-UI login be bound to a specific directory? - For a setup where IMAP/JMAP validation is pinned to one audience (
bulwark), what is the supported way to also accept the web-UI login — (A) make the web UI reuse thebulwarkclient, or (B) add a second@type: Oidcdirectory matchingstalwart-webui(iss=.../o/stalwart-webui/,aud=stalwart-webui) for the web-UI login whilebulwarkkeeps serving IMAP/JMAP? Is either supported/recommended?
Group 3 — do app passwords work when the only directory is @type: Oidc?
The AppPassword docs say the secret is stored “on the account as one of its secrets” ($app$name$password). But the Directory reference object shows the Oidc type has no store field — unlike Sql (store: “Storage backend where accounts and groups are stored”) and Ldap (attrSecret). So an OIDC directory appears to have no writable backing store for that secret. We also can’t yet mint one to test, because we can’t reach /account (Group 2), and the docs say admins can’t create app passwords for users — only the user via the portal.
What we observe today: OIDC principals materialise as x:Account with an empty credentials {} map; x:Authentication.maxAppPasswords: 5 is set; IMAP/SMTP advertise AUTH=PLAIN/AUTH PLAIN LOGIN; a PLAIN attempt for an OIDC principal is processed (wrong password → clean NO [AUTHENTICATIONFAILED], not “mechanism unsupported”).
Questions
- Do app passwords authenticate IMAP/SMTP
AUTH PLAINfor a principal whose authoritative directory is@type: Oidc? If yes — where is the$app$secret persisted (the OIDC directory has nostore; does it land on an internal data-store principal instead?), and is any additional directory/config required (e.g. an internal fallback directory) for app-password validation to be consulted alongside OIDC? - If app passwords don’t work with an OIDC-only directory, what is the supported way to give Apple Mail / Thunderbird / K-9 users a password credential while OIDC remains the IMAP/JMAP
OAUTHBEARERauthority?
Group 4 — minor
login_hint: Stalwart sends the mailbox address as the OIDClogin_hint(+prompt=login). Our IdP keys users by a username that differs from the mailbox, so the hint pre-fills a non-matching identity. Canlogin_hintbe omitted or customised?
I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.
on