Domain based authentication directory

Your question

I’ve been experimenting with setting up an OIDC directory on v0.16 and gotten it to work, but wanted to share some of my experiences and ideas. I’m still not completely sure what is a bug vs expected behavior.

Setup

I have two domains:

  • DomainA - with directory set to the OIDC directory
  • DomainB - with no directory set (thus internal directory)

I set the Authentication Directory to the OIDC directory. This means I can only authenticate using OIDC for all accounts, no longer using internal directory for any accounts.

First issue

If I try to edit an Account in DomainB and change the password I get the error message:

Cannot change credentials for accounts in an external directory.

DomainB is configured to the internal directory, so this seems wrong. Is this a bug, or just a confusing message? The docs say

directoryId points to the Directory in which accounts for the domain are looked up. Leaving the directory unset falls back to the internal directory.

What is the meaning of “looked up” if not for authentication or editing credentials? Is this expected behavior?

Second issue

Accounts in DomainB do not exist in my OIDC IdP so I must rely on app passwords to authenticate these accounts for mailbox use.

However, in order to create app passwords to these accounts I need to enter the self-service portal:

  • sign in to the account - not possible as they don’t exist in IdP and authentication is set to OIDC
  • impersonate the account - doesn’t seem to work when I tried user1@DomainB%user2@DomainA in WebUI, I just get signed in as user2@DomainA

Did I miss any option?

Feature idea/request

What I would love to see and would make a lot of sense to me: instead of setting a single Authentication Directory globally, decide the authentication based on the domain of the account. user@DomainB gets authentication against internal directory, user@DomainA gets auth against OIDC.

The WebUI login flow already seems prepared for this given the initial discovery call that includes the account name - which should return the appropriate document based on the domain. I suppose that authentication through the other protocols would also work given the accounts now use the full email.

For my case, I could have my DomainB in the internal directory, e.g. with a dedicated administrator account or other service accounts, authenticating with Stalwart, and keep my regular users on DomainA using OIDC.

Is this already possible to do with multi-tenant setup?

Let me know if I misunderstood or missed anything that would have alleviated my issues!

Let me research the relevant documentation on directories, OIDC, and multi-tenant authentication in Stalwart.

Good, I have enough information to give a thorough, doc-grounded answer. Let me summarize:

  1. Issue 1 (credential error for DomainB accounts): The domain’s directoryId controls where accounts are looked up (recipient resolution, etc.), but authentication is governed by the global directoryId on the Authentication singleton. Setting the Authentication singleton to the OIDC directory means all accounts authenticate via OIDC globally, regardless of which domain they belong to. This explains the “cannot change credentials for external directory” error even for DomainB accounts - the system sees the global auth as external.

  2. Issue 2 (impersonation): The format is target%impersonator, not impersonator%target. You may have had it reversed.

  3. Feature request (per-domain auth directory): The docs confirm directoryId on the Domain object controls account lookups, but there is no documented mechanism for routing authentication on a per-domain basis to different directories. This appears to not currently be supported.


Good analysis of a real design constraint. Here’s what the docs say about each point:

Issue 1 - “Cannot change credentials” error

The directoryId on a Domain object only controls where accounts for that domain are looked up (recipient resolution, mailbox delivery). It does not affect authentication. Authentication is governed exclusively by the directoryId set on the global Authentication singleton. Since you pointed that at the OIDC directory, the entire server treats all accounts - including those in DomainB - as belonging to an external directory. That is why credential changes fail: from Stalwart’s perspective, authentication is external for everyone. This is expected behavior, not a bug, though the error message is admittedly confusing in a mixed-domain setup.

Issue 2 - Impersonation

The composite login format is <target>%<impersonator> with the impersonator’s password. So to sign in to user1@DomainB as user2@DomainA, you would use user1@DomainB%user2@DomainA as the username and user2@DomainA’s password (not the target’s). The account doing the impersonating also needs the impersonate permission assigned to it. If you had the order or password wrong, that would explain it silently signing you in as user2@DomainA instead.

Once you can impersonate user1@DomainB, an admin can also create app passwords for any account directly via the JMAP API (x:AppPassword/set) authenticated as themselves - see the AppPassword reference, which requires the sysAppPasswordCreate permission and can target any account ID.

Feature request - per-domain authentication directory

Based on the current documentation, there is no supported mechanism to route authentication to different directories based on account domain. The directoryId on a Domain is documented purely for account lookup, and the Authentication singleton has a single global directoryId. Your proposed approach (matching the OIDC discovery request domain to choose the auth backend) is not currently a documented capability.

