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

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