OpenID Connect
Organisations that run an identity provider, such as Keycloak, Authentik or Microsoft Entra ID, often want every application to sign in through it. That way the provider’s password policy and multi-factor authentication apply everywhere, and a user has one set of credentials. An OpenID Connect directory brings mail into this arrangement. It delegates the accounts of a domain to the external provider: users sign in at the provider, and clients then present the provider’s access tokens to Stalwart on JMAP, IMAP, SMTP and the other protocols.
In OAuth terms, the provider is the authorization server for such a domain, the system that signs users in and issues tokens. Stalwart acts only as a resource server, the system that accepts those tokens. It validates the tokens that the provider issued and maps them to accounts, but it never brokers sign-ins on behalf of the provider.
This division has a consequence for mail applications. An application that supports OAuth discovers the provider, registers with it and requests its tokens there, without Stalwart’s involvement. For such applications to work, the provider itself must implement the OAuth Profile for Open Public Clients. The directory follows that profile unless configured otherwise, and each relaxation is a setting that must be enabled explicitly.
To connect a provider, create a Directory object of type Oidc (in the WebUI under Settings › Authentication › Directories). Then set its id as directoryId on the Authentication singleton or, in the Enterprise edition, on the domains the directory serves (Per-domain directories).
Account provisioning
Section titled “Account provisioning”OpenID Connect provides no way to look up an account without a token of its owner. Stalwart therefore learns about an account only when the user first signs in, and it creates the local account at that point. Until then, the address does not exist on the server, and mail for it is rejected.
To accept mail from the start, the accounts have to exist before the first sign-in. There are two ways to achieve this. The accounts can be created in advance in the WebUI, with the CLI or through the management API. Alternatively, SCIM provisioning (Enterprise) lets the provider create each account as soon as it exists upstream.
In a domain opened to SCIM, sign-ins create and update no accounts. A sign-in by an account that SCIM has not provisioned fails with Accounts in this domain are provisioned through SCIM, just-in-time provisioning is disabled.
Accounts are never deleted or suspended automatically. A user disabled at the provider can no longer sign in, but the mailbox keeps receiving mail. That continues until an administrator suspends or deletes the account, or until SCIM does. Provisioning models compares the approaches.
Discovery and issuer
Section titled “Discovery and issuer”issuerUrl identifies the provider. When the directory is loaded, the server reads the provider’s metadata from that URL, a JSON document that lists the provider’s endpoints and capabilities. It first tries the RFC 8414 authorization server metadata, served with the content type application/json. If that fails, it falls back to <issuer>/.well-known/openid-configuration. When the first document it reads lacks the UserInfo or end-session endpoint, it fetches the other document as well.
The issuer in the metadata must equal issuerUrl exactly, and a trailing slash counts. Type the issuer as the provider publishes it, or enable lenientIssuerMatch. Templated multi-tenant issuers that contain a {tenantid} placeholder are not supported, so use the tenant-specific issuer URL instead.
Requests to the provider time out after 30 seconds and never follow redirects. Which providers a directory may contact depends on its owner. A directory owned by the server may contact providers on private networks, whereas one owned by a tenant may contact only providers on public addresses. allowInvalidCerts accepts invalid TLS certificates and is meant for testing.
Temporary failures while the server starts are retried for a short time. Three conditions make the directory fail to load, and the error is logged: a discovery failure, an issuer mismatch, and introspection mode with a provider that publishes no introspection endpoint.
Mapping tokens to accounts
Section titled “Mapping tokens to accounts”A token says who signed in through its claims, the named values it carries. Every token must lead to an email address, which becomes the address of the account.
claimUsername names the claim that identifies the account, preferred_username unless changed. The server turns its value into an address in one of three ways:
- A value that is an email address is used as it is.
- A value that is not an address gets the domain in
usernameDomainappended, when that field is set, so thatjohnbecomes[email protected]. - Without
usernameDomain, theemailclaim is used instead.
A token from which no address can be derived is rejected.
Two further claims are optional. claimName names the claim with the display name, and claimGroups the claim with group memberships, such as groups or roles. When neither is set, the server reads no display name or groups from the provider.
Setting these claims has a cost. When a JWT access token lacks one of the configured claims, the server calls the provider’s UserInfo endpoint at every sign-in to obtain it. Set claimName and claimGroups only when the provider includes them in access tokens, or when the extra request is acceptable. Groups named by the provider map to local groups, which are created as needed, and their roles and permissions are assigned on the server.
SCIM takes precedence over these claims. In a domain where SCIM provisioning is enabled, SCIM is authoritative for the display name and group memberships, so claimName and claimGroups are not applied there. claimUsername still identifies the account.
Validating access tokens
Section titled “Validating access tokens”Before a token grants access, the server checks its form, its audience and its scopes. Tokens come in two forms, which are validated differently. JWT access tokens can be validated by the server alone. Opaque tokens need the provider’s help.
JWT access tokens
Section titled “JWT access tokens”A JSON Web Token (JWT) is a token whose claims are readable and signed by the provider. Access tokens in this form are verified offline, against the provider’s published keys. The key set is cached and fetched again when a token names an unknown key, so key rotation at the provider is picked up automatically. HMAC-signed tokens (HS256, HS384, HS512) are always refused.
A token must carry iss and exp, which are checked with 60 seconds of tolerance. The client is read from azp, client_id, cid or appid, and a token that names different clients in these claims is rejected. The scopes are read from scope or scp, and only the four interoperable URNs among them count.
A provider also issues ID tokens and refresh tokens, which are not meant to grant access. The server refuses them when they are presented as access tokens, and it recognises them by how the provider marks them:
- A token that the provider marks as an ID or refresh token is always rejected.
- A token marked as an access token, by a header type of
at+jwtor an equivalent claim, is accepted. - A token that carries neither marking is rejected as a possible ID token in two cases: if it contains a
nonceorat_hashclaim, or if it contains neither a scope nor a client claim. EnablingallowIdTokenClaimslifts this rule. For this check, the client claims areclient_id,cidandappid;azpalone does not count.
Audience
Section titled “Audience”The audience of a token names the services it was issued for. Checking it prevents a token that the provider issued to some other application from being used at Stalwart. The token’s aud claim, a string or an array, must contain one of the server’s resource identifiers or a value listed in acceptedAudiences. The server lists its resource identifiers in protected_resources of its /.well-known/oauth-authorization-server document. A token without aud, or with no matching value, is rejected unless skipAudienceValidation is enabled.
The value that the provider puts in aud is configured at the provider. Depending on the product, it is an audience, API identifier or resource configured for the client, or the client id itself. Add that value to acceptedAudiences.
Access granted
Section titled “Access granted”What a valid token may do depends on its client and its scopes, in three cases:
- A token whose client claim equals the
oauthClientIdof an installed Application, or identifies the Client ID Metadata Document of an installed Application such as the WebUI, receives the account’s full permissions. - A token that carries interoperable scopes is limited to them (Scopes).
- A token that carries none is handled by
unscopedTokens, which isrejectunless changed, so such tokens are rejected.
Opaque tokens
Section titled “Opaque tokens”An opaque token is a string that only its issuer can interpret. Access tokens that are not JWTs are of this kind, so they cannot be verified offline. opaqueTokenValidation decides how they are handled, and is none unless changed:
nonerejects them without contacting the provider.introspectionvalidates each token at the provider’s introspection endpoint (RFC 7662), which must be listed in the provider’s metadata. The server authenticates to the endpoint withintrospectionClientIdandintrospectionClientSecret, by the method set inintrospectionAuthMethod:clientSecretBasic,clientSecretPost, ornonewhen there is no secret. The response must report the token as active, and the audience, client, scope and issuer checks then apply as for JWTs. The claims are read from the response, and a missing required claim fails the sign-in.userInfoaccepts any token that the provider’s UserInfo endpoint accepts. The endpoint reveals neither the audience nor the scopes, so a token that the provider issued to any application authenticates, and its access is always decided byunscopedTokens. That setting must then beinteroperableorfull; withreject, the directory logs a warning.
Only one of these methods is used for a token.
Expiry and domain binding
Section titled “Expiry and domain binding”A token stops working at its exp.
After a token has been validated, the domain of the account must be served by this directory. Otherwise the sign-in fails, and the auth.failed event records the reason The account's domain is not served by the directory that authenticated it. The check matters when several directories serve different domains.
The recovery administrator is not affected by any of this. It always signs in through Stalwart’s own authorization server, also for domains of an OpenID Connect directory.
Signing in to the WebUI
Section titled “Signing in to the WebUI”The WebUI follows the directory too. When the default directory, or the directory of the user’s domain, is an OpenID Connect directory, the WebUI signs in at the provider. It uses the authorization code flow, as a public client with PKCE (S256), and it learns from the server the provider’s endpoints, the client id to use and the scopes to request. The WebUI always sends the access token to the server, never the ID token. That token receives full access through its client claim (see Access granted).
Client id of the WebUI
Section titled “Client id of the WebUI”The provider has to know the WebUI as a client, and the client id the WebUI presents comes from its Application. There are two possibilities:
- When
oauthClientIdis set on the Application, the WebUI uses that static client id. - Otherwise it uses the URL of its Client ID Metadata Document,
https://<host>/<prefix>/oauth-client.json. The provider fetches this document from Stalwart, so the server must then be reachable from the provider over https.
There is no built-in client id. If no static id is set and the provider does not advertise client_id_metadata_document_supported in its metadata, the WebUI shows a configuration error.
Scopes requested
Section titled “Scopes requested”The WebUI requests only the scopes it needs for the configured claims, and each one only when the provider lists it in scopes_supported:
openid- The scope that carries the
claimUsernameclaim email, whenclaimUsernameis notemailandusernameDomainis not setprofile, forclaimNamegroups, forclaimGroups
When the provider publishes no scopes_supported, the standard email and profile scopes are assumed.
The WebUI never requests offline_access or the interoperable scopes. This matters for providers that issue refresh tokens only when offline_access is requested: with such a provider, the WebUI session ends when the access token expires, and the user signs in again.
Provider checklist
Section titled “Provider checklist”The client for the WebUI at the provider needs these settings:
- A public client without a secret, or acceptance of the WebUI’s Client ID Metadata Document URL
- The redirect URI
https://<host>/admin/oauth/callbackfor the administration interface andhttps://<host>/account/oauth/callbackfor the Account Manager, or the equivalent for other prefixes - PKCE with
S256 - An audience in its access tokens that matches a resource identifier of the server or an
acceptedAudiencesentry - The scopes listed above, and the groups claim if
claimGroupsis used
Mail, calendar and contacts clients
Section titled “Mail, calendar and contacts clients”Applications that sign in with OAuth present the provider’s tokens like any other bearer token: through OAUTHBEARER or XOAUTH2 on IMAP, POP3, SMTP and ManageSieve, and through bearer authentication on HTTP (Mail, calendar and contacts clients). For these domains, the server names the provider as the authorization server in three places: in the RFC 7628 error response, in the protected resource metadata and in the WebUI discovery.
PACC is the discovery document from which a mail application learns how to connect to a domain, including whether OAuth is available. Whether the PACC document of a domain advertises OAuth depends on whether the provider conforms to the open public clients profile. The server checks this against the provider’s metadata when the directory loads. The profile requires:
- An https issuer without query, fragment or user information, and with a lowercase host
- Metadata served as
application/json - A registration endpoint, and authorization and token endpoints
codeamong the response typesauthorization_codeandrefresh_tokenamong the grant typesnoneamong the token endpoint authentication methodsS256authorization_response_iss_parameter_supported: true- The interoperable scopes of the published services:
mailalways, andcalendars,contactsandfileswhen CalDAV, CardDAV or WebDAV is enabled
A conforming provider is advertised with its issuer in the PACC document of its domains. For a provider that does not conform, the server logs a warning that lists the missing items:
The identity provider does not conform to OAuth Public, PACC documents for its domains will only advertise password authentication. Missing: ...The PACC document of those domains is still published, together with its DNS record, but it advertises password authentication only. No setting overrides this.
Compatibility settings
Section titled “Compatibility settings”Many providers issue tokens that do not meet every requirement above. The compatibility settings relax the rules for the tokens Stalwart accepts, so that such providers can still be used. The WebUI groups them under “Compatibility (reduces security)”, because each one accepts tokens that the strict rules would refuse. Each must be enabled explicitly, and each has a cost:
unscopedTokensaccepts tokens that carry none of the interoperable scopes.interoperabletreats them as carrying the mail, contacts, calendars and files scopes.fullgrants every permission of the account, including management, which is unsafe unless the audience check is strict. WebUI tokens recognised by their client claim always have full access and do not need this setting.skipAudienceValidationaccepts tokens whoseaudis missing or matches neither a resource identifier noracceptedAudiences. Tokens that the provider issued to any application are then accepted. Listing the provider’s value inacceptedAudiencesis not a relaxation, and is preferable.allowIdTokenClaimsaccepts JWT access tokens that carrynonceorat_hash, as produced by providers whose access tokens are copies of the ID token. An ID token can then be presented as an access token, although tokens that the provider explicitly marks as ID tokens are still rejected.lenientIssuerMatchignores a trailing-slash difference betweenissuerUrland the issuer reported by the provider and found in tokens.
The modes of opaqueTokenValidation involve the same tradeoff: introspection keeps the audience, client and scope checks, whereas userInfo drops them.
These settings only decide which tokens Stalwart accepts. They do not make Stalwart advertise OAuth in the PACC document of a non-conforming provider’s domains. They also do not make the provider offer dynamic client registration or the interoperable scopes.
Example
Section titled “Example”The following directory serves example.org from a provider without Client ID Metadata Document support. A public client mail-webui is registered at the provider for the WebUI. The provider issues JWT access tokens with that client id as audience, a bare user name in preferred_username and groups in a groups claim. For the WebUI to use that client, and for its tokens to be recognised as first party, the WebUI’s Application must also have oauthClientId set to mail-webui:
{ "@type": "Oidc", "description": "Corporate identity provider", "issuerUrl": "https://sso.example.org/realms/corp", "claimUsername": "preferred_username", "usernameDomain": "example.org", "claimGroups": "groups", "acceptedAudiences": {"mail-webui": true}}Troubleshooting
Section titled “Troubleshooting”The server logs the reason for every rejected token. Each message below names the cause and the remedy:
| Message | Cause and remedy |
|---|---|
Failed to decode token ... configured as the default directory under the Authentication object | The token was not issued by Stalwart, and no OpenID Connect directory handles it. Set the directory as directoryId of the Authentication singleton or of the domain. |
Token audience ... matches neither a resource identifier of this server nor acceptedAudiences | Add the token’s aud value to acceptedAudiences. |
Token carries none of the urn:ietf:params:oauth:scope:{mail,contacts,calendars,files} scopes and unscopedTokens is set to reject | The provider does not issue the interoperable scopes. Configure them at the provider, or set unscopedTokens. |
Opaque access tokens are not accepted, only JWT access tokens are validated | Configure the provider to issue JWT access tokens, or set opaqueTokenValidation. |
ID and refresh tokens are not accepted as access tokens | The client sent an ID or refresh token instead of the access token. |
Token carries ID token claims (nonce or at_hash), enable allowIdTokenClaims ... | The provider copies the ID token into the access token. Enable allowIdTokenClaims. |
Token has no scope or client_id claim and may be an ID token ... | The token carries no scope or client claim. Configure the provider to add one, or enable allowIdTokenClaims. |
Unsupported algorithm | The token is signed with an HMAC algorithm. Configure an asymmetric signing algorithm at the provider. |
Unknown key id | The token’s signing key is not in the provider’s key set. |
The account's domain is not served by the directory that authenticated it | The token’s account belongs to a domain served by another directory. |