wp-m365-login/includes/class-m365-login-auth.php
Friederich Loheide 7749ebff9b
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
Remove the login box frame and hide "Lost your password?" reliably
- No border or shadow around the login box on wp-login.php (form and
  Microsoft block, also in button-only mode); the white area stays.
- Button-only mode: the "Lost your password?" link is removed through
  lost_password_html_link instead of CSS only, the lostpassword,
  retrievepassword, rp and resetpass screens redirect to the login page
  and allow_password_reset refuses resets – all unless the fallback link
  is active.
- The login stylesheet is also loaded when only the form is hidden
  (e.g. broken connection); before, the link and form showed there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 04:30:13 +00:00

1355 lines
49 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* OpenID Connect authorization code flow (with PKCE) against Microsoft Entra ID.
*
* @package M365_Login
*/
defined( 'ABSPATH' ) || exit;
/**
* Handles the login start, the callback and user matching.
*/
class M365_Login_Auth {
const ACTION_START = 'm365_login';
const CALLBACK_PATH = 'm365-login/callback';
const STATE_COOKIE = 'm365_login_state';
const FALLBACK_COOKIE = 'm365_login_fallback';
const FALLBACK_TTL = 30 * MINUTE_IN_SECONDS;
const STATE_TTL = 600; // 10 minutes.
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;
/**
* Settings.
*
* @var M365_Login_Settings
*/
private $settings;
/**
* Graph client.
*
* @var M365_Login_Graph
*/
private $graph;
/**
* User authenticated by an application password in the current authenticate pass (0 = none).
*
* @var int
*/
private $app_password_user = 0;
/**
* Constructor.
*
* @param M365_Login_Settings $settings Settings.
* @param M365_Login_Graph $graph Graph client.
*/
public function __construct( M365_Login_Settings $settings, M365_Login_Graph $graph ) {
$this->settings = $settings;
$this->graph = $graph;
add_action( 'login_form_' . self::ACTION_START, array( $this, 'handle_start' ) );
add_action( 'init', array( $this, 'maybe_handle_callback' ), 5 );
add_filter( 'wp_login_errors', array( $this, 'login_errors' ), 10, 1 );
// Button-only mode (works on wp-login.php and on custom login pages).
add_action( 'init', array( $this, 'maybe_accept_fallback_key' ), 6 );
// Runs after core's username/password handlers (priority 20), which would otherwise overwrite an early WP_Error.
add_filter( 'authenticate', array( $this, 'block_password_login' ), 99, 3 );
// Track application-password sign-ins per authenticate pass (XML-RPC multicall runs several passes per request).
add_filter( 'authenticate', array( $this, 'reset_app_password_user' ), 0 );
add_action( 'application_password_did_authenticate', array( $this, 'remember_app_password_user' ) );
// Button-only mode: no "Lost your password?" link, no reset screen, no reset e-mails.
add_filter( 'lost_password_html_link', array( $this, 'hide_lost_password_link' ), 99 );
add_filter( 'allow_password_reset', array( $this, 'block_password_reset' ), 99 );
foreach ( array( 'lostpassword', 'retrievepassword', 'rp', 'resetpass' ) as $reset_action ) {
add_action( 'login_form_' . $reset_action, array( $this, 'block_reset_screen' ) );
}
// No session cookies from API contexts (XML-RPC, REST) while password sign-in is disabled.
add_filter( 'send_auth_cookies', array( $this, 'block_api_auth_cookies' ), 99, 4 );
// Custom login page: send people back there after logging out.
add_filter( 'logout_redirect', array( $this, 'logout_redirect' ), 10, 3 );
}
/* ------------------------------------------------------------------ */
/* Button-only mode */
/* ------------------------------------------------------------------ */
/**
* Whether the current browser presented the fallback key (cookie set for 30 minutes).
*
* @return bool
*/
public function fallback_active() {
if ( ! $this->settings->button_only() ) {
return true; // Nothing is hidden, the form is always available.
}
$cookie = isset( $_COOKIE[ self::FALLBACK_COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::FALLBACK_COOKIE ] ) ) : '';
$parts = explode( '|', $cookie );
if ( 2 !== count( $parts ) || ! ctype_digit( $parts[0] ) ) {
return false;
}
$issued = (int) $parts[0];
if ( $issued > time() + 60 || time() - $issued > self::FALLBACK_TTL ) {
return false; // Expired on the server side, whatever the browser keeps.
}
return hash_equals( $this->fallback_cookie_value( $issued ), $cookie );
}
/**
* Fallback cookie value for an issue time: "time|HMAC" (the key itself never sits in the cookie).
*
* @param int $issued Issue timestamp.
* @return string
*/
private function fallback_cookie_value( $issued ) {
return $issued . '|' . hash_hmac( 'sha256', 'fallback|' . $issued . '|' . $this->settings->fallback_key(), wp_salt( 'auth' ) );
}
/**
* ?m365_fallback=KEY (on any page) → sets the fallback cookie and reloads the login page without the key in the URL.
*/
public function maybe_accept_fallback_key() {
if ( ! $this->settings->button_only() ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the key itself is the secret.
$given = isset( $_GET['m365_fallback'] ) ? sanitize_text_field( wp_unslash( $_GET['m365_fallback'] ) ) : '';
if ( '' === $given || 'on' === $given ) {
return;
}
// The correct key always works (a shared office IP must not lock the administrator out);
// wrong keys are slowed down per IP. The key has ~139 bits, the limit only reduces log noise.
$ip_key = 'm365_login_fb_' . md5( $this->client_ip() );
if ( ! hash_equals( $this->settings->fallback_key(), $given ) ) {
$attempts = (int) get_transient( $ip_key );
if ( $attempts >= 10 ) {
$this->fail( 'fallback_locked' );
}
set_transient( $ip_key, $attempts + 1, 15 * MINUTE_IN_SECONDS );
$this->fail( 'fallback_invalid' );
}
delete_transient( $ip_key );
$issued = time();
$this->send_cookie( self::FALLBACK_COOKIE, $this->fallback_cookie_value( $issued ), $issued + self::FALLBACK_TTL );
nocache_headers();
wp_safe_redirect( add_query_arg( 'm365_fallback', 'on', $this->settings->login_page_url() ) );
exit;
}
/**
* After logout, return to the custom login page instead of wp-login.php.
*
* @param string $redirect_to Requested redirect.
* @param string $requested_redirect_to Raw requested redirect.
* @param WP_User|WP_Error $user User.
* @return string
*/
public function logout_redirect( $redirect_to, $requested_redirect_to, $user ) {
$custom = $this->settings->custom_login_url();
if ( '' === $custom || '' !== (string) $requested_redirect_to ) {
return $redirect_to;
}
return add_query_arg( 'loggedout', 'true', $custom );
}
/**
* Refuses username/password sign-in on wp-login.php while button-only mode is active.
*
* @param null|WP_User|WP_Error $user Result so far.
* @param string $username Username.
* @param string $password Password.
* @return null|WP_User|WP_Error
*/
public function block_password_login( $user, $username, $password ) {
if ( ! $this->settings->button_only() || $this->fallback_active() ) {
return $user;
}
if ( '' === (string) $username && '' === (string) $password ) {
return $user; // Initial form render or cookie auth, no password attempt.
}
// Exempt by credential, not by request context: application passwords (XML-RPC, REST) and
// WP-CLI keep working. A normal password is refused everywhere also in forms of other
// plugins that happen to run inside xmlrpc.php or a REST request.
// The application password must have authenticated exactly this user in this pass did_action()
// is request-global and would let a later multicall boxcar through with a normal password.
if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( $user instanceof WP_User && $this->app_password_user && $this->app_password_user === $user->ID ) ) {
return $user;
}
/**
* Allows exempting a password sign-in from button-only mode (e.g. a trusted membership plugin).
*
* @param bool $block Whether to block. Default true.
* @param WP_User $user Authenticated user.
*/
if ( $user instanceof WP_User && ! apply_filters( 'm365_login_block_password_login', true, $user ) ) {
return $user;
}
// Same answer for right and wrong passwords: the form must not become a password oracle.
return new WP_Error( 'm365_login_button_only', __( 'Password sign-in is disabled on this site. Please use the Microsoft button.', 'm365-login' ) );
}
/**
* Whether password resets are switched off (button-only mode without an active fallback).
*
* @return bool
*/
private function passwords_disabled() {
return $this->settings->button_only() && ! $this->fallback_active();
}
/**
* Removes the "Lost your password?" link from wp-login.php (server side, independent of CSS).
*
* @param string $html Link markup.
* @return string
*/
public function hide_lost_password_link( $html ) {
return $this->passwords_disabled() ? '' : $html;
}
/**
* Refuses password resets (form, e-mails, "Send password reset" in the users list).
*
* @param bool|WP_Error $allow Whether the reset is allowed.
* @return bool|WP_Error
*/
public function block_password_reset( $allow ) {
if ( ! $this->passwords_disabled() ) {
return $allow;
}
return new WP_Error( 'm365_login_no_password_reset', __( 'Passwords are not used on this site. Please sign in with the Microsoft button.', 'm365-login' ) );
}
/**
* Sends the lost-password and reset screens back to the login page.
*/
public function block_reset_screen() {
if ( ! $this->passwords_disabled() ) {
return;
}
nocache_headers();
wp_safe_redirect( add_query_arg( 'm365_error', 'password_reset_disabled', $this->settings->login_page_url() ) );
exit;
}
/**
* Starts a new authenticate pass (runs first on the authenticate filter).
*
* @param null|WP_User|WP_Error $user Result so far (unchanged).
* @return null|WP_User|WP_Error
*/
public function reset_app_password_user( $user ) {
$this->app_password_user = 0;
return $user;
}
/**
* Remembers which user an application password authenticated in the current pass.
*
* @param WP_User $user Authenticated user.
*/
public function remember_app_password_user( $user ) {
$this->app_password_user = $user instanceof WP_User ? (int) $user->ID : 0;
}
/**
* While button-only mode is active, API requests (XML-RPC, REST) never receive session cookies.
*
* Core can set cookies there, e.g. when an application password is used to change the account
* password via REST, or when another plugin's login handler runs inside xmlrpc.php.
*
* @param bool $send Whether to send the cookies.
* @param int $expire Expiry (unused).
* @param int $expiration Expiration (unused).
* @param int|null $user_id User ID (0 when cookies are cleared; not passed before WordPress 6.2).
* @return bool
*/
public function block_api_auth_cookies( $send, $expire = 0, $expiration = 0, $user_id = null ) {
// Before WordPress 6.2 the filter gets no user ID: block in API contexts anyway (also blocks
// clearing cookies there, which is harmless).
if ( ! $send || 0 === $user_id || ! $this->settings->button_only() || $this->fallback_active() ) {
return $send;
}
if ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
return false;
}
return $send;
}
/**
* Best-effort client IP for rate limiting.
*
* @return string
*/
private function client_ip() {
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0';
/**
* Name of a trusted proxy header (e.g. 'HTTP_CF_CONNECTING_IP' or 'HTTP_X_REAL_IP') that carries the
* real client IP. Only set this when every request passes through that proxy; the header is
* client-controlled otherwise. Defaults to the M365_LOGIN_CLIENT_IP_HEADER constant or none.
*
* @param string $header $_SERVER key or ''.
*/
$header = apply_filters( 'm365_login_client_ip_header', defined( 'M365_LOGIN_CLIENT_IP_HEADER' ) ? M365_LOGIN_CLIENT_IP_HEADER : '' );
if ( '' !== $header && ! empty( $_SERVER[ $header ] ) ) {
// Proxies append to the list: the right-most entry was written by the trusted proxy,
// entries further left are supplied by the client.
$hops = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) );
$candidate = trim( (string) end( $hops ) );
if ( filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
$ip = $candidate;
}
}
return $ip;
}
/* ------------------------------------------------------------------ */
/* Endpoints */
/* ------------------------------------------------------------------ */
/**
* Microsoft authority base URL for the configured tenant.
*
* @return string
*/
public function authority() {
return 'https://login.microsoftonline.com/' . rawurlencode( $this->settings->tenant() );
}
/**
* OpenID configuration document URL.
*
* @return string
*/
public function discovery_url() {
return $this->authority() . '/v2.0/.well-known/openid-configuration';
}
/**
* Authorization endpoint.
*
* @return string
*/
public function authorize_endpoint() {
return $this->authority() . '/oauth2/v2.0/authorize';
}
/**
* Token endpoint.
*
* @return string
*/
public function token_endpoint() {
return $this->authority() . '/oauth2/v2.0/token';
}
/**
* JWKS endpoint.
*
* @return string
*/
public function jwks_endpoint() {
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.
*
* @param string $redirect_to Optional destination after login.
* @return string
*/
public function start_url( $redirect_to = '' ) {
$args = array( 'action' => self::ACTION_START );
$redirect_to = '' !== (string) $redirect_to ? wp_validate_redirect( (string) $redirect_to, '' ) : '';
if ( '' !== $redirect_to ) {
$args['redirect_to'] = rawurlencode( $redirect_to ); // add_query_arg() does not encode values.
}
return add_query_arg( $args, wp_login_url() );
}
/* ------------------------------------------------------------------ */
/* Step 1: redirect to Microsoft */
/* ------------------------------------------------------------------ */
/**
* Builds the authorization request and redirects the browser.
*/
public function handle_start() {
if ( ! $this->settings->is_configured() ) {
$this->fail( 'not_configured' );
}
// Cap the number of pending login attempts one client can create (state records are stored server-side).
$throttle_key = 'm365_login_start_' . md5( $this->client_ip() );
$starts = (int) get_transient( $throttle_key );
if ( $starts >= 300 ) {
$this->fail( 'too_many_attempts' );
}
set_transient( $throttle_key, $starts + 1, self::STATE_TTL );
// 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 ) );
$cookie_token = M365_Login_JWT::b64url_encode( random_bytes( 32 ) );
$code_challenge = M365_Login_JWT::b64url_encode( hash( 'sha256', $code_verifier, true ) );
// The transient is keyed by a hash of the state, so the raw state never hits the database.
set_transient(
$this->state_key( $state ),
array(
'nonce' => $nonce,
'verifier' => $code_verifier,
'cookie' => hash( 'sha256', $cookie_token ),
'redirect_to' => $redirect_to,
'link_user' => $link_user,
'created' => time(),
),
self::STATE_TTL
);
$this->set_state_cookie( $cookie_token );
$params = array(
'client_id' => $this->settings->get( 'client_id' ),
'response_type' => 'code',
'redirect_uri' => $this->settings->redirect_uri(),
'response_mode' => 'query',
'scope' => 'openid profile email',
'state' => $state,
'nonce' => $nonce,
'code_challenge' => $code_challenge,
'code_challenge_method' => 'S256',
);
$prompt = $this->settings->get( 'prompt' );
if ( in_array( $prompt, array( 'select_account', 'login' ), true ) ) {
$params['prompt'] = $prompt;
}
/**
* Filters the parameters sent to the Microsoft authorization endpoint.
*
* @param array $params Query parameters.
*/
$params = apply_filters( 'm365_login_authorize_params', $params );
nocache_headers();
wp_redirect( $this->authorize_endpoint() . '?' . http_build_query( $params, '', '&', PHP_QUERY_RFC3986 ) ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- external IdP redirect by design.
exit;
}
/* ------------------------------------------------------------------ */
/* Step 2: callback */
/* ------------------------------------------------------------------ */
/**
* Detects a request to /m365-login/callback regardless of permalink settings.
*/
public function maybe_handle_callback() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing only; the OAuth state is verified in handle_callback().
$query_form = isset( $_GET['m365-login'] ) && 'callback' === sanitize_key( wp_unslash( $_GET['m365-login'] ) );
$path_form = false;
if ( isset( $_SERVER['REQUEST_URI'] ) ) {
$request_path = wp_parse_url( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH );
$expected = wp_parse_url( home_url( '/' . self::CALLBACK_PATH ), PHP_URL_PATH );
$path_form = is_string( $request_path ) && is_string( $expected )
&& untrailingslashit( $request_path ) === untrailingslashit( $expected );
}
if ( ! $query_form && ! $path_form ) {
return;
}
$this->handle_callback();
}
/**
* Processes the authorization response, exchanges the code, verifies the
* ID token and signs the matching WordPress user in.
*/
private function handle_callback() {
nocache_headers();
if ( ! $this->settings->is_configured() ) {
$this->fail( 'not_configured' );
}
// State is the CSRF token for this request; there is no WP nonce by design.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : '';
$code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : '';
$error = isset( $_GET['error'] ) ? sanitize_key( wp_unslash( $_GET['error'] ) ) : '';
// phpcs:enable WordPress.Security.NonceVerification.Recommended
if ( '' === $state || ! preg_match( '/^[A-Za-z0-9_\-]{20,128}$/', $state ) ) {
$this->fail( 'invalid_state' );
}
// Consume the state immediately: every state is single use.
$key = $this->state_key( $state );
$attempt = get_transient( $key );
delete_transient( $key );
if ( ! is_array( $attempt ) || empty( $attempt['nonce'] ) || empty( $attempt['verifier'] ) || empty( $attempt['cookie'] ) ) {
$this->fail( 'invalid_state' );
}
if ( empty( $attempt['created'] ) || ( time() - (int) $attempt['created'] ) > self::STATE_TTL ) {
$this->fail( 'invalid_state' );
}
// Bind the callback to the browser that started the flow.
$cookie_token = isset( $_COOKIE[ self::STATE_COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::STATE_COOKIE ] ) ) : '';
$this->clear_state_cookie();
if ( '' === $cookie_token || ! hash_equals( $attempt['cookie'], hash( 'sha256', $cookie_token ) ) ) {
$this->fail( 'invalid_state' );
}
if ( '' !== $error ) {
$this->fail( 'access_denied' === $error ? 'access_denied' : 'provider_error' );
}
if ( '' === $code ) {
$this->fail( 'provider_error' );
}
$tokens = $this->exchange_code( $code, $attempt['verifier'] );
if ( is_wp_error( $tokens ) ) {
$this->log( 'Token exchange failed: ' . $tokens->get_error_message() );
$this->fail( 'token_exchange' );
}
$claims = $this->verify_id_token( $tokens['id_token'], $attempt['nonce'] );
if ( is_wp_error( $claims ) ) {
$this->log( 'ID token rejected: ' . $claims->get_error_message() );
$this->fail( 'invalid_token' );
}
// In multi-tenant mode a guest or federated identity could present any user name.
if ( $this->settings->is_multi_tenant() && $this->is_external_identity( $claims ) ) {
$this->fail( 'external_identity' );
}
$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 );
}
// E-mail and user principal name may differ: every usable address is tried (on the domain allow-list).
$candidates = $this->email_candidates( $claims );
$email = $candidates ? $candidates[0] : '';
$allowed = array_values( array_filter( $candidates, array( $this, 'domain_allowed' ) ) );
if ( $candidates && ! $allowed ) {
$this->fail( 'domain_not_allowed' );
}
// 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, then the user principal name.
$user = $this->find_bound_user( $oid );
if ( ! $user ) {
$user = $this->find_assigned_user( $claims );
}
if ( ! $user ) {
if ( ! $allowed ) {
$this->fail( 'no_email' );
}
foreach ( $allowed as $candidate ) {
$user = get_user_by( 'email', $candidate );
if ( $user instanceof WP_User ) {
break;
}
}
}
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.' ) );
$this->fail( 'no_user' );
}
if ( is_multisite() && ! is_user_member_of_blog( $user->ID ) && ! is_super_admin( $user->ID ) ) {
$this->fail( 'no_user' );
}
if ( M365_Login_Sync::disabled_info( $user->ID ) ) {
$this->fail( 'account_disabled' );
}
// Entra group restriction.
$group_check = $this->check_groups( $claims, $oid );
if ( true !== $group_check ) {
$this->fail( $group_check );
}
// Bind the account to the immutable Microsoft object ID after first login.
$stored = strtolower( (string) get_user_meta( $user->ID, self::META_OID, true ) );
if ( $this->settings->get( 'bind_oid' ) ) {
if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) {
$this->fail( 'invalid_token' );
}
if ( '' !== $stored && ! hash_equals( $stored, $oid ) ) {
$this->log( sprintf( 'Object ID mismatch for user #%d.', $user->ID ) );
$this->fail( 'oid_mismatch' );
}
}
// Privileged accounts are only safe through a verified binding (bind_oid on and the stored
// object ID matches checked above). Otherwise only the user principal name of a member
// account may claim them: its domain is verified in the tenant, the e-mail attribute is not.
$bound = $this->settings->get( 'bind_oid' ) && '' !== $stored;
if ( ! $bound && M365_Login_Sync::is_privileged( $user ) && ! $this->may_claim_privileged( $claims, $user ) ) {
$this->log( sprintf( 'Refused first sign-in of privileged user #%d without a matching user principal name.', $user->ID ) );
$this->fail( 'privileged_unlinked' );
}
if ( $this->settings->get( 'bind_oid' ) && '' === $stored ) {
// One Microsoft identity, one WordPress account.
$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->log( sprintf( 'Object ID is already bound to another account (user #%d).', $user->ID ) );
$this->fail( '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'] ) );
}
}
/**
* Allows blocking a login after all checks passed (e.g. group membership).
*
* @param bool|WP_Error $allowed True to allow.
* @param WP_User $user Matched user.
* @param array $claims Verified ID token claims.
*/
$allowed = apply_filters( 'm365_login_allow_user', true, $user, $claims );
if ( true !== $allowed ) {
$this->fail( 'not_allowed' );
}
update_user_meta( $user->ID, self::META_LAST_LOGIN, time() );
$remember = (bool) $this->settings->get( 'remember_me' );
wp_set_current_user( $user->ID );
wp_set_auth_cookie( $user->ID, $remember, is_ssl() );
/** This action is documented in wp-includes/user.php */
do_action( 'wp_login', $user->user_login, $user );
/**
* Fires after a successful Microsoft login.
*
* @param WP_User $user User.
* @param array $claims Verified claims.
*/
do_action( 'm365_login_success', $user, $claims );
$redirect_to = ! empty( $attempt['redirect_to'] ) ? $attempt['redirect_to'] : admin_url();
/** This filter is documented in wp-login.php */
$redirect_to = apply_filters( 'login_redirect', $redirect_to, $redirect_to, $user );
wp_safe_redirect( $redirect_to );
exit;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
/**
* Exchanges the authorization code for tokens.
*
* @param string $code Authorization code.
* @param string $verifier PKCE verifier.
* @return array|WP_Error
*/
private function exchange_code( $code, $verifier ) {
$auth = $this->settings->client_auth_params( $this->token_endpoint() );
if ( is_wp_error( $auth ) ) {
return $auth;
}
$response = wp_remote_post(
$this->token_endpoint(),
array(
'timeout' => self::HTTP_TIMEOUT,
'headers' => array( 'Accept' => 'application/json' ),
'body' => array_merge(
array(
'client_id' => $this->settings->get( 'client_id' ),
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $this->settings->redirect_uri(),
'code_verifier' => $verifier,
'scope' => 'openid profile email',
),
$auth
),
)
);
if ( is_wp_error( $response ) ) {
return $response;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
$http = (int) wp_remote_retrieve_response_code( $response );
if ( 200 !== $http || ! is_array( $body ) ) {
$detail = is_array( $body ) && ! empty( $body['error'] ) ? (string) $body['error'] : 'HTTP ' . $http;
return new WP_Error( 'token_http', $detail );
}
if ( empty( $body['id_token'] ) || ! is_string( $body['id_token'] ) ) {
return new WP_Error( 'token_missing', 'No id_token in response.' );
}
return $body;
}
/**
* Verifies the ID token, refreshing the JWKS cache once on an unknown key ID.
*
* @param string $id_token Token.
* @param string $nonce Expected nonce.
* @return array|WP_Error
*/
private function verify_id_token( $id_token, $nonce ) {
$tenant = $this->settings->tenant();
$expected = array(
'aud' => (string) $this->settings->get( 'client_id' ),
'nonce' => $nonce,
'tenant' => M365_Login_Settings::is_guid( $tenant ) ? $tenant : '',
);
$jwks = $this->get_jwks( false );
if ( is_wp_error( $jwks ) ) {
return $jwks;
}
$claims = M365_Login_JWT::verify( $id_token, $jwks, $expected );
if ( is_wp_error( $claims ) && 'jwt_unknown_kid' === $claims->get_error_code() ) {
// Key rollover: fetch a fresh key set and try once more.
$jwks = $this->get_jwks( true );
if ( is_wp_error( $jwks ) ) {
return $jwks;
}
$claims = M365_Login_JWT::verify( $id_token, $jwks, $expected );
}
return $claims;
}
/**
* Fetches (and caches) the JWKS document.
*
* @param bool $force Bypass cache.
* @return array|WP_Error
*/
private function get_jwks( $force = false ) {
$cache_key = 'm365_login_jwks_' . md5( $this->jwks_endpoint() );
if ( ! $force ) {
$cached = get_transient( $cache_key );
if ( is_array( $cached ) && ! empty( $cached['keys'] ) ) {
return $cached;
}
}
$response = wp_remote_get( $this->jwks_endpoint(), array( 'timeout' => self::HTTP_TIMEOUT ) );
if ( is_wp_error( $response ) ) {
return $response;
}
if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
return new WP_Error( 'jwks_http', 'JWKS endpoint returned HTTP ' . wp_remote_retrieve_response_code( $response ) );
}
$jwks = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $jwks ) || empty( $jwks['keys'] ) || ! is_array( $jwks['keys'] ) ) {
return new WP_Error( 'jwks_format', 'JWKS document is invalid.' );
}
set_transient( $cache_key, $jwks, self::JWKS_CACHE_TTL );
return $jwks;
}
/**
* Fetches the OpenID configuration (used by the admin "test connection" button).
*
* @return array|WP_Error
*/
public function fetch_discovery() {
$response = wp_remote_get( $this->discovery_url(), array( 'timeout' => self::HTTP_TIMEOUT ) );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== $code || ! is_array( $body ) || empty( $body['issuer'] ) ) {
return new WP_Error( 'discovery', sprintf( 'HTTP %d', $code ) );
}
return $body;
}
/**
* Whether the token comes from a guest or another external identity provider.
*
* Entra only adds the "idp" claim when the identity provider differs from the issuer.
*
* @param array $claims Verified claims.
* @return bool
*/
private function is_external_identity( $claims ) {
$idp = isset( $claims['idp'] ) && is_string( $claims['idp'] ) ? $claims['idp'] : '';
$iss = isset( $claims['iss'] ) && is_string( $claims['iss'] ) ? $claims['iss'] : '';
return '' !== $idp && $idp !== $iss;
}
/**
* Whether a token may claim a privileged account that is not bound yet.
*
* @param array $claims Verified claims.
* @param WP_User $user Matched account.
* @return bool
*/
private function may_claim_privileged( $claims, $user ) {
$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'];
}
/**
* Applies the Entra group rules: members of an excluded group are refused,
* everybody else needs membership in one of the allowed groups (if any are set).
*
* @param array $claims Verified claims.
* @param string $oid User object ID.
* @return true|string True, or an error code for fail().
*/
private function check_groups( $claims, $oid ) {
$denied = $this->check_denied_groups( $claims, $oid );
if ( true !== $denied ) {
return $denied;
}
return $this->check_allowed_groups( $claims, $oid );
}
/**
* Group IDs from the "groups" claim, or null when the token has no complete list (claim missing or overage).
*
* @param array $claims Verified claims.
* @return string[]|null
*/
private function token_groups( $claims ) {
$overage = ! empty( $claims['_claim_names'] ) || ! empty( $claims['hasgroups'] );
if ( $overage || ! isset( $claims['groups'] ) || ! is_array( $claims['groups'] ) ) {
return null;
}
return array_map( 'strtolower', array_filter( $claims['groups'], 'is_string' ) );
}
/**
* Refuses members of an excluded group (fails closed).
*
* A "groups" claim can be filtered in the app registration (e.g. only groups assigned to the
* application), so it can prove membership but never non-membership: without a match in the
* token the plugin always asks Microsoft Graph.
*
* @param array $claims Verified claims.
* @param string $oid User object ID.
* @return true|string True, or an error code for fail().
*/
private function check_denied_groups( $claims, $oid ) {
$denied = array_keys( $this->settings->denied_groups() );
if ( empty( $denied ) ) {
return true;
}
$token_groups = $this->token_groups( $claims );
if ( null !== $token_groups && array_intersect( $denied, $token_groups ) ) {
$this->log( 'User is a member of an excluded group (token claim).' );
return 'in_denied_group';
}
if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) {
return 'invalid_token';
}
$matches = $this->graph->check_member_groups( $oid, $denied, false );
if ( is_wp_error( $matches ) ) {
$this->log( 'Excluded-group check via Microsoft Graph failed: ' . $matches->get_error_message() );
return 'group_check_failed';
}
if ( ! empty( $matches ) ) {
$this->log( 'User is a member of an excluded group (Graph).' );
return 'in_denied_group';
}
return true;
}
/**
* Verifies membership in one of the allowed Entra groups.
*
* Uses the "groups" claim when the token carries one (and is not in overage),
* otherwise asks Microsoft Graph (transitive check, needs application permissions).
*
* @param array $claims Verified claims.
* @param string $oid User object ID.
* @return true|string True, or an error code for fail().
*/
private function check_allowed_groups( $claims, $oid ) {
$allowed = array_keys( $this->settings->allowed_groups() );
if ( empty( $allowed ) ) {
return true;
}
$token_groups = $this->token_groups( $claims );
if ( null !== $token_groups ) {
if ( array_intersect( $allowed, $token_groups ) ) {
return true;
}
// The claim is authoritative when present: no need to ask Graph.
$this->log( 'User is not a member of an allowed group (token claim).' );
return 'not_in_group';
}
if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) {
return 'invalid_token';
}
$matches = $this->graph->check_member_groups( $oid, $allowed, false );
if ( is_wp_error( $matches ) ) {
$this->log( 'Group check via Microsoft Graph failed: ' . $matches->get_error_message() );
return 'group_check_failed';
}
if ( empty( $matches ) ) {
$this->log( 'User is not a member of an allowed group (Graph).' );
return 'not_in_group';
}
return true;
}
/**
* Addresses used for matching, in order (e-mail claim, then user principal name).
*
* @param array $claims Verified claims.
* @return string[] Lowercase addresses.
*/
private function email_candidates( $claims ) {
$candidates = array();
$email = ! empty( $claims['email'] ) && is_string( $claims['email'] ) ? $claims['email'] : '';
$upn = ! empty( $claims['preferred_username'] ) && is_string( $claims['preferred_username'] ) ? $claims['preferred_username'] : '';
if ( $this->settings->is_multi_tenant() ) {
// In multi-tenant mode any tenant admin can set an arbitrary "email" attribute on their users.
// The UPN domain, on the other hand, must be verified in the issuing tenant, so it comes first;
// the e-mail claim is only used when Microsoft marks its domain as owner-verified (xms_edov).
if ( '' !== $upn ) {
$candidates[] = $upn;
}
if ( '' !== $email && ! empty( $claims['xms_edov'] ) && true === $claims['xms_edov'] ) {
$candidates[] = $email;
}
} else {
if ( '' !== $email ) {
$candidates[] = $email;
}
if ( $this->settings->get( 'upn_fallback' ) && '' !== $upn ) {
$candidates[] = $upn;
}
}
$out = array();
foreach ( $candidates as $candidate ) {
$candidate = strtolower( trim( $candidate ) );
if ( is_email( $candidate ) ) {
/**
* Filters an e-mail address used to look up the WordPress user.
*
* @param string $email E-mail from the token.
* @param array $claims Verified claims.
*/
$candidate = strtolower( (string) apply_filters( 'm365_login_match_email', $candidate, $claims ) );
if ( is_email( $candidate ) && ! in_array( $candidate, $out, true ) ) {
$out[] = $candidate;
}
}
}
return $out;
}
/**
* Checks the optional domain allow-list.
*
* @param string $email E-mail.
* @return bool
*/
private function domain_allowed( $email ) {
$allowed = $this->settings->allowed_domains();
if ( empty( $allowed ) ) {
return true;
}
$domain = strtolower( substr( strrchr( $email, '@' ), 1 ) );
return in_array( $domain, $allowed, true );
}
/**
* Transient key for a state value.
*
* @param string $state State.
* @return string
*/
private function state_key( $state ) {
return 'm365_login_st_' . hash_hmac( 'sha256', $state, wp_salt( 'nonce' ) );
}
/**
* Sets the short-lived state cookie.
*
* @param string $token Cookie value.
*/
private function set_state_cookie( $token ) {
$this->send_cookie( self::STATE_COOKIE, $token, time() + self::STATE_TTL );
}
/**
* Removes the state cookie.
*/
private function clear_state_cookie() {
$this->send_cookie( self::STATE_COOKIE, '', time() - YEAR_IN_SECONDS );
}
/**
* Cookie helper: HttpOnly, SameSite=Lax (needed for the top-level redirect back), Secure on HTTPS.
*
* @param string $name Cookie name.
* @param string $value Value.
* @param int $expires Expiry timestamp.
*/
private function send_cookie( $name, $value, $expires ) {
$path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
$path = is_string( $path ) && '' !== $path ? $path : '/';
setcookie(
$name,
$value,
array(
'expires' => $expires,
'path' => $path,
'domain' => COOKIE_DOMAIN ? COOKIE_DOMAIN : '',
'secure' => is_ssl(),
'httponly' => true,
'samesite' => 'Lax',
)
);
}
/**
* Writes a diagnostic line to the debug log (only with WP_DEBUG and WP_DEBUG_LOG; never tokens or secrets).
*
* @param string $message Message.
*/
private function log( $message ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
error_log( '[M365 Login] ' . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
}
}
/**
* Aborts the flow and shows a generic error on the login screen.
*
* @param string $code Error code (mapped to a translated message on the login page).
*/
private function fail( $code ) {
$this->clear_state_cookie();
nocache_headers();
wp_safe_redirect( add_query_arg( 'm365_error', rawurlencode( $code ), $this->settings->login_page_url() ) );
exit;
}
/**
* Messages for the current request (from ?m365_error and ?m365_fallback=on), for any login page.
*
* @return array[] Each item: array( 'type' => 'error'|'message', 'code' => string, 'text' => string ).
*/
public function current_messages() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only display of whitelisted flags.
$code = isset( $_GET['m365_error'] ) ? sanitize_key( wp_unslash( $_GET['m365_error'] ) ) : '';
$fallback_on = isset( $_GET['m365_fallback'] ) && 'on' === $_GET['m365_fallback'];
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$out = array();
if ( $this->settings->button_only() && ! $this->settings->is_configured() && ! $this->fallback_active() ) {
$out[] = array(
'type' => 'error',
'code' => 'unavailable',
'text' => __( 'Microsoft sign-in is temporarily unavailable. Please contact an administrator.', 'm365-login' ),
);
}
if ( $fallback_on && $this->settings->button_only() && $this->fallback_active() ) {
$out[] = array(
'type' => 'message',
'code' => 'fallback_on',
'text' => __( 'Password sign-in is temporarily enabled for this browser (30 minutes).', 'm365-login' ),
);
}
if ( '' !== $code ) {
$messages = $this->error_messages();
$out[] = array(
'type' => 'access_denied' === $code ? 'message' : 'error',
'code' => $code,
'text' => isset( $messages[ $code ] ) ? $messages[ $code ] : $messages['provider_error'],
);
}
return $out;
}
/**
* Error code → translated message map.
*
* @return string[]
*/
private function error_messages() {
return array(
'not_configured' => __( 'Microsoft login is not configured yet.', 'm365-login' ),
'invalid_state' => __( 'The login request expired or was invalid. Please try again.', 'm365-login' ),
'access_denied' => __( 'Microsoft sign-in was cancelled.', 'm365-login' ),
'provider_error' => __( 'Microsoft returned an error. Please try again.', 'm365-login' ),
'token_exchange' => __( 'Could not complete the sign-in with Microsoft. Please try again or contact an administrator.', 'm365-login' ),
'invalid_token' => __( 'The Microsoft sign-in could not be verified.', 'm365-login' ),
'no_email' => __( 'Your Microsoft account did not provide an e-mail address.', 'm365-login' ),
'domain_not_allowed' => __( 'Your e-mail domain is not allowed to sign in here.', 'm365-login' ),
'no_user' => __( 'No WordPress account exists for your Microsoft e-mail address.', 'm365-login' ),
'oid_mismatch' => __( 'This WordPress account is linked to a different Microsoft account. Please contact an administrator.', 'm365-login' ),
'not_allowed' => __( 'You are not allowed to sign in with this account.', 'm365-login' ),
'not_in_group' => __( 'Your Microsoft account is not a member of a group that is allowed to sign in here.', 'm365-login' ),
'group_check_failed' => __( 'Your group membership could not be verified. Please contact an administrator.', 'm365-login' ),
'in_denied_group' => __( 'Your Microsoft account is a member of a group that is not allowed to sign in here.', 'm365-login' ),
'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ),
'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 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' ),
'password_reset_disabled' => __( 'Passwords are not used on this site. Please sign in with the Microsoft button.', 'm365-login' ),
);
}
/**
* Adds the current messages to the wp-login.php error object.
*
* @param WP_Error $errors Login errors.
* @return WP_Error
*/
public function login_errors( $errors ) {
if ( ! $errors instanceof WP_Error ) {
$errors = new WP_Error();
}
foreach ( $this->current_messages() as $msg ) {
$errors->add( 'm365_login_' . $msg['code'], $msg['text'], $msg['type'] );
}
return $errors;
}
}