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,460 @@
<?php
/**
* Admin settings screen.
*
* @package M365_Login
*/
defined( 'ABSPATH' ) || exit;
/**
* Registers and renders the settings page.
*/
class M365_Login_Admin {
const PAGE = 'm365-login';
const GROUP = 'm365_login';
const AJAX_TEST = 'm365_login_test_connection';
const NONCE_TEST = 'm365_login_test';
/**
* Settings.
*
* @var M365_Login_Settings
*/
private $settings;
/**
* Auth component (for endpoint URLs and discovery).
*
* @var M365_Login_Auth
*/
private $auth;
/**
* Screen hook suffix.
*
* @var string
*/
private $hook = '';
/**
* Constructor.
*
* @param M365_Login_Settings $settings Settings.
* @param M365_Login_Auth $auth Auth.
*/
public function __construct( M365_Login_Settings $settings, M365_Login_Auth $auth ) {
$this->settings = $settings;
$this->auth = $auth;
add_action( 'admin_menu', array( $this, 'menu' ) );
add_action( 'admin_init', array( $this, 'register' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
add_action( 'wp_ajax_' . self::AJAX_TEST, array( $this, 'ajax_test_connection' ) );
add_action( 'admin_notices', array( $this, 'setup_notice' ) );
}
/**
* Adds the menu entry under Settings.
*/
public function menu() {
$this->hook = add_options_page(
__( 'M365 Login', 'm365-login' ),
__( 'M365 Login', 'm365-login' ),
'manage_options',
self::PAGE,
array( $this, 'render' )
);
}
/**
* Registers the option with the Settings API.
*/
public function register() {
register_setting(
self::GROUP,
M365_LOGIN_OPTION,
array(
'type' => 'array',
'sanitize_callback' => array( $this->settings, 'sanitize' ),
'default' => $this->settings->defaults(),
)
);
}
/**
* Nudges administrators to finish the setup.
*/
public function setup_notice() {
if ( $this->settings->is_configured() || ! current_user_can( 'manage_options' ) ) {
return;
}
$screen = get_current_screen();
if ( $screen && $this->hook === $screen->id ) {
return;
}
if ( ! $screen || ! in_array( $screen->id, array( 'plugins', 'dashboard' ), true ) ) {
return;
}
printf(
'<div class="notice notice-info is-dismissible"><p>%s <a href="%s">%s</a></p></div>',
esc_html__( 'M365 Login is active but not connected to Microsoft Entra ID yet.', 'm365-login' ),
esc_url( admin_url( 'options-general.php?page=' . self::PAGE ) ),
esc_html__( 'Open the settings', 'm365-login' )
);
}
/**
* Loads assets on our screen only.
*
* @param string $hook Current screen hook.
*/
public function enqueue( $hook ) {
if ( $hook !== $this->hook ) {
return;
}
wp_enqueue_media();
wp_enqueue_style( 'wp-color-picker' );
wp_enqueue_style( 'm365-login-admin', M365_LOGIN_URL . 'assets/css/admin.css', array( 'wp-color-picker' ), M365_LOGIN_VERSION );
wp_enqueue_script( 'm365-login-admin', M365_LOGIN_URL . 'assets/js/admin.js', array( 'jquery', 'wp-color-picker' ), M365_LOGIN_VERSION, true );
wp_localize_script(
'm365-login-admin',
'm365LoginAdmin',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( self::NONCE_TEST ),
'action' => self::AJAX_TEST,
'defaultLogo' => M365_Login_Button::microsoft_logo(),
'i18n' => array(
'chooseIcon' => __( 'Choose button icon', 'm365-login' ),
'useIcon' => __( 'Use this icon', 'm365-login' ),
'copied' => __( 'Copied!', 'm365-login' ),
'copy' => __( 'Copy', 'm365-login' ),
'testing' => __( 'Testing…', 'm365-login' ),
'testFailed' => __( 'The tenant could not be reached. Check the tenant ID and the servers outgoing connections.', 'm365-login' ),
),
)
);
}
/**
* AJAX: fetch the OpenID configuration for the tenant typed into the form.
*/
public function ajax_test_connection() {
check_ajax_referer( self::NONCE_TEST, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
}
$tenant = isset( $_POST['tenant'] ) ? strtolower( sanitize_text_field( wp_unslash( $_POST['tenant'] ) ) ) : '';
if ( '' === $tenant || ! M365_Login_Settings::is_valid_tenant( $tenant ) ) {
wp_send_json_error( array( 'message' => __( 'Please enter a valid tenant ID first.', 'm365-login' ) ) );
}
$url = 'https://login.microsoftonline.com/' . rawurlencode( $tenant ) . '/v2.0/.well-known/openid-configuration';
$response = wp_remote_get( $url, array( 'timeout' => M365_Login_Auth::HTTP_TIMEOUT ) );
if ( is_wp_error( $response ) ) {
wp_send_json_error( array( 'message' => $response->get_error_message() ) );
}
$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'] ) ) {
wp_send_json_error(
array(
/* translators: %d: HTTP status code */
'message' => sprintf( __( 'Microsoft answered with HTTP %d. Is the tenant ID correct?', 'm365-login' ), $code ),
)
);
}
wp_send_json_success(
array(
'issuer' => esc_url_raw( $body['issuer'] ),
'endpoint' => isset( $body['authorization_endpoint'] ) ? esc_url_raw( $body['authorization_endpoint'] ) : '',
'message' => __( 'Tenant reachable. The OpenID configuration was loaded successfully.', 'm365-login' ),
)
);
}
/**
* Renders the settings screen.
*/
public function render() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to access this page.', 'm365-login' ) );
}
$s = $this->settings->all();
$configured = $this->settings->is_configured();
$has_secret = '' !== $this->settings->client_secret();
$option = M365_LOGIN_OPTION;
$field = function ( $key ) use ( $option ) {
return esc_attr( $option . '[' . $key . ']' );
};
?>
<div class="wrap m365-admin">
<header class="m365-admin__header">
<div class="m365-admin__brand">
<span class="m365-admin__logo"><?php echo M365_Login_Button::microsoft_logo(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG. ?></span>
<div>
<h1><?php esc_html_e( 'M365 Login', 'm365-login' ); ?></h1>
<p><?php esc_html_e( 'Let existing users sign in with their Microsoft 365 / Entra ID account.', 'm365-login' ); ?></p>
</div>
</div>
<span class="m365-admin__status <?php echo $configured ? 'is-ok' : 'is-pending'; ?>">
<span class="m365-admin__status-dot"></span>
<?php echo $configured ? esc_html__( 'Connected', 'm365-login' ) : esc_html__( 'Setup incomplete', 'm365-login' ); ?>
</span>
</header>
<form method="post" action="options.php" class="m365-admin__form" novalidate>
<?php settings_fields( self::GROUP ); ?>
<nav class="m365-admin__tabs" role="tablist">
<button type="button" class="m365-admin__tab is-active" role="tab" data-tab="connection" aria-selected="true"><?php esc_html_e( 'Connection', 'm365-login' ); ?></button>
<button type="button" class="m365-admin__tab" role="tab" data-tab="button" aria-selected="false"><?php esc_html_e( 'Button', 'm365-login' ); ?></button>
<button type="button" class="m365-admin__tab" role="tab" data-tab="security" aria-selected="false"><?php esc_html_e( 'Security', 'm365-login' ); ?></button>
</nav>
<div class="m365-admin__layout">
<div class="m365-admin__main">
<!-- Connection -->
<section class="m365-admin__panel is-active" data-panel="connection">
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Microsoft Entra ID app registration', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Enter the values from your app registration in the Microsoft Entra admin center.', 'm365-login' ); ?></p>
<div class="m365-field">
<label for="m365-tenant"><?php esc_html_e( 'Directory (tenant) ID', 'm365-login' ); ?></label>
<div class="m365-field__row">
<input type="text" id="m365-tenant" name="<?php echo $field( 'tenant_id' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s['tenant_id'] ); ?>" class="regular-text code" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off" spellcheck="false" />
<button type="button" class="button" id="m365-test"><?php esc_html_e( 'Test tenant', 'm365-login' ); ?></button>
</div>
<p class="description"><?php esc_html_e( 'Recommended: the GUID of your tenant. Only sign-ins from this tenant are accepted. "organizations" allows any work or school account.', 'm365-login' ); ?></p>
<div id="m365-test-result" class="m365-inline-result" hidden></div>
</div>
<div class="m365-field">
<label for="m365-client-id"><?php esc_html_e( 'Application (client) ID', 'm365-login' ); ?></label>
<input type="text" id="m365-client-id" name="<?php echo $field( 'client_id' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s['client_id'] ); ?>" class="regular-text code" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off" spellcheck="false" />
</div>
<div class="m365-field">
<label for="m365-client-secret"><?php esc_html_e( 'Client secret', 'm365-login' ); ?></label>
<div class="m365-field__row">
<input type="password" id="m365-client-secret" name="<?php echo $field( 'client_secret' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="" class="regular-text code" autocomplete="new-password" placeholder="<?php echo $has_secret ? esc_attr__( '•••••••••••• (stored, leave empty to keep)', 'm365-login' ) : esc_attr__( 'Paste the secret value', 'm365-login' ); ?>" />
<button type="button" class="button m365-toggle-secret" aria-label="<?php esc_attr_e( 'Show secret', 'm365-login' ); ?>"><span class="dashicons dashicons-visibility"></span></button>
</div>
<?php if ( $has_secret ) : ?>
<label class="m365-check m365-check--inline">
<input type="checkbox" name="<?php echo $field( 'client_secret_clear' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" />
<?php esc_html_e( 'Remove the stored secret', 'm365-login' ); ?>
</label>
<?php endif; ?>
<p class="description"><?php esc_html_e( 'Stored encrypted (AES-256-GCM, key derived from your WordPress salts) and never displayed again. Client secrets expire note the expiry date in Entra ID.', 'm365-login' ); ?></p>
</div>
<div class="m365-field">
<label for="m365-prompt"><?php esc_html_e( 'Account prompt', 'm365-login' ); ?></label>
<select id="m365-prompt" name="<?php echo $field( 'prompt' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
<option value="select_account" <?php selected( $s['prompt'], 'select_account' ); ?>><?php esc_html_e( 'Always let the user pick an account (recommended)', 'm365-login' ); ?></option>
<option value="none" <?php selected( $s['prompt'], 'none' ); ?>><?php esc_html_e( 'Use the current Microsoft session if available', 'm365-login' ); ?></option>
<option value="login" <?php selected( $s['prompt'], 'login' ); ?>><?php esc_html_e( 'Always require re-entering credentials', 'm365-login' ); ?></option>
</select>
</div>
</div>
</section>
<!-- Button -->
<section class="m365-admin__panel" data-panel="button">
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Appearance', 'm365-login' ); ?></h2>
<div class="m365-preview">
<span class="m365-preview__label"><?php esc_html_e( 'Live preview', 'm365-login' ); ?></span>
<div class="m365-preview__stage">
<div class="m365-login m365-login--preview" id="m365-preview" style="<?php echo esc_attr( str_replace( array( '.m365-login{', '}' ), '', M365_Login::instance()->button->css_variables() ) ); ?>">
<div class="m365-login__divider"><span id="m365-preview-divider"><?php echo esc_html( $s['divider_text'] ); ?></span></div>
<a class="m365-login__button" href="#" onclick="return false;" id="m365-preview-button">
<span id="m365-preview-icon"><?php echo M365_Login_Button::microsoft_logo(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG. ?></span>
<span class="m365-login__label" id="m365-preview-text"><?php echo esc_html( $s['button_text'] ); ?></span>
</a>
</div>
</div>
</div>
<div class="m365-grid">
<div class="m365-field">
<label for="m365-button-text"><?php esc_html_e( 'Button text', 'm365-login' ); ?></label>
<input type="text" id="m365-button-text" name="<?php echo $field( 'button_text' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s['button_text'] ); ?>" class="regular-text" maxlength="80" data-preview="text" />
</div>
<div class="m365-field">
<label for="m365-divider-text"><?php esc_html_e( 'Divider text', 'm365-login' ); ?></label>
<input type="text" id="m365-divider-text" name="<?php echo $field( 'divider_text' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s['divider_text'] ); ?>" class="regular-text" maxlength="40" data-preview="divider" />
<p class="description"><?php esc_html_e( 'Leave empty to hide the divider line.', 'm365-login' ); ?></p>
</div>
</div>
<div class="m365-field">
<span class="m365-field__label"><?php esc_html_e( 'Icon', 'm365-login' ); ?></span>
<label class="m365-check">
<input type="checkbox" name="<?php echo $field( 'button_show_icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['button_show_icon'] ); ?> data-preview="show-icon" />
<?php esc_html_e( 'Show an icon on the button', 'm365-login' ); ?>
</label>
<div class="m365-icon-picker">
<div class="m365-icon-picker__thumb" id="m365-icon-thumb">
<?php if ( '' !== $s['button_icon'] ) : ?>
<img src="<?php echo esc_url( $s['button_icon'] ); ?>" alt="" />
<?php else : ?>
<?php echo M365_Login_Button::microsoft_logo(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG. ?>
<?php endif; ?>
</div>
<div class="m365-icon-picker__controls">
<input type="url" id="m365-icon-url" name="<?php echo $field( 'button_icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_url( $s['button_icon'] ); ?>" class="regular-text code" placeholder="<?php esc_attr_e( 'Default: Microsoft logo', 'm365-login' ); ?>" data-preview="icon" />
<div class="m365-field__row">
<button type="button" class="button" id="m365-icon-choose"><?php esc_html_e( 'Choose from media library', 'm365-login' ); ?></button>
<button type="button" class="button-link m365-link-danger" id="m365-icon-reset"><?php esc_html_e( 'Use Microsoft logo', 'm365-login' ); ?></button>
</div>
<p class="description"><?php esc_html_e( 'PNG, SVG, JPG or WebP. Square images (e.g. 64×64 px) work best.', 'm365-login' ); ?></p>
</div>
</div>
</div>
<div class="m365-grid m365-grid--4">
<?php
$colors = array(
'button_bg' => __( 'Background', 'm365-login' ),
'button_bg_hover' => __( 'Background (hover)', 'm365-login' ),
'button_color' => __( 'Text colour', 'm365-login' ),
'button_border' => __( 'Border', 'm365-login' ),
);
foreach ( $colors as $key => $label ) :
?>
<div class="m365-field">
<label for="m365-<?php echo esc_attr( $key ); ?>"><?php echo esc_html( $label ); ?></label>
<input type="text" id="m365-<?php echo esc_attr( $key ); ?>" name="<?php echo $field( $key ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s[ $key ] ); ?>" class="m365-color" data-default-color="<?php echo esc_attr( $this->settings->defaults()[ $key ] ); ?>" data-preview="<?php echo esc_attr( str_replace( 'button_', '', $key ) ); ?>" />
</div>
<?php endforeach; ?>
</div>
<div class="m365-grid">
<div class="m365-field">
<label for="m365-radius"><?php esc_html_e( 'Corner radius', 'm365-login' ); ?> <span class="m365-range-value" id="m365-radius-value"><?php echo esc_html( $s['button_radius'] ); ?> px</span></label>
<input type="range" id="m365-radius" name="<?php echo $field( 'button_radius' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s['button_radius'] ); ?>" min="0" max="50" step="1" data-preview="radius" />
</div>
<div class="m365-field">
<label for="m365-position"><?php esc_html_e( 'Position on the login page', 'm365-login' ); ?></label>
<select id="m365-position" name="<?php echo $field( 'button_position' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
<option value="below" <?php selected( $s['button_position'], 'below' ); ?>><?php esc_html_e( 'Below the login form', 'm365-login' ); ?></option>
<option value="above" <?php selected( $s['button_position'], 'above' ); ?>><?php esc_html_e( 'Above the login form', 'm365-login' ); ?></option>
</select>
</div>
</div>
<div class="m365-presets">
<span class="m365-field__label"><?php esc_html_e( 'Quick presets', 'm365-login' ); ?></span>
<button type="button" class="m365-preset" data-preset='{"bg":"#2f2f2f","bg_hover":"#1a1a1a","color":"#ffffff","border":"#2f2f2f"}'><span style="background:#2f2f2f"></span><?php esc_html_e( 'Microsoft dark', 'm365-login' ); ?></button>
<button type="button" class="m365-preset" data-preset='{"bg":"#ffffff","bg_hover":"#f3f3f3","color":"#5e5e5e","border":"#8c8c8c"}'><span style="background:#ffffff;border-color:#8c8c8c"></span><?php esc_html_e( 'Microsoft light', 'm365-login' ); ?></button>
<button type="button" class="m365-preset" data-preset='{"bg":"#0078d4","bg_hover":"#106ebe","color":"#ffffff","border":"#0078d4"}'><span style="background:#0078d4"></span><?php esc_html_e( 'Azure blue', 'm365-login' ); ?></button>
<button type="button" class="m365-preset" data-preset='{"bg":"#2271b1","bg_hover":"#135e96","color":"#ffffff","border":"#2271b1"}'><span style="background:#2271b1"></span><?php esc_html_e( 'WordPress blue', 'm365-login' ); ?></button>
</div>
</div>
</section>
<!-- Security -->
<section class="m365-admin__panel" data-panel="security">
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'User matching & hardening', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Users are never created automatically. A Microsoft sign-in only succeeds when a WordPress user with the same e-mail address already exists.', 'm365-login' ); ?></p>
<label class="m365-check m365-check--block">
<input type="checkbox" name="<?php echo $field( 'bind_oid' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['bind_oid'] ); ?> />
<span>
<strong><?php esc_html_e( 'Bind WordPress accounts to the Microsoft object ID', 'm365-login' ); ?></strong>
<em><?php esc_html_e( 'On first sign-in the immutable Microsoft object ID is stored with the user. Later sign-ins with the same e-mail but a different Microsoft identity are rejected. Strongly recommended.', 'm365-login' ); ?></em>
</span>
</label>
<label class="m365-check m365-check--block">
<input type="checkbox" name="<?php echo $field( 'upn_fallback' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['upn_fallback'] ); ?> />
<span>
<strong><?php esc_html_e( 'Fall back to the user principal name (UPN)', 'm365-login' ); ?></strong>
<em><?php esc_html_e( 'If the token contains no "email" claim, use the UPN (e.g. jane@contoso.com) when it is a valid e-mail address. Usually required for work accounts.', 'm365-login' ); ?></em>
</span>
</label>
<label class="m365-check m365-check--block">
<input type="checkbox" name="<?php echo $field( 'remember_me' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['remember_me'] ); ?> />
<span>
<strong><?php esc_html_e( 'Keep users signed in ("Remember me")', 'm365-login' ); ?></strong>
<em><?php esc_html_e( 'Issues a 14-day WordPress session instead of a browser session.', 'm365-login' ); ?></em>
</span>
</label>
<div class="m365-field">
<label for="m365-domains"><?php esc_html_e( 'Allowed e-mail domains (optional)', 'm365-login' ); ?></label>
<textarea id="m365-domains" name="<?php echo $field( 'allowed_domains' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" rows="3" class="large-text code" placeholder="contoso.com, contoso.de"><?php echo esc_textarea( $s['allowed_domains'] ); ?></textarea>
<p class="description"><?php esc_html_e( 'One or more domains separated by commas or new lines. Leave empty to allow any domain of your tenant.', 'm365-login' ); ?></p>
</div>
</div>
<div class="m365-card m365-card--muted">
<h2 class="m365-card__title"><?php esc_html_e( 'What the plugin does to keep sign-ins safe', 'm365-login' ); ?></h2>
<ul class="m365-list">
<li><?php esc_html_e( 'OpenID Connect authorization code flow with PKCE (S256) no tokens ever pass through the browser.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Single-use state and nonce values bound to the browser via an HttpOnly cookie (CSRF and replay protection).', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'ID token signature verified against Microsofts published signing keys; issuer, audience, tenant, expiry and nonce are checked.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Client secret encrypted at rest; no accounts are created, no passwords are changed.', 'm365-login' ); ?></li>
</ul>
</div>
</section>
<div class="m365-admin__actions">
<?php submit_button( __( 'Save changes', 'm365-login' ), 'primary large', 'submit', false ); ?>
</div>
</div>
<aside class="m365-admin__sidebar">
<div class="m365-card m365-card--accent">
<h2 class="m365-card__title"><?php esc_html_e( 'Redirect URI', 'm365-login' ); ?></h2>
<p><?php esc_html_e( 'Register this URI in your app registration under Authentication → Web → Redirect URIs:', 'm365-login' ); ?></p>
<div class="m365-copy">
<code id="m365-redirect-uri"><?php echo esc_html( $this->settings->redirect_uri() ); ?></code>
<button type="button" class="button button-small m365-copy__button" data-copy="m365-redirect-uri"><?php esc_html_e( 'Copy', 'm365-login' ); ?></button>
</div>
<?php if ( ! $this->settings->uses_pretty_callback() ) : ?>
<p class="description"><?php esc_html_e( 'Plain permalinks are active, so the callback uses a query string. If you enable pretty permalinks later, the redirect URI changes and must be updated in Entra ID.', 'm365-login' ); ?></p>
<?php endif; ?>
<?php if ( ! is_ssl() && 'https' !== wp_parse_url( home_url(), PHP_URL_SCHEME ) ) : ?>
<p class="m365-warning"><?php esc_html_e( 'Your site does not use HTTPS. Microsoft only accepts http:// redirect URIs for localhost; production sites must use HTTPS.', 'm365-login' ); ?></p>
<?php endif; ?>
</div>
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Setup in 5 steps', 'm365-login' ); ?></h2>
<ol class="m365-steps">
<li><?php esc_html_e( 'Open the Microsoft Entra admin center → App registrations → New registration.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Choose "Accounts in this organizational directory only", set the platform to Web and paste the redirect URI above.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Copy the Application (client) ID and Directory (tenant) ID from the overview page.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Under Certificates & secrets create a client secret and copy its value (not the ID).', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Under Token configuration add the optional claim "email" for ID tokens (recommended), then save this page.', 'm365-login' ); ?></li>
</ol>
<p class="description"><?php esc_html_e( 'Required API permission: openid, profile, email (delegated) granted by default.', 'm365-login' ); ?></p>
</div>
<div class="m365-card m365-card--muted">
<h2 class="m365-card__title"><?php esc_html_e( 'Shortcode', 'm365-login' ); ?></h2>
<p><?php esc_html_e( 'Place the button on a custom login page:', 'm365-login' ); ?></p>
<code>[m365_login_button redirect="/my-account/"]</code>
</div>
</aside>
</div>
</form>
</div>
<?php
}
}

View file

@ -0,0 +1,617 @@
<?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 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;
/**
* Constructor.
*
* @param M365_Login_Settings $settings Settings.
*/
public function __construct( M365_Login_Settings $settings ) {
$this->settings = $settings;
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 );
}
/* ------------------------------------------------------------------ */
/* 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' );
}
// Bind the account to the immutable Microsoft object ID after first login.
$oid = isset( $claims['oid'] ) && is_string( $claims['oid'] ) ? strtolower( $claims['oid'] ) : '';
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;
}
/**
* 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( $token, time() + self::STATE_TTL );
}
/**
* Removes the state cookie.
*/
private function clear_state_cookie() {
$this->send_cookie( '', time() - YEAR_IN_SECONDS );
}
/**
* Cookie helper: HttpOnly, SameSite=Lax (needed for the top-level redirect back), Secure on HTTPS.
*
* @param string $value Value.
* @param int $expires Expiry timestamp.
*/
private function send_cookie( $value, $expires ) {
$path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
$path = is_string( $path ) && '' !== $path ? $path : '/';
setcookie(
self::STATE_COOKIE,
$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();
wp_safe_redirect( add_query_arg( 'm365_error', rawurlencode( $code ), wp_login_url() ) );
exit;
}
/**
* Writes to the PHP error log when WP_DEBUG_LOG is enabled.
*
* @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
}
}
/**
* Maps error codes to messages on the login screen.
*
* @param WP_Error $errors Login errors.
* @return WP_Error
*/
public function login_errors( $errors ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a whitelisted error code.
$code = isset( $_GET['m365_error'] ) ? sanitize_key( wp_unslash( $_GET['m365_error'] ) ) : '';
if ( '' === $code ) {
return $errors;
}
$messages = 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' ),
);
if ( ! $errors instanceof WP_Error ) {
$errors = new WP_Error();
}
$errors->add(
'm365_login_' . $code,
isset( $messages[ $code ] ) ? $messages[ $code ] : $messages['provider_error'],
'access_denied' === $code ? 'message' : 'error'
);
return $errors;
}
}

