Microsoft 365 user sync: import, roles, profile fields, deprovisioning #1

Open
friloo wants to merge 6 commits from feature/user-sync into claude/modest-hypatia-2gfg7m
Owner

Summary

Adds an optional Microsoft 365 user sync (new "User sync" tab) and fixes a 1.0.0 bug in certificate storage.

User sync

  • Imports Microsoft 365 / Entra ID users as WordPress accounts: whole tenant or the (nested) members of selected groups, guests optional, domain allow-list respected; existing accounts are linked by e-mail address.
  • Roles: selectable default role plus a Graph-searchable group → role mapping, either in addition to or instead of the default role (first match wins, reorderable). Roles of pre-existing accounts are only managed when explicitly enabled.
  • Profile fields: selectable attributes (display/first/last name, job title, department, company, office, employee ID, phones, address, language) plus the profile photo, which replaces the Gravatar (stored in uploads/m365-login-avatars/).
  • Deprovisioning: disabled / deleted in Microsoft 365 / removed from sync groups → nothing, deactivate or delete (with content reassignment). Accounts deactivated by the sync are reactivated automatically. Deactivated accounts cannot sign in at all (Microsoft, password, application passwords) and lose all sessions.
  • Safeguards: dry run, safety stop above 20 % (min. 5) deprovisioning per run, abort on any Graph error before anything is deactivated, "deleted" only on a 404 for the object ID, protected pre-existing admins and own account, run lock.
  • Runs manually (dry run / sync now), via WP-Cron (hourly / twice daily / daily) or wp m365-login sync [--dry-run].
  • Users screen: "Microsoft 365" column with deactivate / reactivate row actions; read-only Microsoft 365 section on the profile screen.
  • Graph client: paging, retry on 429/503/504, user / transitive group member / photo endpoints. Group picker refactored to be reusable.
  • Version 1.1.0, German translations (du + Sie), README, readme.txt (External services updated), CHANGELOG, security audit addendum.

Fix (separate commit): store_certificate() / remove_certificate() went through the form sanitiser in the admin, so "Generate certificate" did not keep the new certificate and double-encrypted an existing client secret.

Required Graph application permissions for the sync: User.Read.All, plus GroupMember.Read.All when groups are used.

Testing

  • End-to-end tests in a real WordPress 7.1 (SQLite) install against a simulated Microsoft Graph: dry run, paging, create/link, UPN fallback, guests, roles (add + replace, removal on group leave), profile fields, photo download/avatar/cleanup, idempotency, deactivate + session drop + login block, reactivation, manual deactivation kept, delete with reassignment, delete without reassign user → deactivate, protected admin, Graph failure, safety stop, scope groups, settings sanitising, cron scheduling — all passing, no PHP notices.
  • Settings round-trip through the registered sanitiser (reproduced and verified the certificate bug fix).
  • Admin page rendered without errors; group picker / role mapping / sync buttons exercised in jsdom.
  • PHPCS (WPCS + PHPCompatibility 7.4+): no new findings apart from the section-divider comment style already used in auth.php.
  • Not done: visual check in a real browser (no Chromium available on the host) and a run against a real tenant.

🤖 Generated with Claude Code

