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 ); // 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 ] ) ) : ''; return '' !== $cookie && hash_equals( $this->fallback_cookie_value(), $cookie ); } /** * Expected fallback cookie value (HMAC of the key, so the key itself never sits in the cookie). * * @return string */ private function fallback_cookie_value() { return hash_hmac( 'sha256', 'fallback|' . $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; } // Slow down brute force attempts on the key. $ip_key = 'm365_login_fb_' . md5( $this->client_ip() ); $attempts = (int) get_transient( $ip_key ); if ( $attempts >= 10 ) { $this->fail( 'fallback_locked' ); } if ( ! hash_equals( $this->settings->fallback_key(), $given ) ) { set_transient( $ip_key, $attempts + 1, 15 * MINUTE_IN_SECONDS ); $this->fail( 'fallback_invalid' ); } delete_transient( $ip_key ); $this->send_cookie( self::FALLBACK_COOKIE, $this->fallback_cookie_value(), time() + 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. } // Interactive password logins only: XML-RPC, REST (application passwords), WP-CLI and cron keep working. if ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'WP_CLI' ) && WP_CLI ) || wp_doing_cron() ) { return $user; } if ( ! $user instanceof WP_User ) { return $user; // Already failed for another reason; keep core's message. } /** * 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 ( ! apply_filters( 'm365_login_block_password_login', true, $user ) ) { return $user; } return new WP_Error( 'm365_login_button_only', __( 'Password sign-in is disabled on this site. Please use the Microsoft button.', 'm365-login' ) ); } /** * Best-effort client IP for rate limiting. * * @return string */ private function client_ip() { return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0'; } /* ------------------------------------------------------------------ */ /* 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 ); if ( '' !== $redirect_to ) { $args['redirect_to'] = $redirect_to; } 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' ); } // 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' ); } $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' ); } $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. if ( $this->settings->get( 'bind_oid' ) ) { if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) { $this->fail( 'invalid_token' ); } $stored = (string) get_user_meta( $user->ID, self::META_OID, true ); if ( '' !== $stored && ! hash_equals( $stored, $oid ) ) { $this->log( sprintf( 'Object ID mismatch for user #%d.', $user->ID ) ); $this->fail( 'oid_mismatch' ); } if ( '' === $stored ) { 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 ) { $response = wp_remote_post( $this->token_endpoint(), array( 'timeout' => self::HTTP_TIMEOUT, 'headers' => array( 'Accept' => 'application/json' ), 'body' => array( 'client_id' => $this->settings->get( 'client_id' ), 'client_secret' => $this->settings->client_secret(), 'grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => $this->settings->redirect_uri(), 'code_verifier' => $verifier, 'scope' => 'openid profile email', ), ) ); 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; } /** * 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_groups( $claims, $oid ) { $allowed = array_keys( $this->settings->allowed_groups() ); if ( empty( $allowed ) ) { return true; } $overage = ! empty( $claims['_claim_names'] ) || ! empty( $claims['hasgroups'] ); if ( ! $overage && isset( $claims['groups'] ) && is_array( $claims['groups'] ) ) { $token_groups = array_map( 'strtolower', array_filter( $claims['groups'], 'is_string' ) ); 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 ); 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(); if ( ! empty( $claims['email'] ) && is_string( $claims['email'] ) ) { $candidates[] = $claims['email']; } if ( $this->settings->get( 'upn_fallback' ) && ! empty( $claims['preferred_username'] ) && is_string( $claims['preferred_username'] ) ) { $candidates[] = $claims['preferred_username']; } 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', ) ); } /** * 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 ( $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' ), 'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ), 'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', '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; } }