oauth_config = $this->get_oauth_config(); add_action( 'template_redirect', array( $this, 'handle_google_auth_callback' ) ); add_action( 'admin_notices', array( $this, 'permalink_warning_notice' ) ); // In your addon's main file or a dedicated class add_filter( 'booktics_team_member_data', function ( $team_member, $team_member_id ) { $team_member['google_calendar_connected'] = $this->team_member_is_connected( $team_member_id ); return $team_member; }, 10, 2 ); } /** * Warn the admin when the permalink structure breaks the OAuth callback. * * The Google callback lives at a pretty path ( booktics-integration/ * google-auth ), which WordPress only routes when permalinks are not set to * Plain. On Plain that URL 404s and the connection silently fails, so a * heads-up is shown — but only once Google is actually configured, to keep * the notice off screens where it would be noise. * * @return void */ public function permalink_warning_notice() { if ( ! current_user_can( 'manage_options' ) ) { return; } // Plain permalinks store an empty structure. if ( get_option( 'permalink_structure' ) ) { return; } if ( ! booktics_get_option( 'google_client_id' ) ) { return; } printf( '

%s

', wp_kses( sprintf( /* translators: %s: link to the WordPress permalink settings screen. */ __( 'Booktics: Google Calendar sign-in needs a permalink structure other than Plain. Open Settings → Permalinks and pick Post name, or the Google connection will fail.', 'booktics' ), esc_url( admin_url( 'options-permalink.php' ) ) ), array( 'strong' => array(), 'a' => array( 'href' => array() ), ) ) ); } /** * Get OAuth configuration for Google API * * @return array */ private function get_oauth_config() { return array( 'client_id' => booktics_get_option( 'google_client_id' ), 'client_secret' => booktics_get_option( 'google_secret_key' ), 'redirect_uri' => booktics_get_option( 'google_redirect_url' ), 'scope' => 'https://www.googleapis.com/auth/calendar', 'access_type' => 'offline', 'prompt' => 'consent', ); } /** * Get valid access token for user, refreshing if necessary * * @param int $user_id * @return string|false Access token or false on failure */ private function get_valid_access_token( $user_id = null ) { if ( null === $user_id ) { $user_id = get_current_user_id(); } $token = get_user_meta( $user_id, self::BGC_USER_META_TOKEN, true ); if ( ! $token ) { return false; } // Handle both string (old format) and array (new format) tokens if ( is_string( $token ) ) { $token = json_decode( $token, true ); } if ( ! is_array( $token ) || empty( $token['access_token'] ) ) { return false; } // Check if token needs refreshing if ( $this->is_token_expired( $token ) ) { $refreshed_token = $this->refresh_access_token( $token, $user_id ); if ( ! $refreshed_token ) { delete_user_meta( $user_id, self::BGC_USER_META_TOKEN ); return false; } return $refreshed_token['access_token']; } return $token['access_token']; } /** * Check if token is expired * * @param array $token * @return bool */ private function is_token_expired( $token ) { if ( empty( $token['expires_at'] ) ) { return true; } $expires_at = is_numeric( $token['expires_at'] ) ? $token['expires_at'] : strtotime( $token['expires_at'] ); $buffer_time = 300; // 5 minutes buffer return ( time() + $buffer_time ) >= $expires_at; } /** * Refresh access token using refresh token * * @param array $token * @param int $user_id * @return array|false New token array or false on failure */ private function refresh_access_token( $token, $user_id ) { if ( empty( $token['refresh_token'] ) ) { return false; } $response = wp_remote_post( 'https://oauth2.googleapis.com/token', array( 'body' => array( 'client_id' => $this->oauth_config['client_id'], 'client_secret' => $this->oauth_config['client_secret'], 'refresh_token' => $token['refresh_token'], 'grant_type' => 'refresh_token', ), ) ); if ( is_wp_error( $response ) ) { return false; } $body = wp_remote_retrieve_body( $response ); $new_token = json_decode( $body, true ); if ( isset( $new_token['error'] ) ) { return false; } // Merge with existing token to preserve refresh_token $refreshed_token = array_merge( $token, $new_token ); // Set expiry time if ( isset( $refreshed_token['expires_in'] ) ) { $refreshed_token['expires_at'] = time() + $refreshed_token['expires_in']; } // Save the refreshed token update_user_meta( $user_id, self::BGC_USER_META_TOKEN, $refreshed_token ); return $refreshed_token; } /** * Issue a one-time OAuth state token. * * The token is random and carries no caller-supplied data; the real * payload (who started the flow and which team member it is for) is held * server-side in a transient. This is what makes the callback trustworthy: * the state cannot be forged to name an arbitrary user, and it cannot be * replayed because it is deleted on first use. * * @param int $team_member_id Target team member/user id. * @return string The opaque state token to hand to Google. */ private function issue_oauth_state( $team_member_id ) { $token = wp_generate_password( 40, false, false ); // URL-safe: letters + digits only. set_transient( self::BGC_OAUTH_STATE_PREFIX . $token, array( 'initiator' => get_current_user_id(), 'team_member_id' => absint( $team_member_id ), ), self::BGC_OAUTH_STATE_TTL ); return $token; } /** * Validate and consume a one-time OAuth state token. * * @param string $token State token returned by Google. * @return array|false Stored { initiator, team_member_id }, or false when * the token is unknown, expired or already used. */ private function consume_oauth_state( $token ) { $key = self::BGC_OAUTH_STATE_PREFIX . sanitize_text_field( $token ); $data = get_transient( $key ); if ( false === $data || ! is_array( $data ) || ! isset( $data['initiator'], $data['team_member_id'] ) ) { return false; } delete_transient( $key ); // Single use. return $data; } /** * Handle direct OAuth callback from Google. */ public function handle_google_auth_callback() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Google OAuth callback; CSRF is prevented by the signed, single-use state verified below. if ( ! isset( $_GET['code'] ) ) { return; } // A server-issued state is mandatory. It is a random, single-use token // bound to the initiating user and the target team member. Without a // valid state we cannot trust who this callback is for, so we refuse // instead of falling back to the current user (which allowed login CSRF // and let a forged state overwrite any user's Google tokens). // phpcs:ignore WordPress.Security.NonceVerification.Recommended $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; $state_data = '' !== $state ? $this->consume_oauth_state( $state ) : false; if ( ! $state_data ) { wp_safe_redirect( $this->get_redirect_url_for_admin() ); exit; } $team_member_id = absint( $state_data['team_member_id'] ); $initiator = absint( $state_data['initiator'] ); // The browser finishing the OAuth round-trip must belong to the same // logged-in user who started it, and that user must still be allowed to // manage the target team member's connection. if ( get_current_user_id() !== $initiator || ( $initiator !== $team_member_id && ! current_user_can( 'edit_user', $team_member_id ) ) ) { wp_safe_redirect( $this->get_redirect_url_for_admin() ); exit; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $code = sanitize_text_field( wp_unslash( $_GET['code'] ) ); try { $tokens = $this->exchange_code_for_tokens( $code ); if ( ! $tokens || isset( $tokens['error'] ) ) { throw new Exception( $tokens['error_description'] ?? 'Authentication failed' ); } // Set expiry time if ( isset( $tokens['expires_in'] ) ) { $tokens['expires_at'] = time() + $tokens['expires_in']; } update_user_meta( $team_member_id, self::BGC_USER_META_TOKEN, $tokens ); if ( current_user_can( 'manage_options' ) && $team_member_id === get_current_user_id() ) { booktics_update_option( 'google_calendar_connected', 1 ); } $redirect_url = ( $team_member_id !== get_current_user_id() ) ? $this->get_redirect_url_for_team_member( $team_member_id ) : $this->get_redirect_url_for_admin(); wp_safe_redirect( $redirect_url ); exit; } catch ( Exception $e ) { $redirect_url = ( $team_member_id !== get_current_user_id() ) ? $this->get_redirect_url_for_team_member( $team_member_id ) : $this->get_redirect_url_for_admin(); wp_safe_redirect( $redirect_url ); exit; } } /** * Exchange authorization code for access tokens * * @param string $code * @return array|false */ private function exchange_code_for_tokens( $code ) { $response = wp_remote_post( 'https://oauth2.googleapis.com/token', array( 'body' => array( 'client_id' => $this->oauth_config['client_id'], 'client_secret' => $this->oauth_config['client_secret'], 'code' => $code, 'grant_type' => 'authorization_code', 'redirect_uri' => $this->oauth_config['redirect_uri'], ), ) ); if ( is_wp_error( $response ) ) { return false; } $body = wp_remote_retrieve_body( $response ); return json_decode( $body, true ); } private function get_redirect_url_for_admin() { return home_url( '/wp-admin/admin.php?page=booktics#/settings?tab=integration' ); } private function get_redirect_url_for_team_member( $team_member_id ) { return home_url( "/wp-admin/admin.php?page=booktics#/team-members/update/{$team_member_id}?tab=integration" ); } /** * Register the routes for the controller. */ public function register_routes(): void { // Get Google auth URL register_rest_route( $this->namespace, $this->rest_base . '/connect', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_auth_url' ), 'permission_callback' => array( $this, 'permission_check' ), 'args' => array( 'team_member_id' => array( 'description' => __( 'ID of the team member to connect', 'booktics' ), 'type' => 'integer', 'required' => false, ), ), ), ) ); // Revoke authentication register_rest_route( $this->namespace, $this->rest_base . '/revoke', array( array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'revoke_auth' ), 'permission_callback' => array( $this, 'permission_check' ), 'args' => array( 'team_member_id' => array( 'description' => __( 'ID of the team member to disconnect', 'booktics' ), 'type' => 'integer', 'required' => false, ), ), ), ) ); // Google connect for admin register_rest_route( $this->namespace, $this->rest_base . '/admin/connect', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'get_admin_auth_url' ), 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, ), ) ); // Check authentication status register_rest_route( $this->namespace, $this->rest_base . '/check', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'check_auth' ), 'permission_callback' => array( $this, 'permission_check' ), 'args' => array( 'team_member_id' => array( 'description' => __( 'ID of the team member to check', 'booktics' ), 'type' => 'integer', 'required' => false, ), ), ), ) ); } /** * Check if a given request has permission. * * @return bool|WP_Error */ public function permission_check( $request ) { if ( ! is_user_logged_in() ) { return $this->error( __( 'You must be logged in to access this endpoint.', 'booktics' ), 401, 'permission_error' ); } // Get team member ID from request $team_member_id = $this->get_team_member_id( $request ); // If no team member ID specified, allow if user can manage options if ( ! $team_member_id ) { return current_user_can( 'manage_options' ); } // If team member ID is specified, check if current user can edit that user if ( get_current_user_id() !== $team_member_id && ! current_user_can( 'edit_user', $team_member_id ) ) { return $this->error( __( 'You do not have permission to access this user\'s Google Calendar settings.', 'booktics' ), 403, 'permission_error' ); } return true; } public function get_admin_auth_url( $request ) { $data = json_decode( $request->get_body(), true ); $google_client_id = $data['google_client_id']; $google_client_secret = $data['google_secret_key']; $block_booking_during_google_events = isset( $data['block_booking_during_google_events'] ) ? $data['block_booking_during_google_events'] : false; // save credentials in options booktics_update_option( 'google_client_id', $google_client_id ); booktics_update_option( 'google_secret_key', $google_client_secret ); booktics_update_option( 'block_booking_during_google_events', $block_booking_during_google_events ); // Update OAuth config $this->oauth_config['client_id'] = $google_client_id; $this->oauth_config['client_secret'] = $google_client_secret; // Bind the flow to this admin with a random, single-use state. $state = $this->issue_oauth_state( get_current_user_id() ); $auth_url = $this->create_auth_url( $state ); return $this->response( array( 'auth_url' => $auth_url ), __( 'Google authentication URL generated successfully.', 'booktics' ) ); } /** * Get Google OAuth URL. * * @param WP_REST_Request $request * @return WP_Error|WP_HTTP_Response */ public function get_auth_url( $request ) { try { // permission_check() has already confirmed the caller may manage // this team member; bind the flow to them with a random, single-use // state rather than an encoded, forgeable user id. $team_member_id = absint( $request->get_param( 'team_member_id' ) ); if ( ! $team_member_id ) { $team_member_id = get_current_user_id(); } $state = $this->issue_oauth_state( $team_member_id ); $auth_url = $this->create_auth_url( $state ); if ( empty( $auth_url ) ) { return $this->error( __( 'Failed to generate Google authentication URL.', 'booktics' ), 500, 'auth_url_error' ); } return $this->response( array( 'auth_url' => $auth_url ), __( 'Google authentication URL generated successfully.', 'booktics' ) ); } catch ( Exception $e ) { return $this->error( /* translators: %s: Error message */ sprintf( __( 'Failed to generate Google auth URL: %s', 'booktics' ), $e->getMessage() ), 500, 'auth_error' ); } } /** * Create OAuth authorization URL * * @param string $state Optional state parameter * @return string */ private function create_auth_url( $state = '' ) { $params = array( 'client_id' => $this->oauth_config['client_id'], 'redirect_uri' => $this->oauth_config['redirect_uri'], 'scope' => $this->oauth_config['scope'], 'response_type' => 'code', 'access_type' => $this->oauth_config['access_type'], 'prompt' => $this->oauth_config['prompt'], ); if ( ! empty( $state ) ) { $params['state'] = $state; } return 'https://accounts.google.com/o/oauth2/auth?' . http_build_query( $params ); } /** * Revoke Google authentication. * * @param WP_REST_Request $request * @return WP_Error|WP_HTTP_Response */ public function revoke_auth( $request ) { try { $team_member_id = $this->get_team_member_id( $request ); if ( current_user_can( 'manage_options' ) && (int)$team_member_id == get_current_user_id() ) { booktics_update_option( 'google_calendar_connected', 0 ); } // Get the token before revoking $token = get_user_meta( $team_member_id, self::BGC_USER_META_TOKEN, true ); if ( empty( $token ) ) { return $this->response( array( 'revoked' => true ), __( 'Google account was not connected.', 'booktics' ) ); } // Handle both string (old format) and array (new format) tokens if ( is_string( $token ) ) { $token = json_decode( $token, true ); } // Revoke the token if ( isset( $token['access_token'] ) ) { $this->revoke_token( $token['access_token'] ); } // Delete the token delete_user_meta( $team_member_id, self::BGC_USER_META_TOKEN ); return $this->response( array( 'revoked' => true ), __( 'Google authentication revoked successfully.', 'booktics' ) ); } catch ( Exception $e ) { return $this->error( /* translators: %s: Error message */ sprintf( __( 'Failed to revoke Google authentication: %s', 'booktics' ), $e->getMessage() ), 500, 'revoke_error' ); } } /** * Revoke access token at Google * * @param string $access_token * @return bool */ private function revoke_token( $access_token ) { $response = wp_remote_post( 'https://oauth2.googleapis.com/revoke', array( 'body' => array( 'token' => $access_token, ), ) ); return ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) === 200; } /** * Check authentication status. * * @param WP_REST_Request $request * @return WP_Error|WP_HTTP_Response */ public function check_auth( $request ) { try { $team_member_id = $this->get_team_member_id( $request ); $token = get_user_meta( $team_member_id, self::BGC_USER_META_TOKEN, true ); $is_authenticated = false; if ( $token ) { // Handle both string (old format) and array (new format) tokens if ( is_string( $token ) ) { $token = json_decode( $token, true ); } if ( is_array( $token ) && isset( $token['access_token'] ) ) { $is_authenticated = ! $this->is_token_expired( $token ); // If token is expired but we have a refresh token, try to refresh if ( ! $is_authenticated && isset( $token['refresh_token'] ) ) { $new_token = $this->refresh_access_token( $token, $team_member_id ); if ( $new_token ) { $is_authenticated = true; } } } } return $this->response( array( 'authenticated' => $is_authenticated ), $is_authenticated ? __( 'Google account is connected.', 'booktics' ) : __( 'Google account is not connected.', 'booktics' ) ); } catch ( Exception $e ) { return $this->error( /* translators: %s: Error message */ sprintf( __( 'Failed to check authentication status: %s', 'booktics' ), $e->getMessage() ), 500, 'auth_check_error' ); } } private function get_team_member_id( $request ) { $data = $request->get_method() === 'GET' ? $request->get_query_params() : json_decode( $request->get_body(), true ); if ( isset( $data['team_member_id'] ) ) { return absint( $data['team_member_id'] ); } return get_current_user_id(); } /** * Check if team member is connected * * @param int $team_member_id team member id or user id * @return int 1 if connected, 0 if not */ private function team_member_is_connected( $team_member_id ) { $token = get_user_meta( $team_member_id, self::BGC_USER_META_TOKEN, true ); return empty( $token ) ? 0 : 1; } }