Add excluded Entra groups for the Microsoft sign-in
Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run

New "Excluded Entra groups" card on the Security tab. Members of these
groups (nested memberships count) can never sign in with Microsoft, even
if they are in an allowed group.

A hit in the ID token's groups claim refuses immediately. Otherwise the
plugin always asks Microsoft Graph (checkMemberGroups), because a groups
claim can be filtered in the app registration and cannot prove
non-membership. Graph errors refuse the sign-in (fail closed).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Friederich Loheide 2026-09-23 16:43:10 +00:00
parent 5c9b19399a
commit 791f43a80b
12 changed files with 372 additions and 196 deletions

View file

@ -642,6 +642,75 @@ class M365_Login_Auth {
return $body;
}
/**
* Applies the Entra group rules: members of an excluded group are refused,
* everybody else needs membership in one of the allowed groups (if any are set).
*
* @param array $claims Verified claims.
* @param string $oid User object ID.
* @return true|string True, or an error code for fail().
*/
private function check_groups( $claims, $oid ) {
$denied = $this->check_denied_groups( $claims, $oid );
if ( true !== $denied ) {
return $denied;
}
return $this->check_allowed_groups( $claims, $oid );
}
/**
* Group IDs from the "groups" claim, or null when the token has no complete list (claim missing or overage).
*
* @param array $claims Verified claims.
* @return string[]|null
*/
private function token_groups( $claims ) {
$overage = ! empty( $claims['_claim_names'] ) || ! empty( $claims['hasgroups'] );
if ( $overage || ! isset( $claims['groups'] ) || ! is_array( $claims['groups'] ) ) {
return null;
}
return array_map( 'strtolower', array_filter( $claims['groups'], 'is_string' ) );
}
/**
* Refuses members of an excluded group (fails closed).
*
* A "groups" claim can be filtered in the app registration (e.g. only groups assigned to the
* application), so it can prove membership but never non-membership: without a match in the
* token the plugin always asks Microsoft Graph.
*
* @param array $claims Verified claims.
* @param string $oid User object ID.
* @return true|string True, or an error code for fail().
*/
private function check_denied_groups( $claims, $oid ) {
$denied = array_keys( $this->settings->denied_groups() );
if ( empty( $denied ) ) {
return true;
}
$token_groups = $this->token_groups( $claims );
if ( null !== $token_groups && array_intersect( $denied, $token_groups ) ) {
$this->log( 'User is a member of an excluded group (token claim).' );
return 'in_denied_group';
}
if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) {
return 'invalid_token';
}
$matches = $this->graph->check_member_groups( $oid, $denied );
if ( is_wp_error( $matches ) ) {
$this->log( 'Excluded-group check via Microsoft Graph failed: ' . $matches->get_error_message() );
return 'group_check_failed';
}
if ( ! empty( $matches ) ) {
$this->log( 'User is a member of an excluded group (Graph).' );
return 'in_denied_group';
}
return true;
}
/**
* Verifies membership in one of the allowed Entra groups.
*
@ -652,15 +721,14 @@ class M365_Login_Auth {
* @param string $oid User object ID.
* @return true|string True, or an error code for fail().
*/
private function check_groups( $claims, $oid ) {
private function check_allowed_groups( $claims, $oid ) {
$allowed = array_keys( $this->settings->allowed_groups() );
if ( empty( $allowed ) ) {
return true;
}
$overage = ! empty( $claims['_claim_names'] ) || ! empty( $claims['hasgroups'] );
if ( ! $overage && isset( $claims['groups'] ) && is_array( $claims['groups'] ) ) {
$token_groups = array_map( 'strtolower', array_filter( $claims['groups'], 'is_string' ) );
$token_groups = $this->token_groups( $claims );
if ( null !== $token_groups ) {
if ( array_intersect( $allowed, $token_groups ) ) {
return true;
}
@ -869,6 +937,7 @@ class M365_Login_Auth {
'not_allowed' => __( 'You are not allowed to sign in with this account.', 'm365-login' ),
'not_in_group' => __( 'Your Microsoft account is not a member of a group that is allowed to sign in here.', 'm365-login' ),
'group_check_failed' => __( 'Your group membership could not be verified. Please contact an administrator.', 'm365-login' ),
'in_denied_group' => __( 'Your Microsoft account is a member of a group that is not allowed to sign in here.', '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' ),