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 ) ) ); } /** * 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&$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; } }