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

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