A practical workaround for your use case: keep the global Authentication directory as internal (unset), and put your OIDC-backed accounts in DomainA while your service/admin accounts in DomainB use internal credentials with app passwords. The OIDC directory on DomainA would then handle lookups for that domain’s accounts while authentication falls back globally to internal. This won’t give you IdP-delegated login for DomainA users via IMAP/SMTP though - that limitation is inherent to how the global auth setting works.

Relevant docs: Directory overview, Domain configuration, Impersonation, App Passwords

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong. If you found it useful, mention @helpbot in any reply to ask follow-up questions and the bot will respond again.

I did get it right. In the admin webUI with OIDC enabled I can type foobarbaz%user@DomainA and it doesn’t seem to care for the target part at all - I always just get signed in as the impersonator.

Per-domain Domain.directoryId routing is wired up but it’s an Enterprise feature: in OSS, both return the global default. So the only authentication directory honoured on OSS is the one set on the Authentication singleton.

That’s also why the “Cannot change credentials for accounts in an external directory” error fires on DomainB even though the Domain object has no directoryId: the is_external_directory check resolves through the same function and ends up at the global OIDC directory.

For your two-domain split on OSS: keep global Auth on internal (so DomainB password changes work), and either pre-provision DomainA OIDC accounts or issue them app passwords. If you want the per-domain dispatch you actually described, Enterprise has it.

This is all on Enterprise. I see the “Stalwart Enterprise Edition license key is valid” log message on startup. And I haven’t noticed any limitations in the web UI.

What I’m seeing is the OSS behavior: with no Authentication.directoryId set only internal authentication works, with Authentication.directoryId = oidc only OIDC works.

Not sure how to troubleshoot this further, do you have any recommendation or information that I could gather that would help?

Some questions that could help me understand this further:

  • Is this domain-based vs global authentication difference visible in the UI? Or UI different between Enterprise and OSS?
  • For domain-based auth to apply - should I set the global Authentication Directory or not? (Or does it not matter?)
  • And, just to make sure, this dual auth is supposed to work with the admin web UI? I assume it would mean the discovery endpoint needs to return different results for users in the two domains, so seems like a useful quick test?

Having read the code I’m starting to think this is an issue rooted in the Enterprise feature enablement…

@helpbot what’s the best way to get to the bottom of whether the authentication uses the OSS or Enterprise path? Or is there a different mechanisms we could check to verify Enterprise is enabled?

This is exactly what I needed. The source code makes the situation crystal clear. Let me also check how the cache is populated to understand how id_directory gets set.

@helpbot looks like your answer was incomplete, can you answer again?

@stalwart I think there’s a bug in get_directory_for_domain that is used by the discovery endpoint. In Claude’s words:

In crates/common/src/auth/authentication.rs, there are two functions that resolve the directory for a domain:

get_directory_for_domain (line 506, async — used by the /api/discover/ endpoint):

.and_then(|domain| self.core.storage.directories.get(&domain.id))
//                                                      ^^^^^^^^^
//                                          domain's own document ID

get_directory_for_cached_domain (line 527, sync — used by actual authentication):

domain.id_directory
    .and_then(|domain_id| self.core.storage.directories.get(&domain_id))
//                                                          ^^^^^^^^^^
//                                          the directory assigned to the domain

DomainCache has both fields (mod.rs:60-61):

  • id: u32 — the domain’s own document ID in the store
  • id_directory: Option<u32> — the directory ID assigned to this domain

Line 519 uses domain.id instead of domain.id_directory. The domain’s document ID won’t match any key in the directories HashMap, so the lookup returns None, and it falls back to get_default_directory() — which is the global Authentication.directoryId. This is why the discover endpoint returns the same document for both domains.

The actual authentication uses the cached variant which should work.

Can you confirm?

This has been fixed in v0.16.7 (yet to be released).

BTW, helpbot no longer replies to mentions so the forum stays focused on human responses.

@stalwart I’ve now confirmed that v0.16.7 fixes the discover endpoint issue. Thanks for the swift fix!

However, I’ve hit a follow-up issue with per-domain directory routing for Bearer token validation. When Bulwark Webmail or Stalwart Web UI authenticates a user via Kanidm (OIDC) and presents the resulting access token to /jmap/session, Stalwart seems to fall back to the default directory instead of using the per-domain OIDC directory.

The cause is in extract_jwt_domain() (crates/common/src/auth/authentication.rs:545). It tries to extract the domain from email, preferred_username, or upn claims in the access token. Kanidm’s access tokens follow RFC 9068 and don’t include any of these. Tokens contain iss, sub (a UUID), etc. but no user email. Since extract_jwt_domain() returns None, the code falls through to the default directory and the per-domain OIDC directory is never tried.

A possible fix would be to match the JWT’s iss claim against configured OIDC directories to find the right one?