# booktics/1.0.29/core/module/google-calendar/Controller/calendar-auth-controller.php

Booktics – Appointment Booking Calendar for Service Businesses, version 1.0.29. 742 lines.

- Page: https://pluginprobe.com/plugins/booktics/1.0.29/code/core/module/google-calendar/Controller/calendar-auth-controller.php
- Raw: https://pluginprobe.com/plugins/booktics/1.0.29/raw/core/module/google-calendar/Controller/calendar-auth-controller.php
- Modified: 2026-10-11T10:01:32+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/booktics/1.0.29/code/core/module/google-calendar/Controller/calendar-auth-controller.php#L10-L20`.

```php
<?php
/**
 * Calendar Auth Controller
 *
 * @package BookticsGoogleCalendar\Google\Controller
 */

namespace Booktics\Module\Google_Calendar\Controller;

if ( ! defined( 'ABSPATH' ) ) exit;

use Booktics\Abstracts\Base_Rest_Controller;
use Exception;
use WP_REST_Request;
use WP_REST_Server;
use WP_Error;
use WP_HTTP_Response;

/**
 * Class Calendar_Auth_Controller
 */
class Calendar_Auth_Controller extends Base_Rest_Controller {

	/**
	 * The namespace of this controller's route.
	 *
	 * @var string
	 */
	protected $namespace = 'booktics/v1';

	/**
	 * The base of this controller's route.
	 *
	 * @var string
	 */
	protected $rest_base = 'settings/google';

	/**
	 * User meta key for storing Google tokens
	 */
	const BGC_USER_META_TOKEN = 'booktics_google_auth_token';

	/**
	 * Transient prefix for one-time OAuth state tokens.
	 */
	const BGC_OAUTH_STATE_PREFIX = 'booktics_gcal_oauth_';

	/**
	 * Lifetime of an OAuth state token.
	 */
	const BGC_OAUTH_STATE_TTL = 900; // 15 minutes.

	/**
	 * Google OAuth configuration.
	 *
	 * @var array
	 */
	private $oauth_config;

	/**
	 * Initialize the class and set up hooks.
	 */
	public function __construct() {
		$this->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(
			'<div class="notice notice-warning"><p>%s</p></div>',
			wp_kses(
				sprintf(
					/* translators: %s: link to the WordPress permalink settings screen. */
					__( 'Booktics: Google Calendar sign-in needs a permalink structure other than <strong>Plain</strong>. Open <a href="%s">Settings &rarr; Permalinks</a> and pick <strong>Post name</strong>, 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;
	}
}

```
