$client_id, 'redirect_uri' => $redirect_uri, 'response_type' => 'code', 'scope' => implode( ' ', $scopes ), 'access_type' => 'offline', // "select_account" makes Google offer its account chooser, so a second // account can be added without signing the first one out. 'prompt' => $choose_account ? 'select_account consent' : 'consent', 'include_granted_scopes' => 'true', 'state' => $state, 'code_challenge' => Base64Url::encode( hash( 'sha256', $verifier, true ) ), 'code_challenge_method' => 'S256', ); return self::AUTHORIZE_URL . '?' . http_build_query( $params, '', '&', PHP_QUERY_RFC3986 ); } /** * Exchanges an authorization code for tokens. * * @param string $client_id Client ID. * @param string $client_secret Client secret. * @param string $code Authorization code. * @param string $verifier PKCE code verifier. * @param string $redirect_uri Redirect URI used in the request. * @return array Token response. * @throws ApiException When Google rejects the exchange. */ public static function exchange_code( $client_id, $client_secret, $code, $verifier, $redirect_uri ) { return self::token_request( array( 'grant_type' => 'authorization_code', 'code' => $code, 'client_id' => $client_id, 'client_secret' => $client_secret, 'redirect_uri' => $redirect_uri, 'code_verifier' => $verifier, ) ); } /** * Gets a new access token from a refresh token. * * @param string $client_id Client ID. * @param string $client_secret Client secret. * @param string $refresh_token Refresh token. * @return array Token response. * @throws ApiException When Google rejects the refresh. */ public static function refresh( $client_id, $client_secret, $refresh_token ) { return self::token_request( array( 'grant_type' => 'refresh_token', 'client_id' => $client_id, 'client_secret' => $client_secret, 'refresh_token' => $refresh_token, ) ); } /** * Revokes a token. Failures are ignored: the token is deleted locally either way. * * @param string $token Refresh or access token. * @return void */ public static function revoke( $token ) { if ( '' === (string) $token ) { return; } wp_remote_post( self::REVOKE_URL, array( 'timeout' => 10, 'body' => array( 'token' => $token ), ) ); } /** * Posts to Google's token endpoint. * * The endpoint is fixed, never taken from user input such as a key file, * so a crafted key file can't make the server call another host. * * @param array $body Form fields. * @return array Decoded response. * @throws ApiException On network or Google errors. */ public static function token_request( array $body ) { $response = wp_remote_post( self::TOKEN_URL, array( 'timeout' => 20, 'headers' => array( 'Accept' => 'application/json' ), 'body' => $body, ) ); if ( is_wp_error( $response ) ) { /* translators: %s: network error message. */ throw new ApiException( sprintf( __( 'Couldn\'t reach Google: %s', 'turtlereport' ), $response->get_error_message() ), ApiException::NETWORK ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } $status = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); $valid = JSON_ERROR_NONE === json_last_error() && is_array( $data ); if ( $status >= 400 ) { throw ApiException::from_token_error( $status, $valid ? $data : array() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } if ( ! $valid || $status < 200 || $status >= 300 || ! isset( $data['access_token'] ) || ! is_string( $data['access_token'] ) || '' === $data['access_token'] ) { throw new ApiException( __( 'Google returned an unreadable sign-in response. Try connecting again.', 'turtlereport' ), ApiException::GOOGLE, $status ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } foreach ( array( 'refresh_token', 'scope', 'id_token', 'token_type' ) as $field ) { if ( isset( $data[ $field ] ) && ! is_string( $data[ $field ] ) ) { throw new ApiException( __( 'Google returned an unreadable sign-in response. Try connecting again.', 'turtlereport' ), ApiException::GOOGLE, $status ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } } if ( isset( $data['expires_in'] ) && ( ( ! is_int( $data['expires_in'] ) && ! is_string( $data['expires_in'] ) ) || ! ctype_digit( (string) $data['expires_in'] ) || (int) $data['expires_in'] < 1 ) ) { throw new ApiException( __( 'Google returned an unreadable sign-in response. Try connecting again.', 'turtlereport' ), ApiException::GOOGLE, $status ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } return $data; } /** * Reads the email address from an ID token. * * The token comes straight from Google's token endpoint over TLS, so its * signature does not need checking for this display-only purpose. * * @param string $id_token JWT. * @return string */ public static function email_from_id_token( $id_token ) { $parts = explode( '.', (string) $id_token ); if ( 3 !== count( $parts ) ) { return ''; } $claims = json_decode( Base64Url::decode( $parts[1] ), true ); return is_array( $claims ) && isset( $claims['email'] ) && is_string( $claims['email'] ) ? sanitize_email( $claims['email'] ) : ''; } }