From ebabecd2e584907466ad3c90ebc47109e1541573 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Tue, 20 Jun 2023 12:21:47 +1000 Subject: [PATCH] Add a GitHub App client abstraction (#406) * Add production composer deps. * Load Composer autoloader. * Add the GitHub_App_Authorization client. --- .github/workflows/build.yml | 3 +- composer.json | 3 + composer.lock | 151 ++++++++++----- mu-plugins/loader.php | 9 + .../class-github-app-authorization.php | 172 ++++++++++++++++++ 5 files changed, 290 insertions(+), 48 deletions(-) create mode 100644 mu-plugins/utilities/class-github-app-authorization.php diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f0edfcdd0..5316cf922 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: - name: Install all dependencies run: | - composer install + composer install --no-dev npm install - name: Build @@ -28,6 +28,7 @@ jobs: - name: Ignore .gitignore run: | git add mu-plugins/blocks/*/build/* --force + git add vendor --force - name: Commit and push # Using a specific hash here instead of a tagged version, for risk mitigation, since this action modifies our repo. diff --git a/composer.json b/composer.json index 98fe8ab88..c0ed24af7 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,9 @@ "url": "git@github.com:WordPress/wporg-repo-tools.git" } ], + "require": { + "adhocore/jwt": "^1.0" + }, "require-dev": { "composer/installers": "~1.0", "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0", diff --git a/composer.lock b/composer.lock index 09570b819..54eed8c43 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,66 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "41ca30816731208b6aee30a8798b7355", - "packages": [], + "content-hash": "d6c8f14ae0c6a73d1bb3e8a81422f7b1", + "packages": [ + { + "name": "adhocore/jwt", + "version": "1.1.2", + "source": { + "type": "git", + "url": "https://github.com/adhocore/php-jwt.git", + "reference": "6c434af7170090bb7a8880d2bc220a2254ba7899" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/adhocore/php-jwt/zipball/6c434af7170090bb7a8880d2bc220a2254ba7899", + "reference": "6c434af7170090bb7a8880d2bc220a2254ba7899", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.5 || ^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ahc\\Jwt\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jitendra Adhikari", + "email": "jiten.adhikary@gmail.com" + } + ], + "description": "Ultra lightweight JSON web token (JWT) library for PHP5.5+.", + "keywords": [ + "auth", + "json-web-token", + "jwt", + "jwt-auth", + "jwt-php", + "token" + ], + "support": { + "issues": "https://github.com/adhocore/php-jwt/issues", + "source": "https://github.com/adhocore/php-jwt/tree/1.1.2" + }, + "funding": [ + { + "url": "https://paypal.me/ji10", + "type": "custom" + } + ], + "time": "2021-02-20T09:56:44+00:00" + } + ], "packages-dev": [ { "name": "composer/installers", @@ -235,30 +293,30 @@ }, { "name": "doctrine/instantiator", - "version": "1.5.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", - "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^11", + "doctrine/coding-standard": "^11", "ext-pdo": "*", "ext-phar": "*", - "phpbench/phpbench": "^0.16 || ^1", - "phpstan/phpstan": "^1.4", - "phpstan/phpstan-phpunit": "^1", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "vimeo/psalm": "^4.30 || ^5.4" + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" }, "type": "library", "autoload": { @@ -285,7 +343,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/1.5.0" + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" }, "funding": [ { @@ -301,7 +359,7 @@ "type": "tidelift" } ], - "time": "2022-12-30T00:15:36+00:00" + "time": "2022-12-30T00:23:10+00:00" }, { "name": "myclabs/deep-copy", @@ -364,16 +422,16 @@ }, { "name": "nikic/php-parser", - "version": "v4.15.4", + "version": "v4.15.5", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "6bb5176bc4af8bcb7d926f88718db9b96a2d4290" + "reference": "11e2663a5bc9db5d714eedb4277ee300403b4a9e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/6bb5176bc4af8bcb7d926f88718db9b96a2d4290", - "reference": "6bb5176bc4af8bcb7d926f88718db9b96a2d4290", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/11e2663a5bc9db5d714eedb4277ee300403b4a9e", + "reference": "11e2663a5bc9db5d714eedb4277ee300403b4a9e", "shasum": "" }, "require": { @@ -414,9 +472,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.4" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.5" }, - "time": "2023-03-05T19:49:14+00:00" + "time": "2023-05-19T20:20:00+00:00" }, { "name": "phar-io/manifest", @@ -1023,16 +1081,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.6", + "version": "9.6.9", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "b65d59a059d3004a040c16a82e07bbdf6cfdd115" + "reference": "a9aceaf20a682aeacf28d582654a1670d8826778" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b65d59a059d3004a040c16a82e07bbdf6cfdd115", - "reference": "b65d59a059d3004a040c16a82e07bbdf6cfdd115", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a9aceaf20a682aeacf28d582654a1670d8826778", + "reference": "a9aceaf20a682aeacf28d582654a1670d8826778", "shasum": "" }, "require": { @@ -1106,7 +1164,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.6" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.9" }, "funding": [ { @@ -1122,7 +1180,7 @@ "type": "tidelift" } ], - "time": "2023-03-27T11:43:46+00:00" + "time": "2023-06-11T06:13:56+00:00" }, { "name": "sebastian/cli-parser", @@ -1424,16 +1482,16 @@ }, { "name": "sebastian/diff", - "version": "4.0.4", + "version": "4.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d" + "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/3461e3fccc7cfdfc2720be910d3bd73c69be590d", - "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/74be17022044ebaaecfdf0c5cd504fc9cd5a7131", + "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131", "shasum": "" }, "require": { @@ -1478,7 +1536,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", - "source": "https://github.com/sebastianbergmann/diff/tree/4.0.4" + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.5" }, "funding": [ { @@ -1486,7 +1544,7 @@ "type": "github" } ], - "time": "2020-10-26T13:10:38+00:00" + "time": "2023-05-07T05:35:17+00:00" }, { "name": "sebastian/environment", @@ -2248,16 +2306,16 @@ }, { "name": "wp-phpunit/wp-phpunit", - "version": "6.1.1", + "version": "6.2.0", "source": { "type": "git", "url": "https://github.com/wp-phpunit/wp-phpunit.git", - "reference": "49521597fa525f762a50a4a6d22ed180839519fd" + "reference": "3b7ab767dde017dec9327cc024e9f26fd776a57b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-phpunit/wp-phpunit/zipball/49521597fa525f762a50a4a6d22ed180839519fd", - "reference": "49521597fa525f762a50a4a6d22ed180839519fd", + "url": "https://api.github.com/repos/wp-phpunit/wp-phpunit/zipball/3b7ab767dde017dec9327cc024e9f26fd776a57b", + "reference": "3b7ab767dde017dec9327cc024e9f26fd776a57b", "shasum": "" }, "type": "library", @@ -2292,7 +2350,7 @@ "issues": "https://github.com/wp-phpunit/issues", "source": "https://github.com/wp-phpunit/wp-phpunit" }, - "time": "2022-11-02T12:52:44+00:00" + "time": "2023-03-30T01:15:51+00:00" }, { "name": "wporg/wporg-repo-tools", @@ -2326,16 +2384,16 @@ }, { "name": "yoast/phpunit-polyfills", - "version": "1.0.4", + "version": "1.0.5", "source": { "type": "git", "url": "https://github.com/Yoast/PHPUnit-Polyfills.git", - "reference": "3c621ff5429d2b1ff96dc5808ad6cde99d31ea4c" + "reference": "3b59adeef77fb1c03ff5381dbb9d68b0aaff3171" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/3c621ff5429d2b1ff96dc5808ad6cde99d31ea4c", - "reference": "3c621ff5429d2b1ff96dc5808ad6cde99d31ea4c", + "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/3b59adeef77fb1c03ff5381dbb9d68b0aaff3171", + "reference": "3b59adeef77fb1c03ff5381dbb9d68b0aaff3171", "shasum": "" }, "require": { @@ -2343,13 +2401,12 @@ "phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0" }, "require-dev": { - "yoast/yoastcs": "^2.2.1" + "yoast/yoastcs": "^2.3.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "1.x-dev", - "dev-develop": "1.x-dev" + "dev-main": "2.x-dev" } }, "autoload": { @@ -2383,7 +2440,7 @@ "issues": "https://github.com/Yoast/PHPUnit-Polyfills/issues", "source": "https://github.com/Yoast/PHPUnit-Polyfills" }, - "time": "2022-11-16T09:07:52+00:00" + "time": "2023-03-30T23:39:05+00:00" } ], "aliases": [], @@ -2395,5 +2452,5 @@ "prefer-lowest": false, "platform": [], "platform-dev": [], - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.1.0" } diff --git a/mu-plugins/loader.php b/mu-plugins/loader.php index c5531b979..f8ecc5df3 100644 --- a/mu-plugins/loader.php +++ b/mu-plugins/loader.php @@ -15,6 +15,15 @@ Autoload\register_class_path( __NAMESPACE__, __DIR__ ); +// Composer loader. +if ( file_exists( __DIR__ . '/vendor/autoload.php' ) ) { + // Production. + require_once __DIR__ . '/vendor/autoload.php'; +} elseif ( file_exists( dirname( __DIR__ ) . '/vendor/autoload.php' ) ) { + // Development. + require_once dirname( __DIR__ ) . '/vendor/autoload.php'; +} + require_once __DIR__ . '/helpers/helpers.php'; require_once __DIR__ . '/blocks/global-header-footer/blocks.php'; require_once __DIR__ . '/blocks/horizontal-slider/horizontal-slider.php'; diff --git a/mu-plugins/utilities/class-github-app-authorization.php b/mu-plugins/utilities/class-github-app-authorization.php new file mode 100644 index 000000000..750048971 --- /dev/null +++ b/mu-plugins/utilities/class-github-app-authorization.php @@ -0,0 +1,172 @@ +app_id = (int) $app_id; + $this->key = $key; + $this->user_agent = $user_agent ?: "WordPress.org GitHub App {$this->app_id}; (+https://wordpress.org/)"; + } + + /** + * Wrapper for wp_remote_request() which uses this apps authorization. + * + * NOTE: Some customizations are available for the $url. + * - May skip including the api.github.com prefix, just the path is needed. + * - May use {ORG} within the URL which will be replaced with the authorized Organization. + * + * @see wp_remote_get() for paramters. + */ + public function request( $url, $args = [] ) { + $args['headers'] ??= []; + $args['headers']['Authorization'] = $this->get_authorization_header(); + + if ( ! str_starts_with( $url, 'https://' ) ) { + $url = 'https://api.github.com/' . ltrim( $url, '/' ); + } + + // Support some dynamic rewrites of the URL, to avoid hard-coding of the account names. + // ie. /orgs/{ORG}/.. => /orgs/WordPress/.. + $url = str_replace( '{ORG}', $this->get_authorized_account(), $url ); + + // Validate that the URL is expected. + if ( 'api.github.com' !== wp_parse_url( $url, PHP_URL_HOST ) ) { + // If the URL is not to the GitHub API, then we don't need to do anything else. + return new WP_Error( 'not_allowed', 'Only requests to the GitHub API are allowed.' ); + } + + return wp_remote_request( $url, $args ); + } + + /** + * Get the `Authorization: ...` header value for the app. + */ + public function get_authorization_header() { + $details = $this->get_app_install_token_details(); + + // Upon failure, just return an empty header, as GitHub will accept that at the lower rate limit temporarily. + return $details ? 'BEARER ' . $details['token'] : ''; + } + + /** + * Fetch the Organization it's authorized as. + * + * NOTE: We only support authorizing against a singular org/account here. + */ + public function get_authorized_account() { + return $this->get_app_install_token_details()['account'] ?? false; + } + + /** + * Fetch an App Authorization token for accessing Github Resources. + */ + protected function get_app_install_token_details() { + $transient_name = __CLASS__ . ':' . $this->app_id . '_app_install_details'; + $details = get_site_transient( $transient_name ); + if ( $details ) { + return $details; + } + + $jwt_token = $this->get_jwt_app_token(); + if ( ! $jwt_token ) { + return false; + } + + $installs = wp_remote_get( + 'https://api.github.com/app/installations', + array( + 'user-agent' => $this->user_agent, + 'headers' => array( + 'Accept' => 'application/vnd.github.machine-man-preview+json', + 'Authorization' => 'BEARER ' . $jwt_token, + ), + ) + ); + + $installs = is_wp_error( $installs ) ? false : json_decode( wp_remote_retrieve_body( $installs ) ); + + if ( ! $installs || empty( $installs[0]->access_tokens_url ) ) { + return false; + } + + $access_token = wp_remote_post( + $installs[0]->access_tokens_url, + array( + 'user-agent' => $this->user_agent, + 'headers' => array( + 'Accept' => 'application/vnd.github.machine-man-preview+json', + 'Authorization' => 'BEARER ' . $jwt_token, + ), + ) + ); + + $access_token = is_wp_error( $access_token ) ? false : json_decode( wp_remote_retrieve_body( $access_token ) ); + if ( ! $access_token || empty( $access_token->token ) ) { + return false; + } + + $token = $access_token->token; + $token_exp = strtotime( $access_token->expires_at ); + + $details = [ + 'token' => $token, + 'account' => $installs[0]->account->login, + ]; + + // Cache the details for 1 minute less than what it's valid for. + set_site_transient( $transient_name, $details, $token_exp - time() - MINUTE_IN_SECONDS ); + + return $details; + } + + /** + * Generate a JWT Authorization token for the Github /app API endpoints. + */ + protected function get_jwt_app_token() { + $transient_name = __CLASS__ . ':' . $this->app_id . '_app_token'; + $token = get_site_transient( $transient_name ); + if ( $token ) { + return $token; + } + + $key = defined( $this->key ) ? constant( $this->key ) : $this->key; + if ( ! str_contains( $key, 'BEGIN RSA PRIVATE KEY' ) ) { + $key = base64_decode( $key ); + } + + try { + $jwt = new \Ahc\Jwt\JWT( openssl_pkey_get_private( $key ), 'RS256' ); + } catch( Exception $e ) { + return false; + } + + $token = $jwt->encode( array( + 'iat' => time(), + 'exp' => time() + $this->expiry, + 'iss' => $this->app_id, + ) ); + + // Cache it for 1 minute less than the expiry. + set_site_transient( $transient_name, $token, $this->expiry - MINUTE_IN_SECONDS ); + + return $token; + } +}