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. * * @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. */ private function request( $method, $path, $json = null, $headers = array() ) { $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( self::GRAPH_BASE . $path, $args ); 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 ( 401 === $code ) { $this->flush_token(); } if ( $code < 200 || $code >= 300 || ! is_array( $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 : '' ) ); } 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; } }