Add M365 Login plugin: Microsoft Entra ID sign-in for existing users

Adds a WordPress plugin that places a customisable "Sign in with
Microsoft" button on wp-login.php and signs existing users in via the
OpenID Connect authorization code flow with PKCE. Users are matched by
e-mail address only; no accounts are created.

Security: single-use state/nonce bound to an HttpOnly cookie, ID token
signature verification against Microsoft's JWKS (RS256 only) with
issuer/audience/tenant/expiry/nonce checks, optional tenant pinning,
account binding to the Microsoft object ID, e-mail domain allow-list,
client secret encrypted at rest (AES-256-GCM).

Admin: settings screen with connection, button and security tabs, live
button preview, colour presets, media-library icon picker, redirect URI
copy button and tenant connectivity test.

Packaging for WordPress.org: readme.txt with External services section,
GPL-2.0 license, uninstall.php, POT + German translations, .distignore,
build script, PHPCS config and CI running Plugin Check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJxAHYdMfKPoN4koRc4Ci2
This commit is contained in:
friloo 2026-09-22 14:21:10 +00:00
commit 3e3e87b399
No known key found for this signature in database
35 changed files with 5413 additions and 0 deletions

View file

@ -0,0 +1,283 @@
<?php
/**
* Settings storage and sanitisation.
*
* @package M365_Login
*/
defined( 'ABSPATH' ) || exit;
/**
* Reads, sanitises and writes plugin settings.
*/
class M365_Login_Settings {
/**
* Cached settings.
*
* @var array|null
*/
private $cache = null;
/**
* Default settings.
*
* @return array
*/
public function defaults() {
return array(
// Connection.
'tenant_id' => '',
'client_id' => '',
'client_secret' => '', // Stored encrypted.
'prompt' => 'select_account',
// Security / matching.
'upn_fallback' => 1,
'bind_oid' => 1,
'allowed_domains' => '',
'remember_me' => 0,
// 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' ),
);
}
/**
* 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;
}
/**
* 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 : '';
}
/**
* Whether the plugin has everything it needs to start a login.
*
* @return bool
*/
public function is_configured() {
return '' !== $this->get( 'tenant_id' ) && '' !== $this->get( 'client_id' ) && '' !== $this->client_secret();
}
/**
* Tenant segment used in Microsoft endpoints.
*
* @return string
*/
public function tenant() {
$tenant = (string) $this->get( 'tenant_id', '' );
return '' === $tenant ? 'organizations' : $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 ) );
}
/**
* Sanitises settings coming from the admin form.
*
* @param array $input Raw input.
* @return array
*/
public function sanitize( $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;
}
}
}
$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 );
// 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 );
$this->cache = null;
return $out;
}
/**
* 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 );
}
}