Expired password (credential expiresAt) does not stop OAuth/OIDC sign-in to third-party clients

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

  1. Register an OAuth client (e.g. clientId “web”, public client, redirect URI of the relying
    party), scopes openid profile email.
  2. 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”}’
  3. IMAP login with that password → refused (as expected).
  4. 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.
  5. 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

Your analysis matches what the source code shows. The password-expiry downgrade logic lives in AccessToken::assert_is_valid() (crates/common/src/auth/access_token.rs): when the primary credential has expired, the token’s permission scope is downgraded to only Authenticate, AuthenticateWithAlias, SysAccountPasswordGet/Update, and EmailReceive — which is why IMAP, JMAP, and the account portal correctly block access until the password is changed.

However, handle_userinfo_request() in crates/http/src/auth/oauth/openid.rs does not consult the access token’s permission scope at all. It just takes the account_id from the already-validated OAuth access token and unconditionally returns the full Userinfo claims (sub, name, preferred_username, email, email_verified) by looking up the account record directly, with no check for CredentialExpired/downgraded scope. The authorization and token endpoints appear to have the same gap, since the login step itself succeeds and issues a code/token even on a downgraded session.

So this is a real inconsistency: the permission downgrade exists in the access-token layer but isn’t enforced on the OIDC userinfo (and apparently authorization/token) code path. A staff member will need to confirm and address this; flagging it here for follow-up rather than closing it out myself.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

Fixed in v1.0.0 (expected for late October/early November).