Sign-in and sync only used the first address (the mail attribute). A
WordPress account that carries the user principal name while the
Microsoft mail differs was not found at sign-in ("no WordPress account")
and the sync created a duplicate account for it.
Both now try the mail address and then the user principal name (UPN
only for members, only with the UPN fallback on, and only within the
e-mail domain allow-list). Privileged accounts keep the stricter rule;
a matching UPN is sufficient there as well.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Privileged accounts are never linked through the settable mail
attribute. Two new ways make that workable when UPN and e-mail differ:
- "Link Microsoft account" on the profile screen: the signed-in user
(nonce, same browser via the state cookie, same user at the callback)
signs in with Microsoft once and binds that identity. Existing links
can only be removed by an administrator; an object ID bound elsewhere
is refused.
- "Assigned Microsoft account (UPN)" per user, editable by
administrators, used by sign-in and user sync; with an option to
remove a link.
Sign-in now finds accounts by bound object ID first, then by assigned
UPN, then by e-mail, so linked users sign in whatever their addresses.
Also: third-audit report (docs/security-audit.md section 7), README
section on linking administrator accounts, translations, tests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Privileged accounts: the UPN rule also applies when bind_oid is off or
the account is not bound; privileges are checked on every site of a
multisite user, include code/HTML capabilities (unfiltered_html,
plugins, themes, users) and the remembered roles of deactivated
accounts.
- send_auth_cookies protection also works on WordPress 6.0/6.1.
- Run lock via INSERT IGNORE (atomic), refreshed during long runs; a
shutdown handler reports fatal errors and frees the lock.
- Deprovisioning only for accounts linked in the current tenant (tenant
recorded per account; legacy links not found are left alone).
- Safety stop based on the accounts linked before the run; new safety
stop for removals of administrative roles.
- Disable is idempotent; row-action nonces are bound to the state.
- Profile photos are re-encoded to 240 px (drops EXIF and appended
data), size-limited while downloading, removed on deactivation;
index.php guard in the photo folder.
- Privacy exporter and eraser for the copied data.
- One-time migration hardens accounts deactivated by 1.0 and cleans a
stored certificate bundle; the .cer download is always re-exported.
- Password fields hidden in button-only mode even when the connection
is broken; settings written non-autoloaded; robust user ID queries.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The credential-based exemption used did_action(
'application_password_did_authenticate'), which is request-global. In a
system.multicall, a first boxcar authenticated with any application
password let later boxcars sign in other users with a normal password.
The exemption now applies only to the user the application password
authenticated in the same authenticate pass (reset at priority 0).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
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>
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>
- 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>
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>
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>
Drop the CI badge pointing at a non-existent GitHub repo and darken the
PHP and Plugin Check badge colours for sufficient contrast with white text.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Adds an "M365 Login" menu entry with a Microsoft-style icon and one
submenu per tab (Connection, Button, Security). The active tab is taken
from the URL, kept in the post-save redirect and highlighted in the
submenu. Notices are printed explicitly because top-level pages do not
include options-head.php. Links, docs and screenshots updated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJxAHYdMfKPoN4koRc4Ci2
Certificate (RFC 7523 client assertion) as an alternative to the client
secret: one-click generation of a 3072-bit RSA key pair with a
self-signed certificate, .cer download (public part only), own PEM
upload with validation, expiry display, encrypted key storage. Both the
authorization code exchange and the Graph client-credentials request
use the selected method. Step-by-step guides for secret, certificate
and the app registration are shown in the settings.
Security audit (docs/security-audit.md) and fixes:
- Multi-tenant mode ignored the unverified email claim: matching now
uses the UPN only, or the email claim when xms_edov is true.
- Login starts are rate limited per client (30 per 10 minutes).
- Optional trusted proxy header for client IPs
(M365_LOGIN_CLIENT_IP_HEADER / filter).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJxAHYdMfKPoN4koRc4Ci2
The absolute line behind the divider text needs a known background;
on themed pages the text now sits between two flex lines instead.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJxAHYdMfKPoN4koRc4Ci2
- Add the button (and error messages) to every wp_login_form() form via
login_form_top/login_form_bottom; in button-only mode the password
fields are wrapped and hidden there.
- New template functions m365_login_button() and m365_login_messages();
the shortcode gains divider and messages attributes.
- New setting for the custom login page URL: failed sign-ins, the
fallback link and the logout redirect point there instead of
wp-login.php. Must be a same-site URL.
- Button-only mode now blocks every interactive password sign-in
through the authenticate filter, not only wp-login.php; XML-RPC, REST,
WP-CLI and cron are exempt, plus a filter for trusted exceptions.
- Fallback key accepted on any page (init) instead of login_init only.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJxAHYdMfKPoN4koRc4Ci2
Login page (default and button-only mode) and the three settings tabs,
rendered from the plugin's own markup and CSS inside a mock WordPress
admin shell.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJxAHYdMfKPoN4koRc4Ci2
Groups: a Graph-backed picker on the Security tab (search by name or
paste object IDs) stores allowed group IDs. During sign-in membership is
read from the ID token's groups claim when present, otherwise verified
through Microsoft Graph checkMemberGroups (transitive). Verification
failures refuse the sign-in.
Button-only mode: hides the password form and the lost-password link
and rejects password sign-ins on wp-login.php via the authenticate
filter. A generated, rate-limited fallback key re-enables the form for
30 minutes per browser; M365_LOGIN_DISABLE_BUTTON_ONLY switches the
mode off from wp-config.php.
Also: new German-language README with sequence diagram, settings
reference, troubleshooting and hook examples; readme.txt external
services section now covers Microsoft Graph; translations updated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJxAHYdMfKPoN4koRc4Ci2
rsync is not available everywhere; tar honours the same .distignore
patterns and ships with every CI runner.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJxAHYdMfKPoN4koRc4Ci2
Adds a WordPress plugin that places a customisable "Sign in with
Microsoft" button on wp-login.php and signs existing users in via the
OpenID Connect authorization code flow with PKCE. Users are matched by
e-mail address only; no accounts are created.
Security: single-use state/nonce bound to an HttpOnly cookie, ID token
signature verification against Microsoft's JWKS (RS256 only) with
issuer/audience/tenant/expiry/nonce checks, optional tenant pinning,
account binding to the Microsoft object ID, e-mail domain allow-list,
client secret encrypted at rest (AES-256-GCM).
Admin: settings screen with connection, button and security tabs, live
button preview, colour presets, media-library icon picker, redirect URI
copy button and tenant connectivity test.
Packaging for WordPress.org: readme.txt with External services section,
GPL-2.0 license, uninstall.php, POT + German translations, .distignore,
build script, PHPCS config and CI running Plugin Check.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJxAHYdMfKPoN4koRc4Ci2