## Summary Adds an optional **Microsoft 365 user sync** (new "User sync" tab) and fixes a 1.0.0 bug in certificate storage. **User sync** - Imports Microsoft 365 / Entra ID users as WordPress accounts: whole tenant or the (nested) members of selected groups, guests optional, domain allow-list respected; existing accounts are linked by e-mail address. - **Roles:** selectable default role plus a Graph-searchable group → role mapping, either *in addition to* or *instead of* the default role (first match wins, reorderable). Roles of pre-existing accounts are only managed when explicitly enabled. - **Profile fields:** selectable attributes (display/first/last name, job title, department, company, office, employee ID, phones, address, language) plus the profile photo, which replaces the Gravatar (stored in `uploads/m365-login-avatars/`). - **Deprovisioning:** disabled / deleted in Microsoft 365 / removed from sync groups → nothing, deactivate or delete (with content reassignment). Accounts deactivated by the sync are reactivated automatically. Deactivated accounts cannot sign in at all (Microsoft, password, application passwords) and lose all sessions. - **Safeguards:** dry run, safety stop above 20 % (min. 5) deprovisioning per run, abort on any Graph error before anything is deactivated, "deleted" only on a 404 for the object ID, protected pre-existing admins and own account, run lock. - Runs manually (dry run / sync now), via WP-Cron (hourly / twice daily / daily) or `wp m365-login sync [--dry-run]`. - Users screen: "Microsoft 365" column with deactivate / reactivate row actions; read-only Microsoft 365 section on the profile screen. - Graph client: paging, retry on 429/503/504, user / transitive group member / photo endpoints. Group picker refactored to be reusable. - Version 1.1.0, German translations (du + Sie), README, readme.txt (External services updated), CHANGELOG, security audit addendum. **Fix (separate commit):** `store_certificate()` / `remove_certificate()` went through the form sanitiser in the admin, so "Generate certificate" did not keep the new certificate and double-encrypted an existing client secret. Required Graph application permissions for the sync: `User.Read.All`, plus `GroupMember.Read.All` when groups are used. ## Testing - End-to-end tests in a real WordPress 7.1 (SQLite) install against a simulated Microsoft Graph: dry run, paging, create/link, UPN fallback, guests, roles (add + replace, removal on group leave), profile fields, photo download/avatar/cleanup, idempotency, deactivate + session drop + login block, reactivation, manual deactivation kept, delete with reassignment, delete without reassign user → deactivate, protected admin, Graph failure, safety stop, scope groups, settings sanitising, cron scheduling — all passing, no PHP notices. - Settings round-trip through the registered sanitiser (reproduced and verified the certificate bug fix). - Admin page rendered without errors; group picker / role mapping / sync buttons exercised in jsdom. - PHPCS (WPCS + PHPCompatibility 7.4+): no new findings apart from the section-divider comment style already used in `auth.php`. - Not done: visual check in a real browser (no Chromium available on the host) and a run against a real tenant. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
friloo added 2 commits 2026-09-23 16:34:50 +00:00
store_certificate() and remove_certificate() wrote the option with
update_option(), which runs the registered sanitize() callback in the
admin (including admin-ajax). sanitize() expects raw form input, so it
restored the previous certificate fields and encrypted the stored client
secret a second time: "Generate certificate" did not keep the new
certificate and broke an existing client secret.

Internal writes now bypass the form sanitiser.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Add Microsoft 365 user sync with roles, profile fields and deprovisioning
Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run
4edf20bc45
New "User sync" tab that imports Microsoft 365 / Entra ID users as
WordPress accounts and keeps them up to date:

- Scope: whole tenant or the (nested) members of selected groups,
  guests optional, e-mail domain allow-list respected. Existing accounts
  are linked by e-mail address.
- Roles: selectable default role plus a group -> role mapping (in
  addition to or instead of the default role, first match wins).
  Roles of pre-existing accounts are only managed on request.
- Profile: selectable Graph attributes (names, job title, department,
  phones, address, language, ...) and the profile photo as avatar.
- Deprovisioning: accounts disabled or deleted in Microsoft 365 (or
  removed from the sync groups) are deactivated or deleted; accounts
  deactivated by the sync are reactivated automatically. Deactivated
  accounts lose every sign-in path and all sessions.
- Safeguards: dry run, safety stop above 20 % (min. 5) deprovisioning,
  abort on any Graph error, "deleted" only on a 404 for the object ID,
  protected pre-existing administrators and own account, content
  reassignment required for deletion, run lock.
- Runs manually, via WP-Cron or `wp m365-login sync [--dry-run]`.
- Users screen column with deactivate/reactivate row actions and a
  read-only Microsoft 365 section on the profile screen.

