wp-m365-login/includes/class-m365-login-graph.php
Friederich Loheide 4edf20bc45
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
Add Microsoft 365 user sync with roles, profile fields and deprovisioning
New "User sync" tab that imports Microsoft 365 / Entra ID users as
WordPress accounts and keeps them up to date:

- Scope: whole tenant or the (nested) members of selected groups,
  guests optional, e-mail domain allow-list respected. Existing accounts
  are linked by e-mail address.
- Roles: selectable default role plus a group -> role mapping (in
  addition to or instead of the default role, first match wins).
  Roles of pre-existing accounts are only managed on request.
- Profile: selectable Graph attributes (names, job title, department,
  phones, address, language, ...) and the profile photo as avatar.
- Deprovisioning: accounts disabled or deleted in Microsoft 365 (or
  removed from the sync groups) are deactivated or deleted; accounts
  deactivated by the sync are reactivated automatically. Deactivated
  accounts lose every sign-in path and all sessions.
- Safeguards: dry run, safety stop above 20 % (min. 5) deprovisioning,
  abort on any Graph error, "deleted" only on a 404 for the object ID,
  protected pre-existing administrators and own account, content
  reassignment required for deletion, run lock.
- Runs manually, via WP-Cron or `wp m365-login sync [--dry-run]`.
- Users screen column with deactivate/reactivate row actions and a
  read-only Microsoft 365 section on the profile screen.

The Graph client gains paging, retry on throttling and user, group
member and photo endpoints. The group picker is now reusable.
Version 1.1.0, German translations (du/Sie), docs and audit addendum.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 16:34:30 +00:00

