'', 'client_id' => '', 'client_secret' => '', // Stored encrypted. 'auth_method' => 'secret', // 'secret' or 'certificate'. 'cert_private_key' => '', // PEM, stored encrypted. 'cert_certificate' => '', // PEM (public). 'prompt' => 'select_account', // Security / matching. 'upn_fallback' => 1, 'bind_oid' => 1, 'allowed_domains' => '', 'allowed_groups' => array(), // id => display name. 'denied_groups' => array(), // id => display name; members may never sign in. 'remember_me' => 0, // Button-only mode. 'button_only' => 0, 'fallback_key' => '', // Button appearance. 'button_text' => __( 'Sign in with Microsoft', 'm365-login' ), 'button_icon' => '', // Empty = bundled Microsoft logo. 'button_show_icon' => 1, 'button_bg' => '#2f2f2f', 'button_bg_hover' => '#1a1a1a', 'button_color' => '#ffffff', 'button_border' => '#2f2f2f', 'button_radius' => 4, 'button_position' => 'below', 'divider_text' => __( 'or', 'm365-login' ), // Custom login pages. 'custom_login_url' => '', 'inject_form' => 1, // Add the button to wp_login_form() output. // User sync. 'sync_enabled' => 0, // Scheduled sync via WP-Cron. 'sync_interval' => 'daily', 'sync_guests' => 0, 'sync_scope_groups' => array(), // id => display name; empty = whole tenant. 'sync_default_role' => 'subscriber', 'sync_role_map' => array(), // id => array( 'name' => .., 'role' => .. ), in priority order. 'sync_role_mode' => 'add', // 'add' (extra roles) or 'replace' (first match replaces the default role). 'sync_manage_existing' => 0, // Also manage roles of accounts that existed before the sync. 'sync_attributes' => array( 'displayName', 'givenName', 'surname' ), 'sync_disabled_action' => 'disable', // Account disabled in Microsoft 365: none|disable|delete. 'sync_deleted_action' => 'disable', // Account deleted in Microsoft 365: none|disable|delete. 'sync_scope_action' => 'none', // Removed from the sync groups: none|disable|delete. 'sync_reassign' => 0, // User ID that receives content of deleted users. ); } /** * Returns all settings merged with defaults. * * @return array */ public function all() { if ( null === $this->cache ) { $stored = get_option( M365_LOGIN_OPTION, array() ); $this->cache = wp_parse_args( is_array( $stored ) ? $stored : array(), $this->defaults() ); } return $this->cache; } /** * Drops the cached settings (after the option was written). */ public function flush() { $this->cache = null; } /** * Returns a single setting. * * @param string $key Setting key. * @param mixed $default Fallback. * @return mixed */ public function get( $key, $default = null ) { $all = $this->all(); return array_key_exists( $key, $all ) ? $all[ $key ] : $default; } /** * Decrypted client secret. * * @return string */ public function client_secret() { $enc = (string) $this->get( 'client_secret', '' ); if ( '' === $enc ) { return ''; } $plain = M365_Login_Crypto::decrypt( $enc ); return is_string( $plain ) ? $plain : ''; } /** * Selected client authentication method. * * @return string 'secret' or 'certificate'. */ public function auth_method() { return 'certificate' === $this->get( 'auth_method' ) ? 'certificate' : 'secret'; } /** * Decrypted certificate private key (PEM) or ''. * * @return string */ public function certificate_key() { $enc = (string) $this->get( 'cert_private_key', '' ); if ( '' === $enc ) { return ''; } $plain = M365_Login_Crypto::decrypt( $enc ); return is_string( $plain ) ? $plain : ''; } /** * Certificate PEM (public part) or ''. * * @return string */ public function certificate_pem() { return (string) $this->get( 'cert_certificate', '' ); } /** * Whether a usable certificate + key pair is stored. * * @return bool */ public function has_certificate() { return '' !== $this->certificate_pem() && '' !== $this->certificate_key(); } /** * Parsed certificate metadata or null. * * @return array|null */ public function certificate_info() { return $this->has_certificate() ? M365_Login_Certificate::info( $this->certificate_pem() ) : null; } /** * Whether the plugin has everything it needs to start a login. * * @return bool */ public function is_configured() { if ( '' === $this->get( 'tenant_id' ) || '' === $this->get( 'client_id' ) ) { return false; } if ( 'certificate' === $this->auth_method() ) { $info = $this->certificate_info(); return null !== $info && ( 0 === $info['not_after'] || $info['not_after'] > time() ); } return '' !== $this->client_secret(); } /** * Client authentication parameters for the token endpoint (secret or signed assertion). * * @param string $token_endpoint Token endpoint URL (assertion audience). * @return array|WP_Error */ public function client_auth_params( $token_endpoint ) { if ( 'certificate' === $this->auth_method() ) { $assertion = M365_Login_Certificate::assertion( $this->certificate_key(), $this->certificate_pem(), (string) $this->get( 'client_id' ), $token_endpoint ); if ( is_wp_error( $assertion ) ) { return $assertion; } return array( 'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 'client_assertion' => $assertion, ); } return array( 'client_secret' => $this->client_secret() ); } /** * Stores a validated key/certificate pair (key encrypted). * * @param array $pair array( 'private_key' => PEM, 'certificate' => PEM ). * @return true|WP_Error */ public function store_certificate( $pair ) { $enc = M365_Login_Crypto::encrypt( $pair['private_key'] ); if ( false === $enc ) { return new WP_Error( 'encrypt', __( 'The private key could not be encrypted. Is the OpenSSL extension available?', 'm365-login' ) ); } $all = $this->all(); $all['cert_private_key'] = $enc; $all['cert_certificate'] = $pair['certificate']; $this->write( $all ); return true; } /** * Removes the stored certificate and key. */ public function remove_certificate() { $all = $this->all(); $all['cert_private_key'] = ''; $all['cert_certificate'] = ''; $this->write( $all ); } /** * Stores already sanitised settings. * * The option is registered with sanitize() as callback, which expects raw form input * (it would, for example, encrypt the stored client secret a second time). * * @param array $all Complete settings. */ private function write( $all ) { $this->raw_write = true; update_option( M365_LOGIN_OPTION, $all ); $this->raw_write = false; $this->cache = null; } /** * Tenant segment used in Microsoft endpoints. * * @return string */ public function tenant() { $tenant = (string) $this->get( 'tenant_id', '' ); return '' === $tenant ? 'organizations' : $tenant; } /** * Whether sign-ins from more than one tenant are accepted (no tenant GUID pinned). * * @return bool */ public function is_multi_tenant() { return ! self::is_guid( $this->tenant() ); } /** * Redirect URI registered in Entra ID. * * @return string */ public function redirect_uri() { if ( $this->uses_pretty_callback() ) { $uri = home_url( '/m365-login/callback' ); } else { $uri = add_query_arg( 'm365-login', 'callback', home_url( '/' ) ); } /** * Filters the redirect URI registered in Entra ID. * * @param string $uri Redirect URI. */ return (string) apply_filters( 'm365_login_redirect_uri', $uri ); } /** * Whether the callback can use a path (requires rewrite rules) instead of a query argument. * * @return bool */ public function uses_pretty_callback() { return '' !== (string) get_option( 'permalink_structure', '' ); } /** * Allowed e-mail domains as an array (lowercase, no leading @). * * @return string[] */ public function allowed_domains() { $raw = (string) $this->get( 'allowed_domains', '' ); if ( '' === trim( $raw ) ) { return array(); } $parts = preg_split( '/[\s,;]+/', strtolower( $raw ) ); $out = array(); foreach ( $parts as $p ) { $p = ltrim( trim( $p ), '@' ); if ( '' !== $p ) { $out[] = $p; } } return array_values( array_unique( $out ) ); } /** * Allowed Entra group IDs (lowercase GUIDs) mapped to display names. * * @return array */ public function allowed_groups() { return self::guid_map( $this->get( 'allowed_groups', array() ) ); } /** * Excluded Entra group IDs (lowercase GUIDs) mapped to display names. * * @return array */ public function denied_groups() { return self::guid_map( $this->get( 'denied_groups', array() ) ); } /** * Groups that limit the user sync (lowercase GUID => name); empty = whole tenant. * * @return array */ public function sync_scope_groups() { return self::guid_map( $this->get( 'sync_scope_groups', array() ) ); } /** * Group → role mapping in priority order. * * @return array lowercase GUID => array( 'name' => string, 'role' => string ). */ public function sync_role_map() { $raw = $this->get( 'sync_role_map', array() ); $out = array(); if ( is_array( $raw ) ) { foreach ( $raw as $id => $row ) { $id = strtolower( (string) $id ); if ( self::is_guid( $id ) && is_array( $row ) && ! empty( $row['role'] ) ) { $out[ $id ] = array( 'name' => isset( $row['name'] ) ? (string) $row['name'] : $id, 'role' => (string) $row['role'], ); } } } return $out; } /** * Keeps GUID keys (lowercased) of an id => name array. * * @param mixed $raw Stored value. * @return array */ private static function guid_map( $raw ) { $out = array(); if ( is_array( $raw ) ) { foreach ( $raw as $id => $name ) { $id = strtolower( (string) $id ); if ( self::is_guid( $id ) ) { $out[ $id ] = (string) $name; } } } return $out; } /** * Whether the password form is hidden and password sign-in blocked. * * @return bool */ public function button_only() { if ( defined( 'M365_LOGIN_DISABLE_BUTTON_ONLY' ) && M365_LOGIN_DISABLE_BUTTON_ONLY ) { return false; } return $this->is_configured() && (bool) $this->get( 'button_only' ) && '' !== $this->fallback_key(); } /** * Secret key that re-enables the password form. * * @return string */ public function fallback_key() { $key = (string) $this->get( 'fallback_key', '' ); return preg_match( '/^[A-Za-z0-9]{16,64}$/', $key ) ? $key : ''; } /** * URL that shows the password form again when button-only mode is active. * * @return string */ public function fallback_url() { $key = $this->fallback_key(); return '' === $key ? '' : add_query_arg( 'm365_fallback', $key, $this->login_page_url() ); } /** * URL of a custom login page (same site), or empty string. * * @return string */ public function custom_login_url() { $url = (string) $this->get( 'custom_login_url', '' ); if ( '' === $url ) { return ''; } $validated = wp_validate_redirect( $url, '' ); return is_string( $validated ) ? $validated : ''; } /** * Where users land after a failed Microsoft sign-in (custom page or wp-login.php). * * @return string */ public function login_page_url() { $custom = $this->custom_login_url(); return '' !== $custom ? $custom : wp_login_url(); } /** * Generates a new fallback key. * * @return string */ public static function generate_fallback_key() { $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'; $key = ''; for ( $i = 0; $i < 24; $i++ ) { $key .= $alphabet[ random_int( 0, strlen( $alphabet ) - 1 ) ]; } return $key; } /** * Sanitises settings coming from the admin form. * * @param array $input Raw input. * @return array */ public function sanitize( $input ) { if ( $this->raw_write ) { return $input; } $defaults = $this->defaults(); $current = $this->all(); $input = is_array( $input ) ? $input : array(); $out = $current; // Tenant: GUID or one of the well-known aliases. $tenant = isset( $input['tenant_id'] ) ? trim( sanitize_text_field( wp_unslash( $input['tenant_id'] ) ) ) : ''; $tenant = strtolower( $tenant ); if ( '' !== $tenant && ! self::is_valid_tenant( $tenant ) ) { add_settings_error( M365_LOGIN_OPTION, 'tenant_id', __( 'The tenant ID must be a GUID (e.g. 1a2b3c4d-…) or one of "organizations", "common", "consumers".', 'm365-login' ) ); $tenant = $current['tenant_id']; } $out['tenant_id'] = $tenant; // Client ID: GUID. $client_id = isset( $input['client_id'] ) ? trim( sanitize_text_field( wp_unslash( $input['client_id'] ) ) ) : ''; if ( '' !== $client_id && ! self::is_guid( $client_id ) ) { add_settings_error( M365_LOGIN_OPTION, 'client_id', __( 'The application (client) ID must be a GUID.', 'm365-login' ) ); $client_id = $current['client_id']; } $out['client_id'] = strtolower( $client_id ); // Client secret: only replaced when a new value was entered. $secret_input = isset( $input['client_secret'] ) ? (string) wp_unslash( $input['client_secret'] ) : ''; $secret_input = trim( $secret_input ); if ( ! empty( $input['client_secret_clear'] ) ) { $out['client_secret'] = ''; } elseif ( '' !== $secret_input ) { if ( strlen( $secret_input ) > 512 || preg_match( '/[\x00-\x1F\x7F]/', $secret_input ) ) { add_settings_error( M365_LOGIN_OPTION, 'client_secret', __( 'The client secret contains invalid characters.', 'm365-login' ) ); } else { $enc = M365_Login_Crypto::encrypt( $secret_input ); if ( false === $enc ) { add_settings_error( M365_LOGIN_OPTION, 'client_secret', __( 'The client secret could not be encrypted. Is the OpenSSL extension available?', 'm365-login' ) ); } else { $out['client_secret'] = $enc; } } } $method = isset( $input['auth_method'] ) ? sanitize_key( $input['auth_method'] ) : 'secret'; $out['auth_method'] = 'certificate' === $method ? 'certificate' : 'secret'; // Certificate: keep the stored pair unless a new one is pasted or removal is requested. $out['cert_private_key'] = $current['cert_private_key']; $out['cert_certificate'] = $current['cert_certificate']; $pasted_key = isset( $input['cert_key_pem'] ) ? trim( (string) wp_unslash( $input['cert_key_pem'] ) ) : ''; $pasted_cert = isset( $input['cert_cert_pem'] ) ? trim( (string) wp_unslash( $input['cert_cert_pem'] ) ) : ''; if ( ! empty( $input['cert_remove'] ) ) { $out['cert_private_key'] = ''; $out['cert_certificate'] = ''; } elseif ( '' !== $pasted_key || '' !== $pasted_cert ) { if ( '' === $pasted_key || '' === $pasted_cert ) { add_settings_error( M365_LOGIN_OPTION, 'certificate', __( 'Please paste both the private key and the certificate.', 'm365-login' ) ); } elseif ( strlen( $pasted_key ) > 20000 || strlen( $pasted_cert ) > 20000 ) { add_settings_error( M365_LOGIN_OPTION, 'certificate', __( 'The pasted key or certificate is too large.', 'm365-login' ) ); } else { $pair = M365_Login_Certificate::from_pem( $pasted_key, $pasted_cert ); if ( is_wp_error( $pair ) ) { add_settings_error( M365_LOGIN_OPTION, 'certificate', $pair->get_error_message() ); } else { $enc = M365_Login_Crypto::encrypt( $pair['private_key'] ); if ( false === $enc ) { add_settings_error( M365_LOGIN_OPTION, 'certificate', __( 'The private key could not be encrypted. Is the OpenSSL extension available?', 'm365-login' ) ); } else { $out['cert_private_key'] = $enc; $out['cert_certificate'] = $pair['certificate']; } } } } if ( 'certificate' === $out['auth_method'] && '' === $out['cert_certificate'] ) { add_settings_error( M365_LOGIN_OPTION, 'auth_method', __( 'Certificate authentication is selected but no certificate is stored yet. Generate one or paste your own; the Microsoft button stays hidden until then.', 'm365-login' ), 'warning' ); } $prompt = isset( $input['prompt'] ) ? sanitize_key( $input['prompt'] ) : ''; $out['prompt'] = in_array( $prompt, array( 'none', 'select_account', 'login' ), true ) ? $prompt : 'none'; $out['upn_fallback'] = empty( $input['upn_fallback'] ) ? 0 : 1; $out['bind_oid'] = empty( $input['bind_oid'] ) ? 0 : 1; $out['remember_me'] = empty( $input['remember_me'] ) ? 0 : 1; $domains = isset( $input['allowed_domains'] ) ? sanitize_textarea_field( wp_unslash( $input['allowed_domains'] ) ) : ''; $domains = preg_replace( '/[^a-z0-9.\-@,;\s]/i', '', $domains ); $out['allowed_domains'] = trim( (string) $domains ); // Allowed groups: GUID => name. $out['allowed_groups'] = self::sanitize_group_list( isset( $input['allowed_groups'] ) ? $input['allowed_groups'] : array() ); $out['denied_groups'] = self::sanitize_group_list( isset( $input['denied_groups'] ) ? $input['denied_groups'] : array() ); // Button-only mode + fallback key. $out['button_only'] = empty( $input['button_only'] ) ? 0 : 1; $key = (string) $current['fallback_key']; if ( ! empty( $input['fallback_regenerate'] ) || ! preg_match( '/^[A-Za-z0-9]{16,64}$/', $key ) ) { $key = self::generate_fallback_key(); } $out['fallback_key'] = $key; // Custom login page (must be on this site). $custom = isset( $input['custom_login_url'] ) ? esc_url_raw( trim( wp_unslash( $input['custom_login_url'] ) ) ) : ''; if ( '' !== $custom ) { if ( 0 === strpos( $custom, '/' ) ) { $custom = home_url( $custom ); } if ( '' === wp_validate_redirect( $custom, '' ) ) { add_settings_error( M365_LOGIN_OPTION, 'custom_login_url', __( 'The custom login page must be a URL on this site.', 'm365-login' ) ); $custom = $current['custom_login_url']; } } $out['custom_login_url'] = $custom; $out['inject_form'] = empty( $input['inject_form'] ) ? 0 : 1; // Button. $text = isset( $input['button_text'] ) ? sanitize_text_field( wp_unslash( $input['button_text'] ) ) : ''; $out['button_text'] = '' === trim( $text ) ? $defaults['button_text'] : mb_substr( $text, 0, 80 ); $icon = isset( $input['button_icon'] ) ? esc_url_raw( trim( wp_unslash( $input['button_icon'] ) ) ) : ''; $out['button_icon'] = self::is_safe_image_url( $icon ) ? $icon : ''; $out['button_show_icon'] = empty( $input['button_show_icon'] ) ? 0 : 1; foreach ( array( 'button_bg', 'button_bg_hover', 'button_color', 'button_border' ) as $color_key ) { $color = isset( $input[ $color_key ] ) ? sanitize_hex_color( trim( wp_unslash( $input[ $color_key ] ) ) ) : ''; $out[ $color_key ] = $color ? $color : $defaults[ $color_key ]; } $radius = isset( $input['button_radius'] ) ? absint( $input['button_radius'] ) : $defaults['button_radius']; $out['button_radius'] = min( 50, $radius ); $position = isset( $input['button_position'] ) ? sanitize_key( $input['button_position'] ) : 'below'; $out['button_position'] = in_array( $position, array( 'above', 'below' ), true ) ? $position : 'below'; $divider = isset( $input['divider_text'] ) ? sanitize_text_field( wp_unslash( $input['divider_text'] ) ) : ''; $out['divider_text'] = mb_substr( $divider, 0, 40 ); $out = $this->sanitize_sync( $input, $out ); $this->cache = null; return $out; } /** * Sanitises the user sync settings. * * @param array $input Raw input. * @param array $out Settings sanitised so far. * @return array */ private function sanitize_sync( $input, $out ) { $defaults = $this->defaults(); $out['sync_enabled'] = empty( $input['sync_enabled'] ) ? 0 : 1; $out['sync_guests'] = empty( $input['sync_guests'] ) ? 0 : 1; $out['sync_manage_existing'] = empty( $input['sync_manage_existing'] ) ? 0 : 1; $interval = isset( $input['sync_interval'] ) ? sanitize_key( $input['sync_interval'] ) : ''; $out['sync_interval'] = in_array( $interval, array( 'hourly', 'twicedaily', 'daily' ), true ) ? $interval : $defaults['sync_interval']; $mode = isset( $input['sync_role_mode'] ) ? sanitize_key( $input['sync_role_mode'] ) : ''; $out['sync_role_mode'] = in_array( $mode, array( 'add', 'replace' ), true ) ? $mode : $defaults['sync_role_mode']; $role = isset( $input['sync_default_role'] ) ? sanitize_key( $input['sync_default_role'] ) : ''; $out['sync_default_role'] = '' !== $role && get_role( $role ) ? $role : $defaults['sync_default_role']; $out['sync_scope_groups'] = self::sanitize_group_list( isset( $input['sync_scope_groups'] ) ? $input['sync_scope_groups'] : array() ); $map = array(); if ( ! empty( $input['sync_role_map'] ) && is_array( $input['sync_role_map'] ) ) { foreach ( $input['sync_role_map'] as $id => $row ) { $id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) ); if ( ! self::is_guid( $id ) || ! is_array( $row ) ) { continue; } $map_role = isset( $row['role'] ) ? sanitize_key( $row['role'] ) : ''; if ( '' === $map_role || ! get_role( $map_role ) ) { continue; } $name = isset( $row['name'] ) ? sanitize_text_field( wp_unslash( (string) $row['name'] ) ) : ''; $map[ $id ] = array( 'name' => '' === $name ? $id : mb_substr( $name, 0, 120 ), 'role' => $map_role, ); if ( count( $map ) >= 100 ) { break; } } } $out['sync_role_map'] = $map; $attributes = array(); if ( ! empty( $input['sync_attributes'] ) && is_array( $input['sync_attributes'] ) ) { $known = array_keys( M365_Login_Sync::attributes() ); foreach ( $input['sync_attributes'] as $attribute ) { $attribute = sanitize_text_field( wp_unslash( (string) $attribute ) ); if ( in_array( $attribute, $known, true ) ) { $attributes[] = $attribute; } } } $out['sync_attributes'] = array_values( array_unique( $attributes ) ); foreach ( array( 'sync_disabled_action', 'sync_deleted_action', 'sync_scope_action' ) as $key ) { $action = isset( $input[ $key ] ) ? sanitize_key( $input[ $key ] ) : ''; $out[ $key ] = in_array( $action, array( 'none', 'disable', 'delete' ), true ) ? $action : $defaults[ $key ]; } $reassign = isset( $input['sync_reassign'] ) ? absint( $input['sync_reassign'] ) : 0; $out['sync_reassign'] = $reassign && get_userdata( $reassign ) ? $reassign : 0; $deletes = in_array( 'delete', array( $out['sync_disabled_action'], $out['sync_deleted_action'], $out['sync_scope_action'] ), true ); if ( $deletes && ! $out['sync_reassign'] ) { add_settings_error( M365_LOGIN_OPTION, 'sync_reassign', __( 'User sync: "Delete" needs a user who receives the posts of deleted accounts. Until one is selected, accounts are deactivated instead.', 'm365-login' ), 'warning' ); } return $out; } /** * Sanitises a GUID => name list posted by a group picker. * * @param mixed $raw Raw input. * @return array */ private static function sanitize_group_list( $raw ) { $groups = array(); if ( empty( $raw ) || ! is_array( $raw ) ) { return $groups; } foreach ( $raw as $id => $name ) { $id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) ); if ( ! self::is_guid( $id ) ) { continue; } $name = sanitize_text_field( wp_unslash( (string) $name ) ); $groups[ $id ] = '' === $name ? $id : mb_substr( $name, 0, 120 ); if ( count( $groups ) >= 100 ) { break; } } return $groups; } /** * Checks a GUID. * * @param string $value Value. * @return bool */ public static function is_guid( $value ) { return (bool) preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $value ); } /** * Checks a tenant identifier. * * @param string $value Value. * @return bool */ public static function is_valid_tenant( $value ) { return self::is_guid( $value ) || in_array( $value, array( 'organizations', 'common', 'consumers' ), true ); } /** * Only allows http(s) image URLs with a known image extension. * * @param string $url URL. * @return bool */ public static function is_safe_image_url( $url ) { if ( '' === $url ) { return false; } $parts = wp_parse_url( $url ); if ( empty( $parts['scheme'] ) || ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) ) { return false; } $path = isset( $parts['path'] ) ? strtolower( $parts['path'] ) : ''; return (bool) preg_match( '/\.(png|jpe?g|gif|svg|webp)$/', $path ); } }