OIDC directory sync clears locally-managed Group membership when no group claim is configured

Issue Description

Summary

synchronize_account() in crates/common/src/cache/directory.rs unconditionally overwrites an account’s member_group_ids to match the directory’s asserted account.groups on every successful authentication. When the directory doesn’t assert any group membership at all (e.g. an OIDC provider configured without a group claim), account.groups is always an empty Vec, so every successful login silently clears any Group membership the account has – including memberships that were set independently of the directory (e.g. a native Stalwart Group used for shared-mailbox/calendar/address-book access).

Reproduction

  1. Configure an OpenId directory without claim_groups set.
  2. Create a native Stalwart Group account and add a User account to it via member_group_ids (either through the registry API directly, or via any other non-directory-driven path).
  3. Authenticate that user via the OIDC directory (a Bearer token that validates successfully).
  4. Observe: member_group_ids on the user account is now empty. The group’s shared mailbox/calendar/etc. is no longer visible to that user.

This reproduces on every successful login, not just failed ones – in practice it showed up as a background webapp session-refresh (any periodic Bearer-authenticated request) repeatedly clearing group membership every ~30s.

Root cause

// crates/common/src/cache/directory.rs, synchronize_account(), existing-account path
let mut member_group_ids = Vec::with_capacity(account.groups.len());
for email in account.groups {
    member_group_ids.push(/* ... resolve each asserted group to an id ... */);
}
if updated_account.member_group_ids.len() != member_group_ids.len()
    || !updated_account.member_group_ids.iter().all(|id| member_group_ids.contains(id))
{
    updated_account.member_group_ids = member_group_ids.into();
    has_changes = true;
}

account.groups comes from the directory backend’s claim resolution – for OIDC (crates/directory/src/backend/oidc/lookup.rs, build_account()), it’s self.config.claim_groups.as_ref().and_then(...).unwrap_or_default(), which is always an empty Vec when claim_groups isn’t configured. The sync above doesn’t distinguish “the directory asserts zero groups” from “the directory doesn’t manage groups at all” – it treats both identically and destructively overwrites either way.

Suggested fix

Only apply the sync when the directory actually asserts at least one group:

if !member_group_ids.is_empty()
    && (updated_account.member_group_ids.len() != member_group_ids.len()
        || !updated_account.member_group_ids.iter().all(|id| member_group_ids.contains(id)))
{
    updated_account.member_group_ids = member_group_ids.into();
    has_changes = true;
}

An empty assertion is treated as “the directory has no opinion about group membership,” not “clear everything.” Directories that DO assert groups (a properly configured LDAP/SQL/OIDC group-claim setup) keep the exact same two-way sync behavior as before – this only changes what happens when the directory returns zero groups for an account that has locally-managed group memberships.

I’ve verified this fix against a live 0.16.12 deployment (compiles clean, cargo check -p common passes with no new warnings, resolves the reproduction case end-to-end) and against current main (same code path, same fix applies cleanly). Diff for reference: fix(directory): don't clear locally-managed group membership on empty… · caseyandgina/stalwart@f8a93c8 · GitHub

Stalwart Version

v0.16.x

Installation Method

Binary (FreeBSD)

Database Backend

PostgreSQL

Blob Storage

S3-compatible

Search Engine

PostgreSQL

Directory Backend

OIDC

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

Confirmed against current source. The relevant code in crates/common/src/cache/directory.rs (synchronize_account) does exactly what you describe: it builds member_group_ids solely from account.groups returned by the directory backend, then unconditionally overwrites updated_account.member_group_ids whenever that set differs from what’s currently stored, with no distinction between “directory asserts zero groups” and “directory doesn’t manage groups at all.” For the OIDC backend, account.groups is indeed always empty when claim_groups isn’t configured, so this reproduces your scenario: every successful auth (including background token refreshes) collapses locally-managed group membership to empty.

Your suggested fix (skip the overwrite when member_group_ids is empty, i.e. treat an empty assertion as “no opinion” rather than “clear all”) is consistent with how the sync is invoked elsewhere and would preserve two-way sync behavior for directories that do assert groups (LDAP/SQL/OIDC with a group claim configured) while stopping the destructive clearing for directories that don’t assert any.

This is a genuine bug in the current codebase, not something addressed by existing docs or configuration. A human/staff member will need to review and merge a fix; I can’t take further action on the repository 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 v0.16.13 which will be released this week.

This issue still seems to happen on Stalwart 0.16.15, using an OIDC directory with a null claimGroups.

This was a separate issue. Fixed in v0.16.18 (yet to be released).