Skip to content

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).

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.

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.

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 usernameDomain appended, when that field is set, so that john becomes [email protected].
  • Without usernameDomain, the email claim 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.

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.

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+jwt or 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 nonce or at_hash claim, or if it contains neither a scope nor a client claim. Enabling allowIdTokenClaims lifts this rule. For this check, the client claims are client_id, cid and appid; azp alone does not count.

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.

What a valid token may do depends on its client and its scopes, in three cases:

  • A token whose client claim equals the oauthClientId of 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 is reject unless changed, so such tokens are rejected.

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:

  • none rejects them without contacting the provider.
  • introspection validates 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 with introspectionClientId and introspectionClientSecret, by the method set in introspectionAuthMethod: clientSecretBasic, clientSecretPost, or none when 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.
  • userInfo accepts 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 by unscopedTokens. That setting must then be interoperable or full; with reject, the directory logs a warning.

Only one of these methods is used for a token.

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.

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).

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 oauthClientId is 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.

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 claimUsername claim
  • email, when claimUsername is not email and usernameDomain is not set
  • profile, for claimName
  • groups, for claimGroups

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.

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/callback for the administration interface and https://<host>/account/oauth/callback for 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 acceptedAudiences entry
  • The scopes listed above, and the groups claim if claimGroups is used

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
  • code among the response types
  • authorization_code and refresh_token among the grant types
  • none among the token endpoint authentication methods
  • S256
  • authorization_response_iss_parameter_supported: true
  • The interoperable scopes of the published services: mail always, and calendars, contacts and files when 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.

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:

  • unscopedTokens accepts tokens that carry none of the interoperable scopes. interoperable treats them as carrying the mail, contacts, calendars and files scopes. full grants 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.
  • skipAudienceValidation accepts tokens whose aud is missing or matches neither a resource identifier nor acceptedAudiences. Tokens that the provider issued to any application are then accepted. Listing the provider’s value in acceptedAudiences is not a relaxation, and is preferable.
  • allowIdTokenClaims accepts JWT access tokens that carry nonce or at_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.
  • lenientIssuerMatch ignores a trailing-slash difference between issuerUrl and 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.

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}
}

The server logs the reason for every rejected token. Each message below names the cause and the remedy:

MessageCause and remedy
Failed to decode token ... configured as the default directory under the Authentication objectThe 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 acceptedAudiencesAdd 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 rejectThe 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 validatedConfigure the provider to issue JWT access tokens, or set opaqueTokenValidation.
ID and refresh tokens are not accepted as access tokensThe 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 algorithmThe token is signed with an HMAC algorithm. Configure an asymmetric signing algorithm at the provider.
Unknown key idThe token’s signing key is not in the provider’s key set.
The account's domain is not served by the directory that authenticated itThe token’s account belongs to a domain served by another directory.