View file

@ -0,0 +1,191 @@
<?php
/**
* Login page button.
*
* @package M365_Login
*/
defined( 'ABSPATH' ) || exit;
/**
* Renders the "Sign in with Microsoft" button on wp-login.php.
*/
class M365_Login_Button {
/**
* Settings.
*
* @var M365_Login_Settings
*/
private $settings;
/**
* Constructor.
*
* @param M365_Login_Settings $settings Settings.
*/
public function __construct( M365_Login_Settings $settings ) {
$this->settings = $settings;
add_action( 'login_enqueue_scripts', array( $this, 'enqueue' ) );
add_filter( 'login_message', array( $this, 'render_above' ), 20 );
add_action( 'login_footer', array( $this, 'render_below' ) );
add_shortcode( 'm365_login_button', array( $this, 'shortcode' ) );
}
/**
* Whether the button should be shown for the current login screen.
*
* @return bool
*/
private function should_render() {
if ( ! $this->settings->is_configured() ) {
return false;
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only routing check.
$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : 'login';
$interim = ! empty( $_REQUEST['interim-login'] );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
if ( $interim || ! in_array( $action, array( '', 'login' ), true ) ) {
return false;
}
/**
* Filters whether the Microsoft button is displayed on the login screen.
*
* @param bool $show Show the button.
*/
return (bool) apply_filters( 'm365_login_show_button', true );
}
/**
* Enqueues login styles and the small positioning script.
*/
public function enqueue() {
if ( ! $this->should_render() ) {
return;
}
wp_enqueue_style( 'm365-login', M365_LOGIN_URL . 'assets/css/login.css', array(), M365_LOGIN_VERSION );
wp_add_inline_style( 'm365-login', $this->css_variables() );
if ( 'below' === $this->settings->get( 'button_position' ) ) {
wp_enqueue_script( 'm365-login', M365_LOGIN_URL . 'assets/js/login.js', array(), M365_LOGIN_VERSION, true );
}
}
/**
* CSS custom properties derived from the settings.
*
* @return string
*/
public function css_variables() {
$s = $this->settings->all();
return sprintf(
'.m365-login{--m365-bg:%1$s;--m365-bg-hover:%2$s;--m365-color:%3$s;--m365-border:%4$s;--m365-radius:%5$dpx;}',
sanitize_hex_color( $s['button_bg'] ),
sanitize_hex_color( $s['button_bg_hover'] ),
sanitize_hex_color( $s['button_color'] ),
sanitize_hex_color( $s['button_border'] ),
absint( $s['button_radius'] )
);
}
/**
* Output above the form (via login_message).
*
* @param string $message Existing message HTML.
* @return string
*/
public function render_above( $message ) {
if ( 'above' !== $this->settings->get( 'button_position' ) || ! $this->should_render() ) {
return $message;
}
return $message . $this->markup( 'above' );
}
/**
* Output below the form (moved into place by login.js).
*/
public function render_below() {
if ( 'below' !== $this->settings->get( 'button_position' ) || ! $this->should_render() ) {
return;
}
echo $this->markup( 'below' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- markup() escapes everything.
}
/**
* Shortcode for placing the button on custom login pages.
*
* @param array $atts Attributes.
* @return string
*/
public function shortcode( $atts ) {
if ( ! $this->settings->is_configured() || is_user_logged_in() ) {
return '';
}
$atts = shortcode_atts( array( 'redirect' => '' ), $atts, 'm365_login_button' );
wp_enqueue_style( 'm365-login', M365_LOGIN_URL . 'assets/css/login.css', array(), M365_LOGIN_VERSION );
wp_add_inline_style( 'm365-login', $this->css_variables() );
return '<div class="m365-login m365-login--shortcode">' . $this->button( esc_url_raw( $atts['redirect'] ) ) . '</div>';
}
/**
* Full block: divider + button.
*
* @param string $position 'above' or 'below'.
* @return string
*/
public function markup( $position ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- passed through to the flow, validated there.
$redirect_to = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : '';
$divider = (string) $this->settings->get( 'divider_text' );
$divider = '' === trim( $divider ) ? '' : '<div class="m365-login__divider" aria-hidden="true"><span>' . esc_html( $divider ) . '</span></div>';
$html = '<div class="m365-login m365-login--' . esc_attr( $position ) . '" id="m365-login-block">';
$html .= 'above' === $position ? $this->button( $redirect_to ) . $divider : $divider . $this->button( $redirect_to );
$html .= '</div>';
return $html;
}
/**
* Button markup.
*
* @param string $redirect_to Post-login destination.
* @return string
*/
public function button( $redirect_to = '' ) {
$auth = M365_Login::instance()->auth;
$url = $auth->start_url( $redirect_to );
$icon = '';
if ( $this->settings->get( 'button_show_icon' ) ) {
$custom = (string) $this->settings->get( 'button_icon' );
if ( '' !== $custom && M365_Login_Settings::is_safe_image_url( $custom ) ) {
$icon = '<img class="m365-login__icon" src="' . esc_url( $custom ) . '" alt="" width="20" height="20" loading="lazy" />';
} else {
$icon = self::microsoft_logo();
}
}
return '<a class="m365-login__button" href="' . esc_url( $url ) . '" rel="nofollow">'
. $icon
. '<span class="m365-login__label">' . esc_html( $this->settings->get( 'button_text' ) ) . '</span>'
. '</a>';
}
/**
* Bundled Microsoft logo (inline SVG, four coloured squares).
*
* @return string
*/
public static function microsoft_logo() {
return '<svg class="m365-login__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" width="20" height="20" aria-hidden="true" focusable="false">'
. '<path fill="#f25022" d="M1 1h10v10H1z"/>'
. '<path fill="#7fba00" d="M12 1h10v10H12z"/>'
. '<path fill="#00a4ef" d="M1 12h10v10H1z"/>'
. '<path fill="#ffb900" d="M12 12h10v10H12z"/>'
. '</svg>';
}
}

View file

@ -0,0 +1,85 @@
<?php
/**
* Symmetric encryption for secrets at rest.
*
* @package M365_Login
*/
defined( 'ABSPATH' ) || exit;
/**
* AES-256-GCM helper keyed from the WordPress salts.
*
* The key is derived from AUTH_KEY / SECURE_AUTH_KEY (via wp_salt()), so the
* stored client secret is useless without access to wp-config.php.
*/
final class M365_Login_Crypto {
const PREFIX = 'm365v1:';
const CIPHER = 'aes-256-gcm';
/**
* Derives the encryption key.
*
* @return string 32 raw bytes.
*/
private static function key() {
$material = wp_salt( 'auth' ) . '|' . wp_salt( 'secure_auth' ) . '|m365-login';
if ( function_exists( 'hash_hkdf' ) ) {
return hash_hkdf( 'sha256', $material, 32, 'm365-login-client-secret' );
}
return hash( 'sha256', $material, true );
}
/**
* Whether encryption is available.
*
* @return bool
*/
public static function available() {
return function_exists( 'openssl_encrypt' ) && in_array( self::CIPHER, openssl_get_cipher_methods(), true );
}
/**
* Encrypts a string.
*
* @param string $plain Plain text.
* @return string|false
*/
public static function encrypt( $plain ) {
if ( ! self::available() ) {
return false;
}
$iv = random_bytes( 12 );
$tag = '';
$ct = openssl_encrypt( $plain, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag, '', 16 );
if ( false === $ct || '' === $tag ) {
return false;
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return self::PREFIX . base64_encode( $iv . $tag . $ct );
}
/**
* Decrypts a string produced by encrypt().
*
* @param string $stored Stored value.
* @return string|false
*/
public static function decrypt( $stored ) {
if ( ! is_string( $stored ) || 0 !== strpos( $stored, self::PREFIX ) || ! self::available() ) {
return false;
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
$raw = base64_decode( substr( $stored, strlen( self::PREFIX ) ), true );
if ( false === $raw || strlen( $raw ) < 28 ) {
return false;
}
$iv = substr( $raw, 0, 12 );
$tag = substr( $raw, 12, 16 );
$ct = substr( $raw, 28 );
$plain = openssl_decrypt( $ct, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag );
return false === $plain ? false : $plain;
}
}

View file

@ -0,0 +1,236 @@
<?php
/**
* ID token validation.
*
* @package M365_Login
*/
defined( 'ABSPATH' ) || exit;
/**
* Verifies RS256 signed JWTs against the Microsoft JWKS.
*/
final class M365_Login_JWT {
const LEEWAY = 120; // Seconds of clock skew tolerated.
/**
* Base64url decode.
*
* @param string $data Data.
* @return string|false
*/
public static function b64url_decode( $data ) {
$data = strtr( $data, '-_', '+/' );
$pad = strlen( $data ) % 4;
if ( $pad ) {
$data .= str_repeat( '=', 4 - $pad );
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
return base64_decode( $data, true );
}
/**
* Base64url encode.
*
* @param string $data Data.
* @return string
*/
public static function b64url_encode( $data ) {
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
}
/**
* Decodes and verifies a JWT.
*
* @param string $jwt Compact serialised token.
* @param array $jwks JWKS document (array with 'keys').
* @param array $expected Expected claims: 'aud', 'nonce', 'tenant' (optional GUID).
* @return array|WP_Error Claims on success.
*/
public static function verify( $jwt, $jwks, $expected ) {
$parts = explode( '.', $jwt );
if ( 3 !== count( $parts ) ) {
return new WP_Error( 'jwt_format', 'Malformed token.' );
}
list( $h64, $p64, $s64 ) = $parts;
$header = json_decode( self::b64url_decode( $h64 ), true );
$claims = json_decode( self::b64url_decode( $p64 ), true );
$signature = self::b64url_decode( $s64 );
if ( ! is_array( $header ) || ! is_array( $claims ) || false === $signature ) {
return new WP_Error( 'jwt_format', 'Malformed token.' );
}
// Only RS256 is accepted; refuse "none" and anything HMAC based.
if ( empty( $header['alg'] ) || 'RS256' !== $header['alg'] ) {
return new WP_Error( 'jwt_alg', 'Unsupported token algorithm.' );
}
if ( empty( $header['kid'] ) || ! is_string( $header['kid'] ) ) {
return new WP_Error( 'jwt_kid', 'Token has no key ID.' );
}
$public_key = self::find_key( $jwks, $header['kid'] );
if ( ! $public_key ) {
return new WP_Error( 'jwt_unknown_kid', 'Signing key not found.' );
}
$ok = openssl_verify( $h64 . '.' . $p64, $signature, $public_key, OPENSSL_ALGO_SHA256 );
if ( 1 !== $ok ) {
return new WP_Error( 'jwt_signature', 'Token signature is invalid.' );
}
$now = time();
if ( empty( $claims['exp'] ) || ! is_numeric( $claims['exp'] ) || ( (int) $claims['exp'] + self::LEEWAY ) < $now ) {
return new WP_Error( 'jwt_expired', 'Token has expired.' );
}
if ( isset( $claims['nbf'] ) && is_numeric( $claims['nbf'] ) && ( (int) $claims['nbf'] - self::LEEWAY ) > $now ) {
return new WP_Error( 'jwt_nbf', 'Token is not valid yet.' );
}
if ( isset( $claims['iat'] ) && is_numeric( $claims['iat'] ) && ( (int) $claims['iat'] - self::LEEWAY ) > $now ) {
return new WP_Error( 'jwt_iat', 'Token issued in the future.' );
}
// Audience must be our client ID.
$aud = isset( $claims['aud'] ) ? $claims['aud'] : null;
if ( is_array( $aud ) ) {
$aud_ok = in_array( $expected['aud'], $aud, true );
} else {
$aud_ok = is_string( $aud ) && hash_equals( $expected['aud'], $aud );
}
if ( ! $aud_ok ) {
return new WP_Error( 'jwt_aud', 'Token audience mismatch.' );
}
// Tenant / issuer: v2.0 issuer is https://login.microsoftonline.com/{tid}/v2.0.
if ( empty( $claims['tid'] ) || ! is_string( $claims['tid'] ) || ! M365_Login_Settings::is_guid( $claims['tid'] ) ) {
return new WP_Error( 'jwt_tid', 'Token has no tenant ID.' );
}
if ( ! empty( $expected['tenant'] ) && ! hash_equals( strtolower( $expected['tenant'] ), strtolower( $claims['tid'] ) ) ) {
return new WP_Error( 'jwt_tenant', 'Token was issued by a different tenant.' );
}
$expected_iss = 'https://login.microsoftonline.com/' . strtolower( $claims['tid'] ) . '/v2.0';
if ( empty( $claims['iss'] ) || ! is_string( $claims['iss'] ) || ! hash_equals( $expected_iss, strtolower( $claims['iss'] ) ) ) {
return new WP_Error( 'jwt_iss', 'Token issuer mismatch.' );
}
// Nonce binds the token to the login attempt.
if ( empty( $claims['nonce'] ) || ! is_string( $claims['nonce'] ) || ! hash_equals( $expected['nonce'], $claims['nonce'] ) ) {
return new WP_Error( 'jwt_nonce', 'Token nonce mismatch.' );
}
return $claims;
}
/**
* Finds a key by kid and returns an OpenSSL public key resource/object.
*
* @param array $jwks JWKS document.
* @param string $kid Key ID.
* @return mixed|null
*/
private static function find_key( $jwks, $kid ) {
if ( empty( $jwks['keys'] ) || ! is_array( $jwks['keys'] ) ) {
return null;
}
foreach ( $jwks['keys'] as $key ) {
if ( ! is_array( $key ) || empty( $key['kid'] ) || ! hash_equals( (string) $key['kid'], $kid ) ) {
continue;
}
if ( isset( $key['kty'] ) && 'RSA' !== $key['kty'] ) {
continue;
}
if ( isset( $key['use'] ) && 'sig' !== $key['use'] ) {
continue;
}
// Prefer the embedded certificate, fall back to modulus/exponent.
if ( ! empty( $key['x5c'][0] ) && is_string( $key['x5c'][0] ) ) {
$pem = "-----BEGIN CERTIFICATE-----\n" . chunk_split( $key['x5c'][0], 64, "\n" ) . "-----END CERTIFICATE-----\n";
$pub = openssl_pkey_get_public( $pem );
if ( $pub ) {
return $pub;
}
}
if ( ! empty( $key['n'] ) && ! empty( $key['e'] ) ) {
$pem = self::rsa_pem_from_components( $key['n'], $key['e'] );
if ( $pem ) {
$pub = openssl_pkey_get_public( $pem );
if ( $pub ) {
return $pub;
}
}
}
}
return null;
}
/**
* Builds a PEM encoded SubjectPublicKeyInfo from JWK modulus / exponent.
*
* @param string $n Base64url modulus.
* @param string $e Base64url exponent.
* @return string|false
*/
private static function rsa_pem_from_components( $n, $e ) {
$modulus = self::b64url_decode( $n );
$exponent = self::b64url_decode( $e );
if ( false === $modulus || false === $exponent ) {
return false;
}
$modulus = self::der_integer( $modulus );
$exponent = self::der_integer( $exponent );
$rsa_key = self::der_seq( $modulus . $exponent );
// rsaEncryption OID 1.2.840.113549.1.1.1 + NULL.
$alg_id = self::der_seq( "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01\x05\x00" );
$bit_str = "\x03" . self::der_len( strlen( $rsa_key ) + 1 ) . "\x00" . $rsa_key;
$spki = self::der_seq( $alg_id . $bit_str );
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return "-----BEGIN PUBLIC KEY-----\n" . chunk_split( base64_encode( $spki ), 64, "\n" ) . "-----END PUBLIC KEY-----\n";
}
/**
* DER length encoding.
*
* @param int $len Length.
* @return string
*/
private static function der_len( $len ) {
if ( $len < 128 ) {
return chr( $len );
}
$bytes = ltrim( pack( 'N', $len ), "\x00" );
return chr( 0x80 | strlen( $bytes ) ) . $bytes;
}
/**
* DER SEQUENCE.
*
* @param string $content Content.
* @return string
*/
private static function der_seq( $content ) {
return "\x30" . self::der_len( strlen( $content ) ) . $content;
}
/**
* DER INTEGER (unsigned big-endian input).
*
* @param string $bytes Raw bytes.
* @return string
*/
private static function der_integer( $bytes ) {
$bytes = ltrim( $bytes, "\x00" );
if ( '' === $bytes || ( ord( $bytes[0] ) & 0x80 ) ) {
$bytes = "\x00" . $bytes;
}
return "\x02" . self::der_len( strlen( $bytes ) ) . $bytes;
}
}

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 );
}
}

View file

@ -0,0 +1,125 @@
<?php
/**
* Plugin bootstrap.
*
* @package M365_Login
*/
defined( 'ABSPATH' ) || exit;
/**
* Wires the individual components together.
*/
final class M365_Login {
/**
* Singleton instance.
*
* @var M365_Login|null
*/
private static $instance = null;
/**
* Settings component.
*
* @var M365_Login_Settings
*/
public $settings;
/**
* Authentication component.
*
* @var M365_Login_Auth
*/
public $auth;
/**
* Login button component.
*
* @var M365_Login_Button
*/
public $button;
/**
* Admin component.
*
* @var M365_Login_Admin|null
*/
public $admin = null;
/**
* Returns the singleton.
*
* @return M365_Login
*/
public static function instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
private function __construct() {
add_action( 'init', array( $this, 'load_textdomain' ) );
$this->settings = new M365_Login_Settings();
$this->auth = new M365_Login_Auth( $this->settings );
$this->button = new M365_Login_Button( $this->settings );
if ( is_admin() ) {
$this->admin = new M365_Login_Admin( $this->settings, $this->auth );
}
add_filter( 'plugin_action_links_' . plugin_basename( M365_LOGIN_FILE ), array( $this, 'action_links' ) );
}
/**
* Loads bundled translations.
*/
public function load_textdomain() {
load_plugin_textdomain( 'm365-login', false, dirname( plugin_basename( M365_LOGIN_FILE ) ) . '/languages' );
}
/**
* Adds a "Settings" link on the plugins screen.
*
* @param string[] $links Existing links.
* @return string[]
*/
public function action_links( $links ) {
$url = admin_url( 'options-general.php?page=m365-login' );
array_unshift( $links, '<a href="' . esc_url( $url ) . '">' . esc_html__( 'Settings', 'm365-login' ) . '</a>' );
return $links;
}
/**
* Activation hook: seed defaults and check requirements.
*/
public static function activate() {
if ( version_compare( PHP_VERSION, '7.4', '<' ) ) {
deactivate_plugins( plugin_basename( M365_LOGIN_FILE ) );
wp_die(
esc_html__( 'M365 Login requires PHP 7.4 or newer.', 'm365-login' ),
esc_html__( 'Plugin activation failed', 'm365-login' ),
array( 'back_link' => true )
);
}
if ( ! function_exists( 'openssl_encrypt' ) ) {
deactivate_plugins( plugin_basename( M365_LOGIN_FILE ) );
wp_die(
esc_html__( 'M365 Login requires the PHP OpenSSL extension (needed to verify Microsoft token signatures and to encrypt the client secret).', 'm365-login' ),
esc_html__( 'Plugin activation failed', 'm365-login' ),
array( 'back_link' => true )
);
}
$settings = new M365_Login_Settings();
if ( false === get_option( M365_LOGIN_OPTION, false ) ) {
add_option( M365_LOGIN_OPTION, $settings->defaults(), '', 'no' );
}
}
}