wp-m365-login/includes/class-m365-login-graph.php
Friederich Loheide 850f0dcd54
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
Fix the findings of a full second security audit
Four-part audit (OIDC/JWT/crypto, user sync, admin UI, login bypasses)
with dynamic PoCs against a real WordPress install; every fix is covered
by a regression test. Report: docs/security-audit.md, section 6.

Critical/High
- Multisite: settings, AJAX actions and certificate download require
  manage_network_options (site admins could sign in as super admin).
- Privileged accounts are only linked (sync and first sign-in) via a
  matching UPN of a member account, never via the settable mail
  attribute; the sync never changes their e-mail address; e-mail change
  notifications stay on.
- Button-only mode exempts by credential (application passwords, WP-CLI)
  instead of request context, closing bypasses through xmlrpc.php and
  REST login handlers; API requests never receive login cookies.
- Multi-tenant mode refuses guest/external identities.

Medium/Low
- Same message for right and wrong passwords; button-only no longer
  switches off when the connection breaks; server-side fallback cookie
  expiry; correct fallback key beats IP lockouts; right-most proxy hop;
  higher start limit; one object ID per account.
- Deactivation sets a random password, revokes application passwords and
  removes the role (restored on reactivation); disabled people are
  deactivated even when their mail vanished; duplicate bindings handled.
- Sync: abort on empty directory answer, no deprovisioning right after a
  tenant change, atomic run lock, strict photo path validation.
- Certificates: key bundles refused, clean re-exported certificate.
- Array-safe sanitising, encoded redirect_to, per-action nonces, escaped
  role lists, no Graph sleeps during sign-in, warnings for public groups,
  multi-tenant group rules and missing salts, uninstall clears the token.

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

490 lines
16 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.
* @param bool $retry Retry on 429/503/504.
* @return array|WP_Error Response array from wp_remote_request().
*/
private function raw_request( $method, $path, $json = null, $headers = array(), $retry = true ) {
$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 ( $retry && $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.
* @param bool $retry Retry on 429/503/504.
* @return array|WP_Error Decoded JSON. Errors carry array( 'status' => HTTP code ) as data.
*/
private function request( $method, $path, $json = null, $headers = array(), $retry = true ) {
$response = $this->raw_request( $method, $path, $json, $headers, $retry );
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 ) ) );
}
/**
* Runs up to 20 GET requests in one Graph JSON batch.
*
* @param string[] $paths Request key => path relative to the v1.0 base.
* @return array|WP_Error Request key => array( 'status' => int, 'body' => mixed ).
*/
public function batch_get( $paths ) {
$requests = array();
foreach ( array_values( $paths ) as $i => $path ) {
$requests[] = array(
'id' => (string) $i,
'method' => 'GET',
'url' => $path,
);
}
$keys = array_keys( $paths );
if ( empty( $requests ) ) {
return array();
}
if ( count( $requests ) > 20 ) {
return new WP_Error( 'graph_batch_size', 'A Graph batch holds at most 20 requests.' );
}
$result = $this->request( 'POST', '/$batch', array( 'requests' => $requests ) );
if ( is_wp_error( $result ) ) {
return $result;
}
$out = array();
foreach ( isset( $result['responses'] ) && is_array( $result['responses'] ) ? $result['responses'] : array() as $response ) {
$i = isset( $response['id'] ) ? (int) $response['id'] : -1;
if ( isset( $keys[ $i ] ) ) {
$out[ $keys[ $i ] ] = array(
'status' => isset( $response['status'] ) ? (int) $response['status'] : 0,
'body' => isset( $response['body'] ) ? $response['body'] : null,
);
}
}
return $out;
}
/**
* Profile photo versions of several users (one batch request per 20 users).
*
* @param string[] $oids User object IDs.
* @return array oid => etag string, null (user has no photo) or WP_Error (could not be checked).
*/
public function photo_versions( $oids ) {
$out = array();
foreach ( array_chunk( array_values( array_filter( $oids, array( 'M365_Login_Settings', 'is_guid' ) ) ), 20 ) as $chunk ) {
$paths = array();
foreach ( $chunk as $oid ) {
$paths[ $oid ] = '/users/' . rawurlencode( strtolower( $oid ) ) . '/photo';
}
$responses = $this->batch_get( $paths );
foreach ( $chunk as $oid ) {
if ( is_wp_error( $responses ) ) {
$out[ $oid ] = $responses;
continue;
}
$response = isset( $responses[ $oid ] ) ? $responses[ $oid ] : array(
'status' => 0,
'body' => null,
);
if ( 404 === $response['status'] ) {
$out[ $oid ] = null;
} elseif ( 200 === $response['status'] && is_array( $response['body'] ) ) {
$etag = isset( $response['body']['@odata.mediaEtag'] ) ? (string) $response['body']['@odata.mediaEtag'] : '';
$out[ $oid ] = '' !== $etag ? $etag : md5( (string) wp_json_encode( $response['body'] ) );
} else {
$code = isset( $response['body']['error']['code'] ) ? (string) $response['body']['error']['code'] : 'HTTP ' . $response['status'];
$out[ $oid ] = new WP_Error( 'graph_photo', $code, array( 'status' => $response['status'] ) );
}
}
}
return $out;
}
/**
* Downloads a user's photo (240×240 rendition, else the original).
*
* @param string $oid User object ID.
* @return string|null|WP_Error Binary image data, null when the user has no photo.
*/
public function photo_bytes( $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/$value', $base . '/photo/$value' ) as $path ) {
$response = $this->raw_request( 'GET', $path, 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 $body;
}
if ( 404 !== $code ) {
return $this->error_from( $code, json_decode( $body, true ) );
}
}
return null;
}
/**
* 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,groupTypes,visibility&$top=25&$orderby=displayName';
if ( '' !== $query && M365_Login_Settings::is_guid( $query ) ) {
$path = '/groups/' . rawurlencode( strtolower( $query ) ) . '?$select=id,displayName,description,securityEnabled,mailEnabled,groupTypes,visibility';
$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' );
}
$unified = isset( $item['groupTypes'] ) && is_array( $item['groupTypes'] ) && in_array( 'Unified', $item['groupTypes'], true );
if ( $unified && isset( $item['visibility'] ) && 'Public' === $item['visibility'] ) {
$type = __( 'Public Microsoft 365 group anyone in the organisation can join', '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).
* @param bool $retry Retry on throttling (off in the interactive sign-in).
* @return string[]|WP_Error Matching group IDs.
*/
public function check_member_groups( $user_oid, $group_ids, $retry = true ) {
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 ),
array(),
$retry
);
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;
}
}