settings = $s; $this->connection = $c; $this->http = $http; $this->relay_url = untrailingslashit( $relay_url ); $this->tokens = $tokens ?? new TokenStore(); } /** * The BYO redirect URI users register with Google. Google's return here is * a logged-in GET, so wp-admin is fine. The relay does not use this URL: * see relay_return_url(). */ public static function return_url(): string { return admin_url( 'admin-post.php?action=tagsmith_oauth_return' ); } /** * Where the relay hands the browser back: the WordPress address (not * wp-admin). * * The handoff is a cross-site POST without cookies, so WordPress sees a * logged-out request. Membership, LMS and "hide wp-admin" plugins redirect * logged-out wp-admin requests to a login or account page before this * plugin can run, while this URL reaches OAuthReturn::maybe_receive() on * init. It is built from site_url(), not home_url(): the site address can * be a headless front end or another origin, whereas site_url() is where * WordPress runs and shares its origin and path with admin_url(). The relay * accepts this shape from 2.1.1 on. */ public static function relay_return_url(): string { return add_query_arg( 'tagsmith_oauth_return', '1', site_url( '/' ) ); } /** * Starts a connection: creates a single-use state bound to the user and * returns the URL to send the browser to (the relay, or Google in BYO mode). * * @param int $user_id The user starting the flow. * @param string[] $features Subset of ['analytics','searchconsole']. * @throws ApiError 'https_required' when wp-admin (or, for the relay, the * WordPress address) is served over plain http on a * non-local host: the relay and Google both reject a * non-https return URL. */ public function start_url( int $user_id, array $features ): string { $byo = $this->usable_byo_credentials(); // Google gets return_url(); the relay gets relay_return_url(). Both reject plain http off local hosts. if ( ! self::secure_url( self::return_url() ) || ( null === $byo && ! self::secure_url( self::relay_return_url() ) ) ) { throw new ApiError( 'https_required', 'Google sign-in needs an https admin and WordPress address.' ); } $features = $this->normalize_features( $features ); $state = self::random_b64url( 32 ); $start = array( 'user' => $user_id, 'features' => $features, ); if ( null === $byo ) { set_transient( self::START_PREFIX . $state, $start, 600 ); $query = array( 'state' => $state, 'return_url' => self::relay_return_url(), 'scopes' => implode( ',', $features ), 'site' => home_url(), ); // Only changes the relay's page copy, never the scopes requested. if ( in_array( 'searchconsole', $features, true ) && $this->connection->is_connected() && $this->connection->has_scope( self::SCOPE_ANALYTICS ) && ! $this->connection->has_scope( self::SCOPE_GSC ) ) { $query['intent'] = 'add_searchconsole'; } return $this->relay_url . '/start?' . http_build_query( $query ); } // 48 random bytes encode to exactly 64 base64url chars (RFC 7636 allows 43-128). $verifier = self::random_b64url( 48 ); $start['verifier'] = $verifier; set_transient( self::START_PREFIX . $state, $start, 600 ); return self::AUTH_URL . '?' . http_build_query( array( 'client_id' => $byo['client_id'], 'redirect_uri' => self::return_url(), 'response_type' => 'code', 'scope' => $this->scope_string( $features ), 'access_type' => 'offline', 'prompt' => 'consent', 'include_granted_scopes' => 'true', 'state' => $state, 'code_challenge' => self::b64url( hash( 'sha256', $verifier, true ) ), 'code_challenge_method' => 'S256', ) ); } /** * Phase 1 of the return (no login required). Consumes the start state, * parks the encrypted result for finalize() and returns the URL to 303 * the browser to. Never modifies the existing connection. * * @param array $request Sanitized request fields. */ public function receive( array $request ): string { $state = $this->request_string( $request, 'wp_state' ); if ( '' === $state ) { $state = $this->request_string( $request, 'state' ); } if ( ! self::valid_state( $state ) ) { return $this->error_url( 'state_mismatch' ); } $start = get_transient( self::START_PREFIX . $state ); if ( ! is_array( $start ) || ! isset( $start['user'] ) ) { return $this->error_url( 'state_mismatch' ); } // The delete is the single-use lock: only the request that actually // removes the start state may proceed (guards concurrent replays). if ( ! delete_transient( self::START_PREFIX . $state ) ) { return $this->error_url( 'state_mismatch' ); } $payload = $this->build_payload( $request, $start ); $encoded = wp_json_encode( $payload ); set_transient( self::DONE_PREFIX . $state, array( 'user' => (int) $start['user'], 'payload' => $this->tokens->encrypt( false === $encoded ? '{}' : $encoded ), ), 300 ); return $this->settings_url( array( 'tagsmith_oauth' => $state ) ); } /** * Phase 2 of the return (logged-in, same-origin GET). Verifies the user * binding, consumes the parked result and saves the tokens. * * @param string $state The state from the redirect. * @param int $user_id The current user. * @return string The URL to redirect to. */ public function finalize( string $state, int $user_id ): string { if ( ! self::valid_state( $state ) ) { return $this->error_url( 'state_mismatch' ); } $done = get_transient( self::DONE_PREFIX . $state ); if ( ! is_array( $done ) || ! isset( $done['user'], $done['payload'] ) || (int) $done['user'] !== $user_id || $user_id <= 0 ) { return $this->error_url( 'state_mismatch' ); } // Checked after the user binding (so a wrong user cannot burn the // flow); the delete is the single-use lock. if ( ! delete_transient( self::DONE_PREFIX . $state ) ) { return $this->error_url( 'state_mismatch' ); } $plain = is_string( $done['payload'] ) ? $this->tokens->decrypt( $done['payload'] ) : null; $payload = null === $plain ? null : json_decode( $plain, true ); if ( ! is_array( $payload ) ) { return $this->error_url( 'state_mismatch' ); } if ( isset( $payload['error'] ) ) { $slug = sanitize_key( (string) $payload['error'] ); return $this->error_url( '' === $slug ? 'token_exchange_failed' : $slug ); } try { if ( isset( $payload['code'] ) ) { $grant = $this->exchange_code( $payload ); $mode = 'byo'; } else { $grant = $payload; $mode = 'relay'; } return $this->save_grant( $grant, $mode, $user_id ); } catch ( ApiError $e ) { return $this->error_url( $e->slug() ); } catch ( \InvalidArgumentException $e ) { return $this->error_url( 'token_exchange_failed' ); } } /** * A valid access token, refreshed when missing, expired or forced. * * @param bool $force_refresh Skip the cache. * @throws ApiError On failure; reauth_required also clears the connection. */ public function access_token( bool $force_refresh = false ): string { if ( ! $force_refresh ) { $cached = $this->connection->access_token(); if ( null !== $cached && '' !== $cached ) { return $cached; } } if ( $this->connection->reconnect_required() ) { // Stored, but it no longer decrypts: the site's keys changed. throw new ApiError( 'reconnect_required' ); } $refresh = $this->connection->refresh_token(); if ( null === $refresh || '' === $refresh ) { throw new ApiError( 'not_connected' ); } $is_byo = 'byo' === $this->connection->mode(); try { if ( $is_byo ) { $byo = $this->usable_byo_credentials(); if ( null === $byo ) { // The client credentials were removed while connected in BYO mode. throw new ApiError( 'not_configured' ); } $response = $this->http->request( 'POST', self::TOKEN_URL, array( 'form' => array( 'client_id' => $byo['client_id'], 'client_secret' => $byo['client_secret'], 'refresh_token' => $refresh, 'grant_type' => 'refresh_token', ), ) ); } else { $response = $this->http->request( 'POST', $this->relay_url . '/refresh', array( 'json' => array( 'refresh_token' => $refresh ) ) ); } } catch ( ApiError $e ) { if ( 'reauth_required' === $e->slug() ) { $this->connection->clear(); if ( $is_byo ) { // Tagged so the message can point at the likely cause: an // OAuth consent screen left in "Testing", whose refresh // tokens Google expires after 7 days. // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception args, never echoed. throw new ApiError( 'reauth_required', $e->getMessage(), $e->status(), array_merge( $e->details(), array( 'mode' => 'byo' ) ) ); } throw $e; } if ( ! $is_byo ) { // Defense in depth (HttpClient already maps relay-host errors): // any other relay failure is transient and must never // disconnect or surface as a Google-side error. // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception args, never echoed. throw new ApiError( 'relay_unavailable', $e->getMessage(), $e->status(), $e->details() ); } throw $e; } $token = isset( $response['access_token'] ) && is_string( $response['access_token'] ) ? $response['access_token'] : ''; if ( '' === $token ) { throw new ApiError( $is_byo ? 'google_unavailable' : 'relay_unavailable', 'Refresh response had no access token.' ); } $this->connection->set_access_token( $token, (int) ( $response['expires_in'] ?? 3600 ) ); return $token; } /** * Disconnects: best-effort revoke at Google, then forgets the tokens and * the chosen property. The measurement id is kept so tracking continues. * * A site whose address changed since it connected may be a copy (staging, * a migration) that kept the salts and so holds the live site's token. * Revoking it from there would end the live site's access, so the revoke * is skipped and the token is only forgotten here. * * @return bool True when the revoke was skipped for that reason: the access * is still active at Google, and is removed at * myaccount.google.com/permissions. */ public function disconnect(): bool { $refresh = $this->connection->refresh_token(); $revoke_skipped = $this->connection->home_url_changed(); if ( ! $revoke_skipped && null !== $refresh && '' !== $refresh ) { try { $this->http->request( 'POST', self::REVOKE_URL, array( 'form' => array( 'token' => $refresh ) ) ); } catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- best effort; disconnect proceeds regardless. } } $this->connection->clear(); $this->settings->update( array( 'property_id' => '', 'property_name' => '', 'account_name' => '', 'stream_id' => '', 'gsc_enabled' => false, 'gsc_site' => '', ) ); return $revoke_skipped; } /** * Builds the payload to park, from known fields only. * * @param array $request Request fields. * @param array $start The consumed start transient. * @return array */ private function build_payload( array $request, array $start ): array { $error = $this->request_string( $request, 'error' ); if ( '' !== $error ) { return array( 'error' => substr( sanitize_key( $error ), 0, 64 ) ); } $verifier = isset( $start['verifier'] ) && is_string( $start['verifier'] ) ? $start['verifier'] : ''; if ( '' !== $verifier ) { // BYO: this flow was started against Google directly, so only a code is acceptable. $code = $this->request_string( $request, 'code' ); if ( '' === $code ) { return array( 'error' => 'token_exchange_failed' ); } return array( 'code' => $code, 'verifier' => $verifier, 'features' => isset( $start['features'] ) && is_array( $start['features'] ) ? $start['features'] : array(), ); } $payload = array( 'access_token' => $this->request_string( $request, 'access_token' ), 'refresh_token' => $this->request_string( $request, 'refresh_token' ), 'expires_in' => (int) $this->request_string( $request, 'expires_in' ), 'scope' => $this->request_string( $request, 'scope' ), ); $email = sanitize_email( $this->request_string( $request, 'email' ) ); if ( '' !== $email ) { $payload['email'] = $email; } return $payload; } /** * Exchanges a BYO authorization code at Google's token endpoint. * * @param array $payload Parked BYO payload (code, verifier). * @return array Grant: access_token, refresh_token, expires_in, scope, email?. * @throws ApiError When not configured or the exchange fails. */ private function exchange_code( array $payload ): array { $byo = $this->usable_byo_credentials(); if ( null === $byo ) { throw new ApiError( 'not_configured' ); } $response = $this->http->request( 'POST', self::TOKEN_URL, array( 'form' => array( 'code' => (string) $payload['code'], 'client_id' => $byo['client_id'], 'client_secret' => $byo['client_secret'], 'redirect_uri' => self::return_url(), 'grant_type' => 'authorization_code', 'code_verifier' => (string) ( $payload['verifier'] ?? '' ), ), ) ); $grant = array( 'access_token' => (string) ( $response['access_token'] ?? '' ), 'refresh_token' => (string) ( $response['refresh_token'] ?? '' ), 'expires_in' => (int) ( $response['expires_in'] ?? 0 ), 'scope' => (string) ( $response['scope'] ?? '' ), ); $email = self::email_from_id_token( (string) ( $response['id_token'] ?? '' ) ); if ( '' !== $email ) { $grant['email'] = $email; } return $grant; } /** * Saves a token grant and returns the success URL. * * An empty refresh token is only acceptable when a connection in the * same mode already exists (Google omits it on some re-consents), so the * existing refresh token is kept. * * @param array $grant Grant fields. * @param string $mode 'relay' or 'byo'. * @param int $user_id Connecting user. */ private function save_grant( array $grant, string $mode, int $user_id ): string { $access = (string) ( $grant['access_token'] ?? '' ); $refresh = (string) ( $grant['refresh_token'] ?? '' ); $email = isset( $grant['email'] ) ? sanitize_email( (string) $grant['email'] ) : ''; if ( '' === $access ) { return $this->error_url( 'token_exchange_failed' ); } // With granular consent the user can untick Analytics. Without it // every report fails, so nothing is saved. A grant with a new refresh // token carries the full granted set (include_granted_scopes); one // without keeps the stored token, whose earlier grants still apply. $granted = array_filter( explode( ' ', (string) ( $grant['scope'] ?? '' ) ) ); if ( '' === $refresh ) { $granted = array_merge( $granted, $this->connection->scopes() ); } if ( ! in_array( self::SCOPE_ANALYTICS, $granted, true ) ) { return $this->error_url( 'scope_denied' ); } if ( '' === $refresh ) { // Keeping the stored refresh token is only safe for the same // OAuth client (mode) and the same Google account. $same_account = '' === $email || 0 === strcasecmp( $email, $this->connection->email() ); if ( ! $this->connection->is_connected() || $this->connection->mode() !== $mode || ! $same_account ) { return $this->error_url( 'token_exchange_failed' ); } } $tokens = array( 'access_token' => $access, 'refresh_token' => $refresh, 'expires_in' => (int) ( $grant['expires_in'] ?? 0 ), 'scope' => (string) ( $grant['scope'] ?? '' ), 'mode' => $mode, 'connected_by' => $user_id, ); if ( '' !== $email ) { $tokens['email'] = $email; } $this->connection->save_tokens( $tokens ); /** * Fires the same action ReportRunner::clear_cache() fires, so a * reconnect resets state that depends on the report cache (e.g. * PostsColumn's warm-up cooldown) even though OAuthFlow cannot * depend on ReportRunner directly: ReportRunner is built from * DataApi/SearchConsoleApi, which are themselves built from this * OAuthFlow, so injecting it here would be circular. * * @since 2.0.0 */ do_action( 'tagsmith_cache_cleared' ); return $this->settings_url( array( 'tagsmith_connected' => '1' ) ); } /** * Reads the email claim from an id_token. Never throws; '' when absent * or malformed. The token comes straight from Google's token endpoint * over TLS, so its signature is not re-verified here. * * @param string $id_token JWT. */ private static function email_from_id_token( string $id_token ): string { $parts = explode( '.', $id_token ); if ( count( $parts ) < 2 || '' === $parts[1] ) { return ''; } $b64 = strtr( $parts[1], '-_', '+/' ); $b64 = str_pad( $b64, (int) ( ceil( strlen( $b64 ) / 4 ) * 4 ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding a JWT segment, not obfuscation. $json = base64_decode( $b64, true ); if ( false === $json ) { return ''; } $claims = json_decode( $json, true ); if ( ! is_array( $claims ) || ! isset( $claims['email'] ) || ! is_string( $claims['email'] ) ) { return ''; } return sanitize_email( $claims['email'] ); } /** * BYO credentials when both the client id and secret are usable, else null (relay mode). * * @return array{client_id: string, client_secret: string}|null */ private function usable_byo_credentials(): ?array { $byo = $this->connection->byo_credentials(); if ( null === $byo || '' === $byo['client_id'] || null === $byo['client_secret'] || '' === $byo['client_secret'] ) { return null; } return array( 'client_id' => $byo['client_id'], 'client_secret' => $byo['client_secret'], ); } /** * Whether an OAuth return URL is acceptable to the relay and Google: * https, or plain http only on a local development host. * * @param string $url Return URL. */ private static function secure_url( string $url ): bool { $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ); if ( 'https' === $scheme ) { return true; } $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); if ( 'http' !== $scheme || '' === $host ) { return false; } if ( in_array( $host, self::LOCAL_HOSTS, true ) ) { return true; } foreach ( self::LOCAL_SUFFIXES as $suffix ) { if ( str_ends_with( $host, $suffix ) ) { return true; } } return false; } /** * Filters features to the known set, in canonical order, always including analytics. * * @param array $features Requested features. * @return string[] */ private function normalize_features( array $features ): array { $features[] = 'analytics'; return array_values( array_intersect( self::FEATURES, $features ) ); } /** * The Google scope string for a set of features. * * @param string[] $features Normalized features. */ private function scope_string( array $features ): string { $scopes = array( 'openid', 'email', self::SCOPE_ANALYTICS ); if ( in_array( 'searchconsole', $features, true ) ) { $scopes[] = self::SCOPE_GSC; } return implode( ' ', $scopes ); } /** * A string request field, or '' when absent or not a string. * * @param array $request Request fields. * @param string $key Field name. */ private function request_string( array $request, string $key ): string { return isset( $request[ $key ] ) && is_string( $request[ $key ] ) ? $request[ $key ] : ''; } /** * Whether a state string has the expected format. * * @param string $state State. */ private static function valid_state( string $state ): bool { return 1 === preg_match( self::STATE_PATTERN, $state ); } /** * The settings screen URL with an error slug. * * @param string $slug Error slug ([a-z0-9_-]). */ private function error_url( string $slug ): string { return $this->settings_url( array( 'tagsmith_error' => $slug ) ); } /** * The settings screen URL with extra query args. * * @param array $args Query args. */ private function settings_url( array $args ): string { return admin_url( 'admin.php?page=tagsmith-settings&' . http_build_query( $args ) ); } /** * Random base64url string (no padding). * * @param int $bytes Number of random bytes. */ private static function random_b64url( int $bytes ): string { return self::b64url( random_bytes( $bytes ) ); } /** * Base64url-encodes without padding. * * @param string $raw Raw bytes. */ private static function b64url( string $raw ): string { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- base64url for OAuth state and PKCE, not obfuscation. return rtrim( strtr( base64_encode( $raw ), '+/', '-_' ), '=' ); } }