The Graph client gains paging, retry on throttling and user, group
member and photo endpoints. The group picker is now reusable.
Version 1.1.0, German translations (du/Sie), docs and audit addendum.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
friloo added 1 commit 2026-09-23 16:40:47 +00:00
Keep profile photos and fields in line with Microsoft 365 on every run
Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run
9f77e9027f
- Photo versions are compared on every sync run via Graph $batch
  (20 users per request); only changed photos are downloaded, the old
  file is deleted and the avatar URL changes. Photos deleted in
  Microsoft 365 are deleted in WordPress. Graph errors never delete a
  photo. Download limit per run (500) with deferral to the next run.
- Switching the photo sync off removes all stored photos; deselected
  m365_* profile fields are removed from the profiles.
- A user's photo is deleted together with the user (delete_user hook).
- Dry run reports photo changes without downloading.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
friloo added 2 commits 2026-09-23 16:43:13 +00:00
701e85a removed M365_Login_Auth::log() while it is still called on every
failure path of the callback (token exchange, token verification,
object ID mismatch, group checks). Those sign-ins ended in a PHP fatal
error instead of the error message on the login page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Add excluded Entra groups for the Microsoft sign-in
Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run
791f43a80b
New "Excluded Entra groups" card on the Security tab. Members of these
groups (nested memberships count) can never sign in with Microsoft, even
if they are in an allowed group.

A hit in the ID token's groups claim refuses immediately. Otherwise the
plugin always asks Microsoft Graph (checkMemberGroups), because a groups
claim can be filtered in the app registration and cannot prove
non-membership. Graph errors refuse the sign-in (fail closed).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
friloo added 1 commit 2026-09-23 17:10:31 +00:00
Fix the findings of a full second security audit
Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run
850f0dcd54
Four-part audit (OIDC/JWT/crypto, user sync, admin UI, login bypasses)
with dynamic PoCs against a real WordPress install; every fix is covered
by a regression test. Report: docs/security-audit.md, section 6.

Critical/High
- Multisite: settings, AJAX actions and certificate download require
  manage_network_options (site admins could sign in as super admin).
- Privileged accounts are only linked (sync and first sign-in) via a
  matching UPN of a member account, never via the settable mail
  attribute; the sync never changes their e-mail address; e-mail change
  notifications stay on.
- Button-only mode exempts by credential (application passwords, WP-CLI)
  instead of request context, closing bypasses through xmlrpc.php and
  REST login handlers; API requests never receive login cookies.
- Multi-tenant mode refuses guest/external identities.

Medium/Low
- Same message for right and wrong passwords; button-only no longer
  switches off when the connection breaks; server-side fallback cookie
  expiry; correct fallback key beats IP lockouts; right-most proxy hop;
  higher start limit; one object ID per account.
- Deactivation sets a random password, revokes application passwords and
  removes the role (restored on reactivation); disabled people are
  deactivated even when their mail vanished; duplicate bindings handled.
- Sync: abort on empty directory answer, no deprovisioning right after a
  tenant change, atomic run lock, strict photo path validation.
- Certificates: key bundles refused, clean re-exported certificate.
- Array-safe sanitising, encoded redirect_to, per-action nonces, escaped
  role lists, no Graph sleeps during sign-in, warnings for public groups,
  multi-tenant group rules and missing salts, uninstall clears the token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feature/user-sync:feature/user-sync
git checkout feature/user-sync

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git checkout claude/modest-hypatia-2gfg7m
git merge --no-ff feature/user-sync
git checkout feature/user-sync
git rebase claude/modest-hypatia-2gfg7m
git checkout claude/modest-hypatia-2gfg7m
git merge --ff-only feature/user-sync
git checkout feature/user-sync
git rebase claude/modest-hypatia-2gfg7m
git checkout claude/modest-hypatia-2gfg7m
git merge --no-ff feature/user-sync
git checkout claude/modest-hypatia-2gfg7m
git merge --squash feature/user-sync
git checkout claude/modest-hypatia-2gfg7m
git merge --ff-only feature/user-sync
git checkout claude/modest-hypatia-2gfg7m
git merge feature/user-sync
git push origin claude/modest-hypatia-2gfg7m
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: friloo/wp-m365-login#1
No description provided.