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(
'
',
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;
}
}