Let administrators link Microsoft accounts whose UPN differs from mail
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

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>
This commit is contained in:
Friederich Loheide 2026-09-24 03:57:45 +00:00
parent 9b893e42bc
commit 81b3a74ae5
12 changed files with 1583 additions and 995 deletions

View file

@ -21,6 +21,8 @@ class M365_Login_Auth {
const META_OID = '_m365_login_oid';
const META_LAST_LOGIN = '_m365_login_last_login';
const META_TID = '_m365_login_tid'; // Tenant the object ID belongs to.
const META_UPN = '_m365_login_upn'; // Microsoft account (UPN) assigned by an administrator.
const LINK_NONCE = 'm365_login_link';
const JWKS_CACHE_TTL = 12 * HOUR_IN_SECONDS;
const HTTP_TIMEOUT = 15;
@ -316,6 +318,22 @@ class M365_Login_Auth {
return $this->authority() . '/discovery/v2.0/keys';
}
/**
* URL that links the signed-in user's WordPress account to a Microsoft account.
*
* @return string
*/
public function link_url() {
return add_query_arg(
array(
'action' => self::ACTION_START,
'm365_link' => '1',
'_wpnonce' => wp_create_nonce( self::LINK_NONCE ),
),
wp_login_url()
);
}
/**
* URL that starts the Microsoft login.
*
@ -354,6 +372,18 @@ class M365_Login_Auth {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- redirect_to is validated with wp_validate_redirect() before use.
$redirect_to = isset( $_GET['redirect_to'] ) ? wp_validate_redirect( esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ), '' ) : '';
// "Link my Microsoft account" from the profile: the signed-in user proves ownership of the
// WordPress account, the Microsoft sign-in proves ownership of the Microsoft account.
$link_user = 0;
if ( isset( $_GET['m365_link'] ) ) {
$link_nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
if ( ! is_user_logged_in() || ! wp_verify_nonce( $link_nonce, self::LINK_NONCE ) ) {
$this->fail( 'invalid_state' );
}
$link_user = get_current_user_id();
$redirect_to = admin_url( 'profile.php' );
}
$state = M365_Login_JWT::b64url_encode( random_bytes( 32 ) );
$nonce = M365_Login_JWT::b64url_encode( random_bytes( 32 ) );
$code_verifier = M365_Login_JWT::b64url_encode( random_bytes( 64 ) );
@ -369,6 +399,7 @@ class M365_Login_Auth {
'verifier' => $code_verifier,
'cookie' => hash( 'sha256', $cookie_token ),
'redirect_to' => $redirect_to,
'link_user' => $link_user,
'created' => time(),
),
self::STATE_TTL
@ -496,16 +527,29 @@ class M365_Login_Auth {
$this->fail( 'external_identity' );
}
$email = $this->email_from_claims( $claims );
if ( '' === $email ) {
$this->fail( 'no_email' );
$oid = isset( $claims['oid'] ) && is_string( $claims['oid'] ) ? strtolower( $claims['oid'] ) : '';
if ( ! empty( $attempt['link_user'] ) ) {
$this->link_account( (int) $attempt['link_user'], $claims, $oid );
}
if ( ! $this->domain_allowed( $email ) ) {
$email = $this->email_from_claims( $claims );
if ( '' !== $email && ! $this->domain_allowed( $email ) ) {
$this->fail( 'domain_not_allowed' );
}
$user = get_user_by( 'email', $email );
// 1. An account already bound to this Microsoft identity, 2. an account the administrator
// assigned this user principal name to, 3. the e-mail address.
$user = $this->find_bound_user( $oid );
if ( ! $user ) {
$user = $this->find_assigned_user( $claims );
}
if ( ! $user ) {
if ( '' === $email ) {
$this->fail( 'no_email' );
}
$user = get_user_by( 'email', $email );
}
if ( ! $user instanceof WP_User ) {
/** This action is documented in wp-includes/user.php */
do_action( 'wp_login_failed', $email, new WP_Error( 'm365_login_no_user', 'No WordPress user with this e-mail address.' ) );
@ -520,8 +564,6 @@ class M365_Login_Auth {
$this->fail( 'account_disabled' );
}
$oid = isset( $claims['oid'] ) && is_string( $claims['oid'] ) ? strtolower( $claims['oid'] ) : '';
// Entra group restriction.
$group_check = $this->check_groups( $claims, $oid );
if ( true !== $group_check ) {
@ -767,11 +809,155 @@ class M365_Login_Auth {
* @return bool
*/
private function may_claim_privileged( $claims, $user ) {
$upn = ! empty( $claims['preferred_username'] ) && is_string( $claims['preferred_username'] ) ? strtolower( trim( $claims['preferred_username'] ) ) : '';
return '' !== $upn
&& ! $this->settings->is_multi_tenant()
&& ! $this->is_external_identity( $claims )
&& hash_equals( strtolower( $user->user_email ), $upn );
$upn = $this->claimed_upn( $claims );
if ( '' === $upn || $this->settings->is_multi_tenant() ) {
return false;
}
$assigned = strtolower( (string) get_user_meta( $user->ID, self::META_UPN, true ) );
return hash_equals( strtolower( $user->user_email ), $upn ) || ( '' !== $assigned && hash_equals( $assigned, $upn ) );
}
/**
* User principal name of a member account from the token ('' for guests/external identities).
*
* @param array $claims Verified claims.
* @return string
*/
private function claimed_upn( $claims ) {
if ( $this->is_external_identity( $claims ) || empty( $claims['preferred_username'] ) || ! is_string( $claims['preferred_username'] ) ) {
return '';
}
return strtolower( trim( $claims['preferred_username'] ) );
}
/**
* Account bound to a Microsoft object ID (network-wide), if any.
*
* @param string $oid Object ID.
* @return WP_User|null
*/
private function find_bound_user( $oid ) {
if ( ! $this->settings->get( 'bind_oid' ) || ! M365_Login_Settings::is_guid( $oid ) ) {
return null;
}
$ids = get_users(
array(
'meta_key' => self::META_OID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $oid, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'fields' => 'ID',
'number' => 2,
'blog_id' => 0,
)
);
if ( 1 !== count( $ids ) ) {
return null; // None, or ambiguous (bound twice by an older version): fall back to the other rules.
}
$user = get_userdata( (int) $ids[0] );
return $user ? $user : null;
}
/**
* Account whose administrator-assigned Microsoft account matches the token's user principal name.
*
* @param array $claims Verified claims.
* @return WP_User|null
*/
private function find_assigned_user( $claims ) {
$upn = $this->claimed_upn( $claims );
if ( '' === $upn || $this->settings->is_multi_tenant() ) {
return null;
}
$ids = get_users(
array(
'meta_key' => self::META_UPN, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $upn, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'fields' => 'ID',
'number' => 2,
'blog_id' => 0,
)
);
if ( 1 !== count( $ids ) ) {
return null;
}
$user = get_userdata( (int) $ids[0] );
return $user ? $user : null;
}
/**
* Binds the Microsoft identity to the signed-in WordPress user who started "Link my Microsoft account".
*
* @param int $user_id User who started the link.
* @param array $claims Verified claims.
* @param string $oid Object ID.
*/
private function link_account( $user_id, $claims, $oid ) {
// The browser must still be signed in as the user who started the link.
if ( ! $user_id || get_current_user_id() !== $user_id ) {
$this->fail_link( 'link_session' );
}
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
$this->fail_link( 'invalid_token' );
}
if ( M365_Login_Sync::disabled_info( $user_id ) ) {
$this->fail_link( 'account_disabled' );
}
$stored = strtolower( (string) get_user_meta( $user_id, self::META_OID, true ) );
if ( '' !== $stored && ! hash_equals( $stored, $oid ) ) {
$this->fail_link( 'link_other' ); // Unlinking is an administrator decision.
}
$taken = get_users(
array(
'meta_key' => self::META_OID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $oid, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'exclude' => array( $user_id ),
'fields' => 'ID',
'number' => 1,
'blog_id' => 0,
)
);
if ( ! empty( $taken ) ) {
$this->fail_link( 'oid_mismatch' );
}
update_user_meta( $user_id, self::META_OID, $oid );
if ( isset( $claims['tid'] ) && M365_Login_Settings::is_guid( (string) $claims['tid'] ) ) {
update_user_meta( $user_id, self::META_TID, strtolower( (string) $claims['tid'] ) );
}
$this->log( sprintf( 'User #%d linked a Microsoft account from the profile.', $user_id ) );
/**
* Fires after a user linked a Microsoft account from the profile screen.
*
* @param int $user_id User ID.
* @param array $claims Verified claims.
*/
do_action( 'm365_login_account_linked', $user_id, $claims );
wp_safe_redirect( add_query_arg( 'm365_linked', '1', admin_url( 'profile.php' ) ) );
exit;
}
/**
* Ends a failed profile link with a message on the profile screen.
*
* @param string $code Error code.
*/
private function fail_link( $code ) {
$this->clear_state_cookie();
nocache_headers();
wp_safe_redirect( add_query_arg( 'm365_link_error', rawurlencode( $code ), admin_url( 'profile.php' ) ) );
exit;
}
/**
* Translated message for a login/link error code.
*
* @param string $code Code.
* @return string
*/
public function error_message( $code ) {
$messages = $this->error_messages();
return isset( $messages[ $code ] ) ? $messages[ $code ] : $messages['provider_error'];
}
/**
@ -1081,7 +1267,9 @@ class M365_Login_Auth {
'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', 'm365-login' ),
'too_many_attempts' => __( 'Too many sign-in attempts from your connection. Please wait a few minutes and try again.', 'm365-login' ),
'account_disabled' => __( 'This account has been deactivated.', 'm365-login' ),
'privileged_unlinked' => __( 'For security reasons this administrator account can only be linked to a Microsoft account whose user principal name equals the WordPress e-mail address. Please contact an administrator.', 'm365-login' ),
'privileged_unlinked' => __( 'For security reasons this administrator account is not linked automatically. Sign in once with your password and click "Link Microsoft account" on your profile page or ask an administrator to enter your Microsoft account (user principal name) in your WordPress profile.', 'm365-login' ),
'link_session' => __( 'The link could not be completed because you are no longer signed in to WordPress. Please sign in and try again.', 'm365-login' ),
'link_other' => __( 'Your WordPress account is already linked to a different Microsoft account. An administrator can remove the link in your profile.', 'm365-login' ),
'external_identity' => __( 'Guest and external accounts cannot sign in here.', 'm365-login' ),
);
}