422 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Minimal Microsoft Graph client (application permissions).
*
* @package M365_Login
*/
defined( 'ABSPATH' ) || exit;
/**
* Obtains app-only tokens via client credentials and queries users and groups.
*/
class M365_Login_Graph {
const GRAPH_BASE = 'https://graph.microsoft.com/v1.0';
const TOKEN_TTL = 50 * MINUTE_IN_SECONDS; // Graph tokens last ~60 minutes.
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;
}
/**
* Transient key for the cached app token.
*
* @return string
*/
private function token_cache_key() {
return 'm365_login_apptoken_' . md5( $this->settings->tenant() . '|' . $this->settings->get( 'client_id' ) . '|' . $this->settings->auth_method() );
}
/**
* Drops the cached app token (e.g. after the client secret changed).
*/
public function flush_token() {
delete_transient( $this->token_cache_key() );
}
/**
* Returns an app-only access token for Microsoft Graph.
*
* @return string|WP_Error
*/
public function app_token() {
$cached = get_transient( $this->token_cache_key() );
if ( is_string( $cached ) && '' !== $cached ) {
return $cached;
}
if ( ! $this->settings->is_configured() ) {
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(
$token_endpoint,
array(
'timeout' => self::HTTP_TIMEOUT,
'headers' => array( 'Accept' => 'application/json' ),
'body' => array_merge(
array(
'client_id' => $this->settings->get( 'client_id' ),
'grant_type' => 'client_credentials',
'scope' => 'https://graph.microsoft.com/.default',
),
$auth
),
)
);
if ( is_wp_error( $response ) ) {
return $response;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) || empty( $body['access_token'] ) ) {
$detail = is_array( $body ) && ! empty( $body['error_description'] ) ? (string) $body['error_description'] : 'HTTP ' . wp_remote_retrieve_response_code( $response );
return new WP_Error( 'graph_token', $detail );
}
set_transient( $this->token_cache_key(), (string) $body['access_token'], self::TOKEN_TTL );
return (string) $body['access_token'];
}
/**
* Performs an authenticated Graph request and returns the raw HTTP response.
*
* Retries a few times when Microsoft throttles (HTTP 429) or is briefly unavailable (503/504).
*
* @param string $method HTTP method.
* @param string $path Path relative to the v1.0 base (with query string) or an absolute Graph URL (paging links).
* @param array|null $json JSON body for POST requests.
* @param array $headers Extra headers.
* @return array|WP_Error Response array from wp_remote_request().
*/
private function raw_request( $method, $path, $json = null, $headers = array() ) {
$url = 0 === strpos( $path, self::GRAPH_BASE . '/' ) ? $path : self::GRAPH_BASE . $path;
if ( 0 !== strpos( $url, self::GRAPH_BASE . '/' ) ) {
return new WP_Error( 'graph_bad_url', 'Refusing to call a non-Graph URL.' );
}
for ( $attempt = 1; ; $attempt++ ) {
$token = $this->app_token();
if ( is_wp_error( $token ) ) {
return $token;
}
$args = array(
'method' => $method,
'timeout' => self::HTTP_TIMEOUT,
'headers' => array_merge(
array(
'Authorization' => 'Bearer ' . $token,
'Accept' => 'application/json',
),
$headers
),
);
if ( null !== $json ) {
$args['headers']['Content-Type'] = 'application/json';
$args['body'] = wp_json_encode( $json );
}
$response = wp_remote_request( $url, $args );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code( $response );
if ( 401 === $code ) {
$this->flush_token();
}
if ( $attempt < 4 && in_array( $code, array( 429, 503, 504 ), true ) ) {
$wait = (int) wp_remote_retrieve_header( $response, 'retry-after' );
sleep( max( 1, min( 10, $wait > 0 ? $wait : $attempt * 2 ) ) );
continue;
}
return $response;
}
}
/**
* Performs an authenticated Graph request.
*
* @param string $method HTTP method.
* @param string $path Path relative to the v1.0 base (with query string).
* @param array|null $json JSON body for POST requests.
* @param array $headers Extra headers.
* @return array|WP_Error Decoded JSON. Errors carry array( 'status' => HTTP code ) as data.
*/
private function request( $method, $path, $json = null, $headers = array() ) {
$response = $this->raw_request( $method, $path, $json, $headers );
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 ( $code < 200 || $code >= 300 || ! is_array( $body ) ) {
return $this->error_from( $code, $body );
}
return $body;
}
/**
* Builds a WP_Error from a failed Graph response.
*
* @param int $code HTTP status.
* @param array|null $body Decoded body.
* @return WP_Error
*/
private function error_from( $code, $body ) {
$graph_code = isset( $body['error']['code'] ) ? (string) $body['error']['code'] : 'HTTP ' . $code;
$message = isset( $body['error']['message'] ) ? (string) $body['error']['message'] : '';
return new WP_Error( 'graph_' . sanitize_key( $graph_code ), $graph_code . ( $message ? ': ' . $message : '' ), array( 'status' => (int) $code ) );
}
/**
* Whether a Graph error means "object does not exist".
*
* @param WP_Error $error Error.
* @return bool
*/
public static function is_not_found( $error ) {
$data = $error->get_error_data();
return is_array( $data ) && isset( $data['status'] ) && 404 === (int) $data['status'];
}
/**
* Follows @odata.nextLink until every page of a collection is read.
*
* @param string $path First page (relative path).
* @param array $headers Extra headers.
* @return array[]|WP_Error All items.
*/
private function collect( $path, $headers = array() ) {
$items = array();
$next = $path;
$pages = 0;
while ( '' !== $next ) {
if ( ++$pages > 1000 ) {
return new WP_Error( 'graph_paging', 'Too many result pages.' );
}
$result = $this->request( 'GET', $next, null, $headers );
if ( is_wp_error( $result ) ) {
return $result;
}
if ( isset( $result['value'] ) && is_array( $result['value'] ) ) {
foreach ( $result['value'] as $item ) {
if ( is_array( $item ) && ! empty( $item['id'] ) ) {
$items[] = $item;
}
}
}
$next = isset( $result['@odata.nextLink'] ) && is_string( $result['@odata.nextLink'] ) ? $result['@odata.nextLink'] : '';
}
return $items;
}
/**
* Lists every user of the tenant.
*
* @param string[] $select Properties to read.
* @return array[]|WP_Error
*/
public function list_users( $select ) {
return $this->collect( '/users?$select=' . rawurlencode( implode( ',', $select ) ) . '&$top=999' );
}
/**
* Lists the users that are (directly or through nested groups) members of a group.
*
* @param string $group_id Group object ID.
* @param string[] $select Properties to read.
* @return array[]|WP_Error
*/
public function list_group_users( $group_id, $select ) {
if ( ! M365_Login_Settings::is_guid( $group_id ) ) {
return new WP_Error( 'graph_bad_group', 'Invalid group object ID.' );
}
return $this->collect(
'/groups/' . rawurlencode( strtolower( $group_id ) ) . '/transitiveMembers/microsoft.graph.user?$select=' . rawurlencode( implode( ',', $select ) ) . '&$top=999&$count=true',
array( 'ConsistencyLevel' => 'eventual' )
);
}
/**
* Reads a single user.
*
* @param string $oid User object ID.
* @param string[] $select Properties to read.
* @return array|WP_Error WP_Error with status 404 when the user does not exist (anymore).
*/
public function get_user( $oid, $select ) {
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' );
}
return $this->request( 'GET', '/users/' . rawurlencode( strtolower( $oid ) ) . '?$select=' . rawurlencode( implode( ',', $select ) ) );
}
/**
* Metadata of a user's profile photo (prefers the 240×240 rendition).
*
* @param string $oid User object ID.
* @return array|null|WP_Error array( 'path' => photo path, 'etag' => string ), null when the user has no photo.
*/
public function photo_info( $oid ) {
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' );
}
$base = '/users/' . rawurlencode( strtolower( $oid ) );
foreach ( array( $base . '/photos/240x240', $base . '/photo' ) as $path ) {
$meta = $this->request( 'GET', $path );
if ( is_wp_error( $meta ) ) {
if ( self::is_not_found( $meta ) ) {
continue;
}
return $meta;
}
$etag = isset( $meta['@odata.mediaEtag'] ) ? (string) $meta['@odata.mediaEtag'] : '';
return array(
'path' => $path,
'etag' => '' !== $etag ? $etag : md5( (string) wp_json_encode( $meta ) ),
);
}
return null;
}
/**
* Downloads photo bytes.
*
* @param string $path Photo path returned by photo_info().
* @return string|WP_Error Binary image data.
*/
public function photo_bytes( $path ) {
$response = $this->raw_request( 'GET', $path . '/$value', null, array( 'Accept' => 'image/*' ) );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code( $response );
$body = wp_remote_retrieve_body( $response );
if ( 200 !== $code ) {
return $this->error_from( $code, json_decode( $body, true ) );
}
return $body;
}
/**
* Searches groups by display name.
*
* @param string $query Search text (may be empty for the first page).
* @return array|WP_Error List of ['id' => .., 'name' => .., 'description' => ..].
*/
public function search_groups( $query ) {
$query = trim( (string) $query );
$select = '$select=id,displayName,description,securityEnabled,mailEnabled&$top=25&$orderby=displayName';
if ( '' !== $query && M365_Login_Settings::is_guid( $query ) ) {
$path = '/groups/' . rawurlencode( strtolower( $query ) ) . '?$select=id,displayName,description,securityEnabled,mailEnabled';
$item = $this->request( 'GET', $path );
if ( is_wp_error( $item ) ) {
return $item;
}
return array( $this->format_group( $item ) );
}
$path = '/groups?' . $select;
if ( '' !== $query ) {
// $search needs the ConsistencyLevel header; the value must be wrapped in double quotes.
$search = '"displayName:' . str_replace( '"', '', $query ) . '"';
$path = '/groups?' . $select . '&$search=' . rawurlencode( $search ) . '&$count=true';
}
$result = $this->request( 'GET', $path, null, array( 'ConsistencyLevel' => 'eventual' ) );
if ( is_wp_error( $result ) ) {
return $result;
}
$groups = array();
if ( ! empty( $result['value'] ) && is_array( $result['value'] ) ) {
foreach ( $result['value'] as $item ) {
if ( is_array( $item ) && ! empty( $item['id'] ) ) {
$groups[] = $this->format_group( $item );
}
}
}
return $groups;
}
/**
* Normalises a Graph group object.
*
* @param array $item Graph group.
* @return array
*/
private function format_group( $item ) {
$type = __( 'Group', 'm365-login' );
if ( ! empty( $item['securityEnabled'] ) && empty( $item['mailEnabled'] ) ) {
$type = __( 'Security group', 'm365-login' );
} elseif ( ! empty( $item['mailEnabled'] ) ) {
$type = __( 'Microsoft 365 group', 'm365-login' );
}
return array(
'id' => strtolower( (string) $item['id'] ),
'name' => isset( $item['displayName'] ) ? (string) $item['displayName'] : (string) $item['id'],
'description' => isset( $item['description'] ) ? (string) $item['description'] : '',
'type' => $type,
);
}
/**
* Checks (transitively) which of the given groups the user belongs to.
*
* @param string $user_oid User object ID.
* @param string[] $group_ids Group object IDs (any count; chunked by 20).
* @return string[]|WP_Error Matching group IDs.
*/
public function check_member_groups( $user_oid, $group_ids ) {
if ( ! M365_Login_Settings::is_guid( $user_oid ) ) {
return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' );
}
$matches = array();
foreach ( array_chunk( array_values( $group_ids ), 20 ) as $chunk ) {
$result = $this->request(
'POST',
'/users/' . rawurlencode( strtolower( $user_oid ) ) . '/checkMemberGroups',
array( 'groupIds' => $chunk )
);
if ( is_wp_error( $result ) ) {
return $result;
}
if ( ! empty( $result['value'] ) && is_array( $result['value'] ) ) {
foreach ( $result['value'] as $id ) {
$matches[] = strtolower( (string) $id );
}
}
if ( ! empty( $matches ) ) {
break; // One match is enough.
}
}
return $matches;
}
}