wp-m365-login/includes/class-m365-login-auth.php
Friederich Loheide 9b893e42bc Fix the findings of the third security audit
- 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>
2026-09-24 03:52:13 +00:00

1104 lines
40 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 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' ) );
// 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' ) );
}
/**
* 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 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'] ) ), '' ) : '';
$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,
'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' );
}
$email = $this->email_from_claims( $claims );
if ( '' === $email ) {
$this->fail( 'no_email' );
}
if ( ! $this->domain_allowed( $email ) ) {
$this->fail( 'domain_not_allowed' );
}
$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.' ) );
$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' );
}
$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 ) {
$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 = ! 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 );
}
/**
* 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;
}
/**
* Extracts the e-mail address used for matching.
*
* @param array $claims Verified claims.
* @return string Lowercase e-mail or empty string.
*/
private function email_from_claims( $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;
}
}
foreach ( $candidates as $candidate ) {
$candidate = strtolower( trim( $candidate ) );
if ( is_email( $candidate ) ) {
/**
* Filters the e-mail address used to look up the WordPress user.
*
* @param string $email E-mail from the token.
* @param array $claims Verified claims.
*/
return (string) apply_filters( 'm365_login_match_email', $candidate, $claims );
}
}
return '';
}
/**
* 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 can only be linked to a Microsoft account whose user principal name equals the WordPress e-mail address. Please contact an administrator.', 'm365-login' ),
'external_identity' => __( 'Guest and external accounts cannot sign in here.', '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;
}
}