wp-m365-login/includes/class-m365-login-auth.php
Friederich Loheide 850f0dcd54
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
Fix the findings of a full second security audit
Four-part audit (OIDC/JWT/crypto, user sync, admin UI, login bypasses)
with dynamic PoCs against a real WordPress install; every fix is covered
by a regression test. Report: docs/security-audit.md, section 6.

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

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

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

1064 lines
38 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 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;
/**
* 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 );
// 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.
if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( $user instanceof WP_User && did_action( 'application_password_did_authenticate' ) ) ) {
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' ) );
}
/**
* 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 $user_id User ID (0 when cookies are cleared).
* @return bool
*/
public function block_api_auth_cookies( $send, $expire = 0, $expiration = 0, $user_id = 0 ) {
if ( ! $send || ! $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 that are not bound yet: 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).
if ( '' === $stored && 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 );
}
/**
* 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;
}
}