Add certificate authentication, in-app setup guides and security audit

Certificate (RFC 7523 client assertion) as an alternative to the client
secret: one-click generation of a 3072-bit RSA key pair with a
self-signed certificate, .cer download (public part only), own PEM
upload with validation, expiry display, encrypted key storage. Both the
authorization code exchange and the Graph client-credentials request
use the selected method. Step-by-step guides for secret, certificate
and the app registration are shown in the settings.

Security audit (docs/security-audit.md) and fixes:
- Multi-tenant mode ignored the unverified email claim: matching now
  uses the UPN only, or the email claim when xms_edov is true.
- Login starts are rate limited per client (30 per 10 minutes).
- Optional trusted proxy header for client IPs
  (M365_LOGIN_CLIENT_IP_HEADER / filter).

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 15:04:32 +00:00
parent 966164177d
commit 8766927123
No known key found for this signature in database
19 changed files with 2515 additions and 644 deletions

View file

@ -16,6 +16,8 @@ class M365_Login_Admin {
const GROUP = 'm365_login';
const AJAX_TEST = 'm365_login_test_connection';
const AJAX_GROUPS = 'm365_login_search_groups';
const AJAX_CERT = 'm365_login_certificate';
const POST_CERT = 'm365_login_download_cert';
const NONCE_TEST = 'm365_login_test';
/**
@ -63,6 +65,8 @@ class M365_Login_Admin {
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
add_action( 'wp_ajax_' . self::AJAX_TEST, array( $this, 'ajax_test_connection' ) );
add_action( 'wp_ajax_' . self::AJAX_GROUPS, array( $this, 'ajax_search_groups' ) );
add_action( 'wp_ajax_' . self::AJAX_CERT, array( $this, 'ajax_certificate' ) );
add_action( 'admin_post_' . self::POST_CERT, array( $this, 'download_certificate' ) );
add_action( 'update_option_' . M365_LOGIN_OPTION, array( $this->graph, 'flush_token' ) );
add_action( 'admin_notices', array( $this, 'setup_notice' ) );
}
@ -140,6 +144,7 @@ class M365_Login_Admin {
'nonce' => wp_create_nonce( self::NONCE_TEST ),
'action' => self::AJAX_TEST,
'groupAction' => self::AJAX_GROUPS,
'certAction' => self::AJAX_CERT,
'defaultLogo' => M365_Login_Button::microsoft_logo(),
'i18n' => array(
'chooseIcon' => __( 'Choose button icon', 'm365-login' ),
@ -154,6 +159,9 @@ class M365_Login_Admin {
'remove' => __( 'Remove', 'm365-login' ),
'saveFirst' => __( 'Save the connection settings first, then search for groups.', 'm365-login' ),
'confirmKey' => __( 'Generate a new fallback key on save? The old link stops working.', 'm365-login' ),
'generating' => __( 'Generating a 3072-bit key pair, this takes a moment…', 'm365-login' ),
'confirmCert' => __( 'Replace the stored certificate? Sign-in stops working until the new certificate is uploaded to Entra ID.', 'm365-login' ),
'confirmCertRemove' => __( 'Remove the stored certificate when saving? Sign-in with the certificate method stops working.', 'm365-login' ),
),
)
);
@ -224,6 +232,63 @@ class M365_Login_Admin {
wp_send_json_success( array( 'groups' => $groups ) );
}
/**
* AJAX: generate a new self-signed certificate and store it (key encrypted).
*/
public function ajax_certificate() {
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 );
}
$op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : '';
if ( 'generate' !== $op ) {
wp_send_json_error( array( 'message' => __( 'Unknown operation.', 'm365-login' ) ) );
}
$host = wp_parse_url( home_url(), PHP_URL_HOST );
$pair = M365_Login_Certificate::generate( is_string( $host ) ? $host : 'wordpress' );
if ( is_wp_error( $pair ) ) {
wp_send_json_error( array( 'message' => $pair->get_error_message() ) );
}
$stored = $this->settings->store_certificate( $pair );
if ( is_wp_error( $stored ) ) {
wp_send_json_error( array( 'message' => $stored->get_error_message() ) );
}
$this->graph->flush_token();
$info = M365_Login_Certificate::info( $pair['certificate'] );
wp_send_json_success(
array(
'message' => __( 'Certificate generated and stored. Download the .cer file and upload it in Entra ID.', 'm365-login' ),
'thumbprint' => $info ? $info['thumbprint'] : '',
)
);
}
/**
* Sends the public certificate as a .cer download (never the private key).
*/
public function download_certificate() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to do this.', 'm365-login' ), 403 );
}
check_admin_referer( self::POST_CERT );
$pem = $this->settings->certificate_pem();
if ( '' === $pem ) {
wp_die( esc_html__( 'No certificate is stored.', 'm365-login' ), 404 );
}
$host = wp_parse_url( home_url(), PHP_URL_HOST );
$name = 'm365-login-' . sanitize_file_name( is_string( $host ) ? $host : 'wordpress' ) . '.cer';
nocache_headers();
header( 'Content-Type: application/x-x509-ca-cert' );
header( 'Content-Disposition: attachment; filename="' . $name . '"' );
header( 'Content-Length: ' . strlen( $pem ) );
echo $pem; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- PEM text, public certificate only.
exit;
}
/**
* Renders the settings screen.
*/
@ -235,6 +300,8 @@ class M365_Login_Admin {
$s = $this->settings->all();
$configured = $this->settings->is_configured();
$has_secret = '' !== $this->settings->client_secret();
$method = $this->settings->auth_method();
$cert_info = $this->settings->certificate_info();
$option = M365_LOGIN_OPTION;
$field = function ( $key ) use ( $option ) {
return esc_attr( $option . '[' . $key . ']' );
@ -280,6 +347,9 @@ class M365_Login_Admin {
<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>
<?php if ( $this->settings->is_multi_tenant() && '' !== $s['tenant_id'] ) : ?>
<p class="m365-warning m365-warning--strong"><?php esc_html_e( 'Multi-tenant mode: accounts from any Microsoft tenant can sign in. Their "email" attribute is not verified, so the plugin matches on the user principal name (verified domain) only and ignores the e-mail claim unless Microsoft marks it as domain-verified. Use the e-mail domain allow-list on the Security tab, or better, pin your tenant GUID.', 'm365-login' ); ?></p>
<?php endif; ?>
<div id="m365-test-result" class="m365-inline-result" hidden></div>
</div>
@ -289,18 +359,132 @@ class M365_Login_Admin {
</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' ); ?>
<span class="m365-field__label"><?php esc_html_e( 'How should WordPress authenticate to Microsoft?', 'm365-login' ); ?></span>
<div class="m365-method">
<label class="m365-method__option" data-method="secret">
<input type="radio" name="<?php echo $field( 'auth_method' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="secret" <?php checked( $method, 'secret' ); ?> />
<span>
<strong><?php esc_html_e( 'Client secret', 'm365-login' ); ?></strong>
<em><?php esc_html_e( 'Quick to set up. A password-like value created in Entra ID that expires after 624 months and must be renewed.', 'm365-login' ); ?></em>
</span>
</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>
<label class="m365-method__option" data-method="certificate">
<input type="radio" name="<?php echo $field( 'auth_method' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="certificate" <?php checked( $method, 'certificate' ); ?> />
<span>
<strong><?php esc_html_e( 'Certificate', 'm365-login' ); ?><span class="m365-method__badge"><?php esc_html_e( 'Recommended', 'm365-login' ); ?></span></strong>
<em><?php esc_html_e( 'The private key never leaves this server; only the public certificate is uploaded to Entra ID. Generated here with one click, valid for 2 years.', 'm365-login' ); ?></em>
</span>
</label>
</div>
</div>
<!-- Secret -->
<div class="m365-auth-panel" data-method="secret">
<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>
<details class="m365-guide" <?php echo $has_secret ? '' : 'open'; ?>>
<summary><?php esc_html_e( 'Step-by-step: create a client secret in Entra ID', 'm365-login' ); ?></summary>
<div class="m365-guide__body">
<ol>
<li><?php esc_html_e( 'Open entra.microsoft.com and sign in with an account that has the "Application Administrator" or "Global Administrator" role.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Go to Identity → Applications → App registrations and open your app (or create it first, see the general guide in the sidebar).', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'In the left menu choose Certificates & secrets, then the tab Client secrets, and click New client secret.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Enter a description such as "WordPress login" and pick an expiry. Microsoft allows at most 24 months; put a reminder in your calendar two weeks before.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Click Add. Copy the Value column immediately it is shown only once. The Secret ID column is NOT what you need.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Paste the value into the Client secret field above and save this page.', 'm365-login' ); ?></li>
</ol>
<p class="m365-guide__note"><?php esc_html_e( 'When the secret expires, sign-ins fail with "Could not complete the sign-in with Microsoft". Create a new secret, paste it here, save, then delete the old one in Entra ID.', 'm365-login' ); ?></p>
</div>
</details>
</div>
<!-- Certificate -->
<div class="m365-auth-panel" data-method="certificate">
<div class="m365-cert">
<?php if ( $cert_info ) : ?>
<?php
$days_left = (int) floor( ( $cert_info['not_after'] - time() ) / DAY_IN_SECONDS );
if ( $days_left < 0 ) {
$status_class = 'is-bad';
$status_text = __( 'Expired', 'm365-login' );
} elseif ( $days_left < 30 ) {
$status_class = 'is-warn';
/* translators: %d: number of days */
$status_text = sprintf( __( 'Expires in %d days', 'm365-login' ), $days_left );
} else {
$status_class = 'is-ok';
$status_text = __( 'Valid', 'm365-login' );
}
?>
<span class="m365-cert__status <?php echo esc_attr( $status_class ); ?>"><?php echo esc_html( $status_text ); ?></span>
<dl class="m365-cert__grid">
<dt><?php esc_html_e( 'Thumbprint (SHA-1)', 'm365-login' ); ?></dt>
<dd><code id="m365-cert-thumbprint"><?php echo esc_html( $cert_info['thumbprint'] ); ?></code> <button type="button" class="button button-small m365-copy__button" data-copy="m365-cert-thumbprint"><?php esc_html_e( 'Copy', 'm365-login' ); ?></button></dd>
<dt><?php esc_html_e( 'Subject', 'm365-login' ); ?></dt>
<dd><?php echo esc_html( $cert_info['subject'] ); ?></dd>
<dt><?php esc_html_e( 'Key size', 'm365-login' ); ?></dt>
<dd><?php echo esc_html( $cert_info['bits'] ); ?> Bit RSA</dd>
<dt><?php esc_html_e( 'Valid until', 'm365-login' ); ?></dt>
<dd><?php echo esc_html( wp_date( get_option( 'date_format' ), $cert_info['not_after'] ) ); ?></dd>
</dl>
<div class="m365-cert__actions">
<a class="button button-primary" href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=' . self::POST_CERT ), self::POST_CERT ) ); ?>"><?php esc_html_e( 'Download certificate (.cer)', 'm365-login' ); ?></a>
<button type="button" class="button" id="m365-cert-generate" data-replace="1"><?php esc_html_e( 'Generate new certificate', 'm365-login' ); ?></button>
<label class="m365-check m365-check--inline">
<input type="checkbox" name="<?php echo $field( 'cert_remove' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" id="m365-cert-remove" />
<?php esc_html_e( 'Remove certificate when saving', 'm365-login' ); ?>
</label>
</div>
<?php else : ?>
<p class="m365-cert__empty"><?php esc_html_e( 'No certificate stored yet.', 'm365-login' ); ?></p>
<div class="m365-cert__actions">
<button type="button" class="button button-primary" id="m365-cert-generate"><?php esc_html_e( 'Generate certificate', 'm365-login' ); ?></button>
<span class="description"><?php esc_html_e( '3072-bit RSA, self-signed, valid for 2 years. The private key is stored encrypted and never shown or downloadable.', 'm365-login' ); ?></span>
</div>
<?php endif; ?>
<div id="m365-cert-result" class="m365-inline-result" hidden></div>
<p class="description" style="margin-top:12px"><a href="#" id="m365-cert-paste-toggle"><?php esc_html_e( 'Use your own certificate instead (paste PEM)', 'm365-login' ); ?></a></p>
<div id="m365-cert-paste" hidden>
<div class="m365-field">
<label for="m365-cert-key"><?php esc_html_e( 'Private key (PEM, unencrypted)', 'm365-login' ); ?></label>
<textarea id="m365-cert-key" name="<?php echo $field( 'cert_key_pem' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" class="large-text m365-pem" rows="6" placeholder="-----BEGIN PRIVATE KEY-----" autocomplete="off" spellcheck="false"></textarea>
</div>
<div class="m365-field">
<label for="m365-cert-cert"><?php esc_html_e( 'Certificate (PEM)', 'm365-login' ); ?></label>
<textarea id="m365-cert-cert" name="<?php echo $field( 'cert_cert_pem' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" class="large-text m365-pem" rows="6" placeholder="-----BEGIN CERTIFICATE-----" spellcheck="false"></textarea>
<p class="description"><?php esc_html_e( 'RSA, at least 2048 bits. The pair is validated and the key is encrypted when you save. Both fields stay empty afterwards.', 'm365-login' ); ?></p>
</div>
</div>
</div>
<details class="m365-guide" <?php echo $cert_info ? 'open' : ''; ?>>
<summary><?php esc_html_e( 'Step-by-step: register the certificate in Entra ID', 'm365-login' ); ?></summary>
<div class="m365-guide__body">
<ol>
<li><?php esc_html_e( 'Click Generate certificate above (or paste your own). Then click Download certificate (.cer) the file contains only the public part.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Open entra.microsoft.com → Identity → Applications → App registrations and open your app.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Choose Certificates & secrets in the left menu, then the tab Certificates, and click Upload certificate.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Select the downloaded .cer file, add a description such as "WordPress login" and click Add.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Compare the thumbprint Entra ID shows with the thumbprint above they must match exactly.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Make sure Certificate is selected above and save this page. If a client secret was stored before, you may delete it in Entra ID now.', 'm365-login' ); ?></li>
</ol>
<p class="m365-guide__note"><?php esc_html_e( 'How it works: for every token request WordPress signs a short-lived JWT (client assertion) with the private key; Microsoft verifies it with the uploaded certificate. Nothing secret is ever transmitted.', 'm365-login' ); ?></p>
<p class="m365-guide__warn"><?php esc_html_e( 'Before the certificate expires: generate a new one here, upload it to Entra ID (both may be registered at the same time), save, then remove the old one from Entra ID. Sign-ins keep working during the switch.', 'm365-login' ); ?></p>
</div>
</details>
</div>
<div class="m365-field">
@ -580,13 +764,16 @@ class M365_Login_Admin {
</div>
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Setup in 5 steps', 'm365-login' ); ?></h2>
<h2 class="m365-card__title"><?php esc_html_e( 'Setup guide: app registration', '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>
<li><?php esc_html_e( 'Open entra.microsoft.com → Identity → Applications → App registrations → New registration.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Name: e.g. "WordPress login". Supported account types: "Accounts in this organizational directory only" (single tenant).', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Redirect URI: choose the platform Web and paste the URI shown above. Then click Register.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'On the Overview page copy the Application (client) ID and the Directory (tenant) ID into the Connection tab.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Authentication: leave "ID tokens" unchecked (the plugin uses the authorization code flow) and "Allow public client flows" on No.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Token configuration → Add optional claim → ID → tick "email" → Add. Confirm the API permission prompt.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Pick the authentication method on the Connection tab and follow its step-by-step guide (client secret or certificate).', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Optional: restrict who may use the app under Enterprise applications → your app → Properties → "Assignment required" = Yes, then assign users/groups.', 'm365-login' ); ?></li>
</ol>
<p class="description"><?php esc_html_e( 'Required API permission: openid, profile, email (delegated) granted by default.', 'm365-login' ); ?></p>
<p class="description"><?php esc_html_e( 'Optional, for group restrictions: application permissions GroupMember.Read.All and User.Read.All (Microsoft Graph) with admin consent.', 'm365-login' ); ?></p>

View file

@ -179,7 +179,23 @@ class M365_Login_Auth {
* @return string
*/
private function client_ip() {
return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0';
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0';
/**
* Name of a trusted proxy header (e.g. 'HTTP_CF_CONNECTING_IP' or 'HTTP_X_REAL_IP') that carries the
* real client IP. Only set this when every request passes through that proxy; the header is
* client-controlled otherwise. Defaults to the M365_LOGIN_CLIENT_IP_HEADER constant or none.
*
* @param string $header $_SERVER key or ''.
*/
$header = apply_filters( 'm365_login_client_ip_header', defined( 'M365_LOGIN_CLIENT_IP_HEADER' ) ? M365_LOGIN_CLIENT_IP_HEADER : '' );
if ( '' !== $header && ! empty( $_SERVER[ $header ] ) ) {
$candidate = trim( explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) )[0] );
if ( filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
$ip = $candidate;
}
}
return $ip;
}
/* ------------------------------------------------------------------ */
@ -257,6 +273,14 @@ class M365_Login_Auth {
$this->fail( 'not_configured' );
}
// Cap the number of pending login attempts one client can create (state records are stored server-side).
$throttle_key = 'm365_login_start_' . md5( $this->client_ip() );
$starts = (int) get_transient( $throttle_key );
if ( $starts >= 30 ) {
$this->fail( 'too_many_attempts' );
}
set_transient( $throttle_key, $starts + 1, self::STATE_TTL );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- redirect_to is validated with wp_validate_redirect() before use.
$redirect_to = isset( $_GET['redirect_to'] ) ? wp_validate_redirect( esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ), '' ) : '';
@ -489,19 +513,26 @@ class M365_Login_Auth {
* @return array|WP_Error
*/
private function exchange_code( $code, $verifier ) {
$auth = $this->settings->client_auth_params( $this->token_endpoint() );
if ( is_wp_error( $auth ) ) {
return $auth;
}
$response = wp_remote_post(
$this->token_endpoint(),
array(
'timeout' => self::HTTP_TIMEOUT,
'headers' => array( 'Accept' => 'application/json' ),
'body' => array(
'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',
'body' => array_merge(
array(
'client_id' => $this->settings->get( 'client_id' ),
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $this->settings->redirect_uri(),
'code_verifier' => $verifier,
'scope' => 'openid profile email',
),
$auth
),
)
);
@ -658,11 +689,26 @@ class M365_Login_Auth {
*/
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'];
$email = ! empty( $claims['email'] ) && is_string( $claims['email'] ) ? $claims['email'] : '';
$upn = ! empty( $claims['preferred_username'] ) && is_string( $claims['preferred_username'] ) ? $claims['preferred_username'] : '';
if ( $this->settings->is_multi_tenant() ) {
// In multi-tenant mode any tenant admin can set an arbitrary "email" attribute on their users.
// The UPN domain, on the other hand, must be verified in the issuing tenant, so it comes first;
// the e-mail claim is only used when Microsoft marks its domain as owner-verified (xms_edov).
if ( '' !== $upn ) {
$candidates[] = $upn;
}
if ( '' !== $email && ! empty( $claims['xms_edov'] ) && true === $claims['xms_edov'] ) {
$candidates[] = $email;
}
} else {
if ( '' !== $email ) {
$candidates[] = $email;
}
if ( $this->settings->get( 'upn_fallback' ) && '' !== $upn ) {
$candidates[] = $upn;
}
}
foreach ( $candidates as $candidate ) {
@ -810,6 +856,7 @@ class M365_Login_Auth {
'group_check_failed' => __( 'Your group membership could not be verified. Please contact an administrator.', 'm365-login' ),
'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ),
'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', 'm365-login' ),
'too_many_attempts' => __( 'Too many sign-in attempts from your connection. Please wait a few minutes and try again.', 'm365-login' ),
);
}

View file

@ -0,0 +1,226 @@
<?php
/**
* Certificate based client authentication (private_key_jwt / RFC 7523).
*
* @package M365_Login
*/
defined( 'ABSPATH' ) || exit;
/**
* Generates, validates and uses an RSA key pair + X.509 certificate for
* authenticating the app registration without a client secret.
*/
final class M365_Login_Certificate {
const KEY_BITS = 3072;
const VALID_DAYS = 730;
const ASSERTION_TTL = 300; // Seconds; Microsoft allows up to 10 minutes.
const EXPIRY_WARNING = 30 * DAY_IN_SECONDS;
/**
* Generates a new self-signed certificate for the given site.
*
* @param string $common_name Subject CN (host name of the site).
* @return array|WP_Error array( 'private_key' => PEM, 'certificate' => PEM ).
*/
public static function generate( $common_name ) {
if ( ! function_exists( 'openssl_pkey_new' ) ) {
return new WP_Error( 'no_openssl', __( 'The PHP OpenSSL extension is not available.', 'm365-login' ) );
}
$common_name = preg_replace( '/[^A-Za-z0-9.\-]/', '', (string) $common_name );
$common_name = '' === $common_name ? 'wordpress' : substr( $common_name, 0, 64 );
$key = openssl_pkey_new(
array(
'private_key_bits' => self::KEY_BITS,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
)
);
if ( false === $key ) {
return new WP_Error( 'keygen', self::openssl_error( __( 'Key generation failed.', 'm365-login' ) ) );
}
$dn = array(
'CN' => $common_name,
'O' => 'M365 Login for WordPress',
);
$csr = openssl_csr_new( $dn, $key, array( 'digest_alg' => 'sha256' ) );
if ( false === $csr ) {
return new WP_Error( 'csr', self::openssl_error( __( 'Certificate request failed.', 'm365-login' ) ) );
}
$cert = openssl_csr_sign( $csr, null, $key, self::VALID_DAYS, array( 'digest_alg' => 'sha256' ), (int) ( time() % PHP_INT_MAX ) );
if ( false === $cert ) {
return new WP_Error( 'sign', self::openssl_error( __( 'Certificate signing failed.', 'm365-login' ) ) );
}
$key_pem = '';
$cert_pem = '';
if ( ! openssl_pkey_export( $key, $key_pem ) || ! openssl_x509_export( $cert, $cert_pem ) ) {
return new WP_Error( 'export', self::openssl_error( __( 'Exporting the certificate failed.', 'm365-login' ) ) );
}
return array(
'private_key' => $key_pem,
'certificate' => $cert_pem,
);
}
/**
* Validates a user supplied key/certificate pair.
*
* @param string $key_pem Private key PEM (unencrypted).
* @param string $cert_pem Certificate PEM.
* @return array|WP_Error Normalised pair.
*/
public static function from_pem( $key_pem, $cert_pem ) {
$key_pem = self::normalise_pem( $key_pem );
$cert_pem = self::normalise_pem( $cert_pem );
if ( ! preg_match( '/-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/', $key_pem ) ) {
return new WP_Error( 'key_format', __( 'The private key must be in PEM format (-----BEGIN PRIVATE KEY-----).', 'm365-login' ) );
}
if ( false !== strpos( $key_pem, 'ENCRYPTED PRIVATE KEY' ) ) {
return new WP_Error( 'key_encrypted', __( 'Password protected private keys are not supported. Export the key without a passphrase.', 'm365-login' ) );
}
$key = openssl_pkey_get_private( $key_pem );
if ( false === $key ) {
return new WP_Error( 'key_invalid', self::openssl_error( __( 'The private key could not be read.', 'm365-login' ) ) );
}
$details = openssl_pkey_get_details( $key );
if ( ! is_array( $details ) || OPENSSL_KEYTYPE_RSA !== $details['type'] ) {
return new WP_Error( 'key_type', __( 'Only RSA keys are supported.', 'm365-login' ) );
}
if ( $details['bits'] < 2048 ) {
return new WP_Error( 'key_bits', __( 'The RSA key must have at least 2048 bits.', 'm365-login' ) );
}
$cert = openssl_x509_read( $cert_pem );
if ( false === $cert ) {
return new WP_Error( 'cert_invalid', self::openssl_error( __( 'The certificate could not be read. Paste it in PEM format (-----BEGIN CERTIFICATE-----).', 'm365-login' ) ) );
}
if ( ! openssl_x509_check_private_key( $cert, $key ) ) {
return new WP_Error( 'cert_mismatch', __( 'The certificate does not belong to this private key.', 'm365-login' ) );
}
$info = openssl_x509_parse( $cert );
if ( is_array( $info ) && ! empty( $info['validTo_time_t'] ) && (int) $info['validTo_time_t'] < time() ) {
return new WP_Error( 'cert_expired', __( 'The certificate has already expired.', 'm365-login' ) );
}
return array(
'private_key' => $key_pem,
'certificate' => $cert_pem,
);
}
/**
* Normalises line endings and trims a PEM block.
*
* @param string $pem PEM.
* @return string
*/
private static function normalise_pem( $pem ) {
$pem = str_replace( array( "\r\n", "\r" ), "\n", trim( (string) $pem ) );
return $pem . "\n";
}
/**
* Certificate metadata for display.
*
* @param string $cert_pem Certificate PEM.
* @return array|null array( 'thumbprint' => hex SHA-1, 'thumbprint_sha256' => hex, 'subject' => string, 'not_before' => ts, 'not_after' => ts, 'bits' => int ).
*/
public static function info( $cert_pem ) {
if ( '' === (string) $cert_pem ) {
return null;
}
$cert = openssl_x509_read( $cert_pem );
if ( false === $cert ) {
return null;
}
$parsed = openssl_x509_parse( $cert );
$der = self::der( $cert_pem );
$public = openssl_pkey_get_public( $cert );
$detail = $public ? openssl_pkey_get_details( $public ) : null;
return array(
'thumbprint' => strtoupper( sha1( $der ) ),
'thumbprint_sha256' => strtoupper( hash( 'sha256', $der ) ),
'subject' => isset( $parsed['subject']['CN'] ) ? (string) $parsed['subject']['CN'] : '',
'not_before' => isset( $parsed['validFrom_time_t'] ) ? (int) $parsed['validFrom_time_t'] : 0,
'not_after' => isset( $parsed['validTo_time_t'] ) ? (int) $parsed['validTo_time_t'] : 0,
'bits' => is_array( $detail ) && isset( $detail['bits'] ) ? (int) $detail['bits'] : 0,
);
}
/**
* DER bytes of a PEM certificate.
*
* @param string $cert_pem PEM.
* @return string
*/
private static function der( $cert_pem ) {
$body = preg_replace( '/-----[^-]+-----|\s+/', '', (string) $cert_pem );
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
$der = base64_decode( $body, true );
return false === $der ? '' : $der;
}
/**
* Builds the signed client assertion for the token endpoint.
*
* @param string $key_pem Private key PEM.
* @param string $cert_pem Certificate PEM (for the x5t header).
* @param string $client_id Application (client) ID.
* @param string $token_endpoint Audience.
* @return string|WP_Error Compact JWS.
*/
public static function assertion( $key_pem, $cert_pem, $client_id, $token_endpoint ) {
$key = openssl_pkey_get_private( $key_pem );
if ( false === $key ) {
return new WP_Error( 'key_invalid', 'Private key could not be loaded.' );
}
$der = self::der( $cert_pem );
if ( '' === $der ) {
return new WP_Error( 'cert_invalid', 'Certificate could not be decoded.' );
}
$now = time();
$header = array(
'alg' => 'RS256',
'typ' => 'JWT',
'x5t' => M365_Login_JWT::b64url_encode( sha1( $der, true ) ),
'x5t#S256' => M365_Login_JWT::b64url_encode( hash( 'sha256', $der, true ) ),
);
$claims = array(
'aud' => $token_endpoint,
'iss' => $client_id,
'sub' => $client_id,
'jti' => M365_Login_JWT::b64url_encode( random_bytes( 24 ) ),
'nbf' => $now - 30,
'iat' => $now,
'exp' => $now + self::ASSERTION_TTL,
);
$signing_input = M365_Login_JWT::b64url_encode( wp_json_encode( $header ) ) . '.' . M365_Login_JWT::b64url_encode( wp_json_encode( $claims ) );
$signature = '';
if ( ! openssl_sign( $signing_input, $signature, $key, OPENSSL_ALGO_SHA256 ) ) {
return new WP_Error( 'sign', 'Signing the client assertion failed.' );
}
return $signing_input . '.' . M365_Login_JWT::b64url_encode( $signature );
}
/**
* Prefixes the last OpenSSL error to a message (for admins).
*
* @param string $message Message.
* @return string
*/
private static function openssl_error( $message ) {
$detail = openssl_error_string();
return $detail ? $message . ' (' . $detail . ')' : $message;
}
}

View file

@ -38,7 +38,7 @@ class M365_Login_Graph {
* @return string
*/
private function token_cache_key() {
return 'm365_login_apptoken_' . md5( $this->settings->tenant() . '|' . $this->settings->get( 'client_id' ) );
return 'm365_login_apptoken_' . md5( $this->settings->tenant() . '|' . $this->settings->get( 'client_id' ) . '|' . $this->settings->auth_method() );
}
/**
@ -63,16 +63,24 @@ class M365_Login_Graph {
return new WP_Error( 'graph_not_configured', __( 'Microsoft login is not configured yet.', 'm365-login' ) );
}
$token_endpoint = 'https://login.microsoftonline.com/' . rawurlencode( $this->settings->tenant() ) . '/oauth2/v2.0/token';
$auth = $this->settings->client_auth_params( $token_endpoint );
if ( is_wp_error( $auth ) ) {
return $auth;
}
$response = wp_remote_post(
'https://login.microsoftonline.com/' . rawurlencode( $this->settings->tenant() ) . '/oauth2/v2.0/token',
$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' => 'client_credentials',
'scope' => 'https://graph.microsoft.com/.default',
'body' => array_merge(
array(
'client_id' => $this->settings->get( 'client_id' ),
'grant_type' => 'client_credentials',
'scope' => 'https://graph.microsoft.com/.default',
),
$auth
),
)
);

View file

@ -30,6 +30,9 @@ class M365_Login_Settings {
'tenant_id' => '',
'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,
@ -96,13 +99,120 @@ class M365_Login_Settings {
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() {
return '' !== $this->get( 'tenant_id' ) && '' !== $this->get( 'client_id' ) && '' !== $this->client_secret();
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'];
update_option( M365_LOGIN_OPTION, $all );
$this->cache = null;
return true;
}
/**
* Removes the stored certificate and key.
*/
public function remove_certificate() {
$all = $this->all();
$all['cert_private_key'] = '';
$all['cert_certificate'] = '';
update_option( M365_LOGIN_OPTION, $all );
$this->cache = null;
}
/**
@ -115,6 +225,15 @@ class M365_Login_Settings {
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.
*
@ -300,6 +419,41 @@ class M365_Login_Settings {
}
}
$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';