connection_id = $connection_id; $this->key = $key; } /** * Validates a key file and keeps only the fields the plugin needs. * * @param string $json Key file contents. * @return array Normalised key data. * @throws \InvalidArgumentException When the file isn't a usable service-account key. */ public static function parse_key( $json ) { $data = json_decode( (string) $json, true ); if ( ! is_array( $data ) ) { throw new \InvalidArgumentException( __( 'That isn\'t valid JSON. Upload the .json key file exactly as Google downloaded it.', 'turtlereport' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } if ( isset( $data['web'] ) || isset( $data['installed'] ) ) { throw new \InvalidArgumentException( __( 'That is an OAuth client file, not a service-account key. Use "Sign in with Google" for OAuth clients, or create a key under IAM & Admin → Service accounts → Keys.', 'turtlereport' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } if ( ! isset( $data['type'] ) || 'service_account' !== $data['type'] ) { throw new \InvalidArgumentException( __( 'That file is not a service-account key. Its "type" must be "service_account".', 'turtlereport' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } foreach ( array( 'client_email', 'private_key', 'private_key_id', 'project_id' ) as $field ) { if ( array_key_exists( $field, $data ) && ! is_string( $data[ $field ] ) ) { throw new \InvalidArgumentException( __( 'The key file fields must contain text, not nested data or numbers. Upload the .json key file exactly as Google downloaded it.', 'turtlereport' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } } $email = isset( $data['client_email'] ) ? sanitize_email( $data['client_email'] ) : ''; $pem = isset( $data['private_key'] ) ? (string) $data['private_key'] : ''; if ( ! is_email( $email ) || false === strpos( $pem, 'PRIVATE KEY' ) ) { throw new \InvalidArgumentException( __( 'The key file is missing its client_email or private_key. Create a new JSON key in Google Cloud (IAM & Admin → Service accounts → Keys) and upload it unchanged.', 'turtlereport' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } if ( ! function_exists( 'openssl_pkey_get_private' ) || false === openssl_pkey_get_private( $pem ) ) { throw new \InvalidArgumentException( __( 'The private key in this file can\'t be read. Make sure the OpenSSL PHP extension is enabled and the file wasn\'t edited.', 'turtlereport' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } return array( 'client_email' => $email, 'private_key' => $pem, 'private_key_id' => isset( $data['private_key_id'] ) ? sanitize_text_field( $data['private_key_id'] ) : '', 'project_id' => isset( $data['project_id'] ) ? sanitize_text_field( $data['project_id'] ) : '', ); } /** * {@inheritDoc} * * @return string */ public function id() { return 'sa:' . $this->connection_id . ':' . $this->key['private_key_id']; } /** * {@inheritDoc} * * @param string $scope Scope URL. * @return bool */ public function supports( $scope ) { return in_array( $scope, Scopes::for_service_account(), true ); } /** * Service-account keys are revoked in Google Cloud, not via OAuth. * * @return void */ public function revoke() { $this->forget_token(); } /** * {@inheritDoc} * * @return array * @throws ApiException When signing fails or Google refuses. */ protected function request_token() { $now = time(); $header = array( 'alg' => 'RS256', 'typ' => 'JWT', ); if ( '' !== $this->key['private_key_id'] ) { $header['kid'] = $this->key['private_key_id']; } $claims = array( 'iss' => $this->key['client_email'], 'scope' => implode( ' ', Scopes::for_service_account() ), 'aud' => OAuthClient::TOKEN_URL, 'iat' => $now - 30, 'exp' => $now + 3300, ); $input = Base64Url::encode( wp_json_encode( $header ) ) . '.' . Base64Url::encode( wp_json_encode( $claims ) ); $signature = ''; if ( ! openssl_sign( $input, $signature, $this->key['private_key'], OPENSSL_ALGO_SHA256 ) ) { throw new ApiException( __( 'The service-account key couldn\'t sign a request. Upload a fresh key.', 'turtlereport' ), ApiException::RECONNECT ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } try { return OAuthClient::token_request( array( 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $input . '.' . Base64Url::encode( $signature ), ) ); } catch ( ApiException $e ) { if ( ApiException::RECONNECT !== $e->reason() ) { throw $e; } throw new ApiException( __( 'Google rejected this service-account key. It may have been deleted or disabled in Google Cloud, or this server\'s clock is wrong. Upload a new key.', 'turtlereport' ), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. ApiException::RECONNECT, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. $e->status() // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. ); } } }