Issue Description
Stalwart is used as the OpenID Connect provider for OpenCloud (OAuth client “web”, authorization
code flow with PKCE, scopes “openid profile email”). OpenCloud uses the userinfo endpoint to
identify the user.
To force a password change on first login, new accounts get a password credential with
“expiresAt” in the past (the Authentication setting passwordDefaultExpiry is documented in the
schema as: “after which the user will be required to change their password”).
This works for IMAP, JMAP mail and the account portal: the session is downgraded
(“Main credential expired, downgrading permissions”), IMAP is refused, JMAP mail returns
“forbidden”, and the account portal only shows the password page. After the password is
changed, everything works again.
However, signing in to a third-party OAuth/OIDC client with the expired password succeeds:
the authorization code and tokens are issued, and /auth/userinfo returns the full identity
claims. The relying party (OpenCloud) therefore lets the user in normally, and the user is
never asked to change the password.
Expected Behavior
While the main password credential is expired, the user should not be able to complete an
OAuth/OIDC sign-in to a third-party client with it. Either
- the login page tells the user that the password has expired and offers to change it
(as the account portal effectively does), or - no authorization code / tokens are issued to clients other than the Stalwart web UI, or
- /auth/userinfo does not return identity claims for a downgraded session.
Actual Behavior
With credentials/0/expiresAt = “2026-10-01T00:00:00Z” (in the past):
- IMAP LOGIN → refused (security.unauthorized “imapAuthenticate”)
- JMAP Mailbox/get (Basic auth) → “forbidden”
- Account portal /account → only “Password” page is shown (expected)
- OAuth sign-in, client “web”:
/login (password) → accepted
/auth/token → 200, access + refresh token issued
/auth/userinfo → 200 {“sub”:“8”,“name”:“…”,“preferred_username”:
“user@”,“email”:“user@”,
“email_verified”:true}
→ the user is signed in to OpenCloud with full access; no password change is requested. - JMAP with that OAuth access token → session 200, but no mail account in primaryAccounts
(so the token itself is downgraded, but the identity is still released to the client)
Deviation: the forced password change can be bypassed through any OIDC relying party that
only needs the identity claims.
Reproduction Steps
- Register an OAuth client (e.g. clientId “web”, public client, redirect URI of the relying
party), scopes openid profile email. - Create a user with a password credential whose expiresAt is in the past, e.g.
stalwart-cli update Account --json ‘{“credentials/0/expiresAt”:“2026-10-01T00:00:00Z”}’ - IMAP login with that password → refused (as expected).
- Start the authorization code flow of the client, sign in on the Stalwart login page with the
same password → the code is issued, the token request succeeds, GET /auth/userinfo with the
access token returns the claims. - Set expiresAt to null → all access works normally.
Relevant Log Output
INFO Authentication successful (auth.success) listenerId = “http”, localPort = 8080, remoteIp = 127.0.0.1, accountName = “user@”, accountId = 8
(no warning or error for the OAuth sign-in, the token request or userinfo)
For comparison, IMAP with the same expired password:
INFO Authentication successful (auth.success) listenerId = “imaps”, localPort = 993, accountName = “user@”, accountId = 8
INFO Unauthorized access (security.unauthorized) details = “imapAuthenticate”, accountId = 8
Stalwart Version
v0.16.x
Installation Method
Docker
Database Backend
RocksDB
Blob Storage
RocksDB
Search Engine
Internal
Directory Backend
Internal
Additional Context
Relying party: OpenCloud 7.2.4 (OIDC, PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD=none, identity from
/auth/userinfo). Reproduced with a fresh account created with an expired password and with an
existing account whose password was set to expire. Account portal and password change via the
portal work as expected.
I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.
on
I have searched this support forum (open and closed topics) and confirm this is not a duplicate.
on
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