diff --git a/README.md b/README.md index a0ef7117d..a46997987 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,20 @@ or ...where the above URI leads to the __owner/repository__ of your theme or plugin. The URI format is `https://github.com//`. You **should not** include any extensions like `.git`. +### GitHub OAuth Token Flow + +Git Updater can initiate a GitHub OAuth authorization flow from the GitHub settings tab and save the returned token as `github_access_token`. + +To enable OAuth, define credentials in `wp-config.php` (or provide them via the `gu_github_oauth_credentials` filter): + +```php +define( 'GU_GITHUB_OAUTH_CLIENT_ID', 'your-client-id' ); +define( 'GU_GITHUB_OAUTH_CLIENT_SECRET', 'your-client-secret' ); // Optional when using PKCE-only app config. +define( 'GU_GITHUB_OAUTH_SCOPE', 'repo' ); // Optional, defaults to repo. +``` + +After setting credentials, use **Authorize via GitHub OAuth** on the GitHub tab in Git Updater settings. + ### API Plugins API plugins for Bitbucket, GitLab, Gitea, and Gist are available. API plugins are available for a one-click install from the **Add-Ons** tab. diff --git a/src/Git_Updater/API/GitHub_API.php b/src/Git_Updater/API/GitHub_API.php index 9dff8d39d..7417179c3 100644 --- a/src/Git_Updater/API/GitHub_API.php +++ b/src/Git_Updater/API/GitHub_API.php @@ -39,6 +39,7 @@ public function __construct( $type = null ) { parent::__construct(); $this->type = $type; $this->response = []; + add_action( 'admin_init', [ $this, 'maybe_handle_oauth_flow' ] ); $this->settings_hook( $this ); $this->add_settings_subtab(); $this->add_install_fields( $this ); @@ -539,10 +540,303 @@ public function print_section_github_info() { */ public function print_section_github_access_token() { esc_html_e( 'Enter your personal GitHub.com or GitHub Enterprise Access Token to avoid API access limits.', 'git-updater' ); + $this->render_oauth_controls(); $icon = plugin_dir_url( dirname( __DIR__, 2 ) ) . 'assets/github-logo.svg'; printf( 'GitHub logo', esc_attr( $icon ) ); } + /** + * Output OAuth controls and status messages. + */ + private function render_oauth_controls() { + $credentials = $this->get_oauth_credentials(); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only status query arg for UI message only. + $status = isset( $_GET['gu_github_oauth'] ) ? sanitize_key( wp_unslash( $_GET['gu_github_oauth'] ) ) : ''; + + if ( 'success' === $status ) { + echo '

' . esc_html__( 'OAuth token updated from GitHub.', 'git-updater' ) . '

'; + } + + if ( str_starts_with( $status, 'error-' ) ) { + echo '

' . esc_html__( 'GitHub OAuth was not completed. You can retry below.', 'git-updater' ) . '

'; + } + + if ( empty( $credentials['client_id'] ) ) { + echo '

' . esc_html__( 'To enable OAuth login, set GU_GITHUB_OAUTH_CLIENT_ID in wp-config.php or filter gu_github_oauth_credentials.', 'git-updater' ) . '

'; + + return; + } + + printf( + '

%s

', + esc_url( $this->get_oauth_start_url() ), + esc_html__( 'Authorize via GitHub OAuth', 'git-updater' ) + ); + } + + /** + * Start OAuth flow and process callback. + */ + public function maybe_handle_oauth_flow() { + if ( ! is_admin() || ! current_user_can( 'manage_options' ) ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is validated in start_oauth_flow(). + if ( isset( $_GET['gu_github_oauth_start'] ) ) { + $this->start_oauth_flow(); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth state+PKCE verifier validation is used on callback. + if ( isset( $_GET['gu_github_oauth_callback'] ) ) { + $this->complete_oauth_flow(); + } + } + + /** + * Build start URL for OAuth flow. + * + * @return string + */ + private function get_oauth_start_url() { + $redirect = $this->get_settings_redirect_url(); + + return add_query_arg( + [ + 'gu_github_oauth_start' => 1, + '_wpnonce' => wp_create_nonce( 'gu-github-oauth-start' ), + ], + $redirect + ); + } + + /** + * Start GitHub OAuth redirect. + */ + private function start_oauth_flow() { + if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_key( wp_unslash( $_GET['_wpnonce'] ) ), 'gu-github-oauth-start' ) ) { + $this->oauth_redirect_with_status( 'error-nonce' ); + + return; + } + + $credentials = $this->get_oauth_credentials(); + if ( empty( $credentials['client_id'] ) ) { + $this->oauth_redirect_with_status( 'error-client-id' ); + + return; + } + + $state = wp_generate_password( 48, false, false ); + $verifier = wp_generate_password( 96, false, false ); + $key = $this->get_oauth_transient_key( $state ); + + set_transient( + $key, + [ + 'code_verifier' => $verifier, + ], + 15 * MINUTE_IN_SECONDS + ); + + $authorize_url = add_query_arg( + [ + 'client_id' => $credentials['client_id'], + 'redirect_uri' => $this->get_oauth_callback_url(), + 'scope' => $credentials['scope'], + 'state' => $state, + 'code_challenge' => $this->get_oauth_code_challenge( $verifier ), + 'code_challenge_method' => 'S256', + ], + 'https://github.com/login/oauth/authorize' + ); + + wp_safe_redirect( $authorize_url ); + exit; + } + + /** + * Process GitHub callback and save token. + */ + private function complete_oauth_flow() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback is validated through state and PKCE verifier. + $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback is validated through state and PKCE verifier. + $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; + + if ( empty( $state ) || empty( $code ) ) { + $this->oauth_redirect_with_status( 'error-callback' ); + + return; + } + + $key = $this->get_oauth_transient_key( $state ); + $flow = get_transient( $key ); + $verifier = is_array( $flow ) && ! empty( $flow['code_verifier'] ) ? $flow['code_verifier'] : ''; + delete_transient( $key ); + + if ( empty( $verifier ) ) { + $this->oauth_redirect_with_status( 'error-state' ); + + return; + } + + $credentials = $this->get_oauth_credentials(); + $token = $this->exchange_code_for_token( $credentials, $code, $verifier ); + + if ( empty( $token ) ) { + $this->oauth_redirect_with_status( 'error-token' ); + + return; + } + + $options = get_site_option( 'git_updater', [] ); + $options['github_access_token'] = $token; + update_site_option( 'git_updater', $options ); + static::$options['github_access_token'] = $token; + + $this->oauth_redirect_with_status( 'success' ); + } + + /** + * Exchange callback code for access token. + * + * @param array $credentials OAuth credentials. + * @param string $code Callback code. + * @param string $verifier PKCE verifier. + * + * @return string + */ + private function exchange_code_for_token( $credentials, $code, $verifier ) { + $body = [ + 'client_id' => $credentials['client_id'], + 'code' => $code, + 'redirect_uri' => $this->get_oauth_callback_url(), + 'code_verifier' => $verifier, + ]; + + if ( ! empty( $credentials['client_secret'] ) ) { + $body['client_secret'] = $credentials['client_secret']; + } + + $response = wp_remote_post( + 'https://github.com/login/oauth/access_token', + [ + 'timeout' => 15, + 'headers' => [ + 'Accept' => 'application/json', + ], + 'body' => $body, + ] + ); + + if ( is_wp_error( $response ) ) { + return ''; + } + + $payload = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( ! is_array( $payload ) || empty( $payload['access_token'] ) ) { + return ''; + } + + return sanitize_text_field( $payload['access_token'] ); + } + + /** + * Build callback URL for GitHub OAuth. + * + * @return string + */ + private function get_oauth_callback_url() { + return add_query_arg( 'gu_github_oauth_callback', 1, $this->get_settings_redirect_url() ); + } + + /** + * Build redirect URL to GitHub subtab. + * + * @return string + */ + private function get_settings_redirect_url() { + $base = is_multisite() ? network_admin_url( 'settings.php' ) : admin_url( 'options-general.php' ); + + return add_query_arg( + [ + 'page' => 'git-updater', + 'tab' => 'git_updater_settings', + 'subtab' => 'github', + ], + $base + ); + } + + /** + * Redirect to GitHub settings with flow status. + * + * @param string $status OAuth status value. + */ + private function oauth_redirect_with_status( $status ) { + wp_safe_redirect( + add_query_arg( + 'gu_github_oauth', + $status, + $this->get_settings_redirect_url() + ) + ); + exit; + } + + /** + * Return GitHub OAuth credentials. + * + * @return array + */ + private function get_oauth_credentials() { + $client_id = defined( 'GU_GITHUB_OAUTH_CLIENT_ID' ) ? GU_GITHUB_OAUTH_CLIENT_ID : ''; + $client_secret = defined( 'GU_GITHUB_OAUTH_CLIENT_SECRET' ) ? GU_GITHUB_OAUTH_CLIENT_SECRET : ''; + $scope = defined( 'GU_GITHUB_OAUTH_SCOPE' ) ? GU_GITHUB_OAUTH_SCOPE : 'repo'; + + $credentials = [ + 'client_id' => $client_id, + 'client_secret' => $client_secret, + 'scope' => $scope, + ]; + + /** + * Filter OAuth credentials for GitHub auth flow. + * + * @since 13.4.0 + * + * @param array $credentials OAuth configuration. + */ + return apply_filters( 'gu_github_oauth_credentials', $credentials ); + } + + /** + * Build transient key for OAuth flow state. + * + * @param string $state OAuth state. + * + * @return string + */ + private function get_oauth_transient_key( $state ) { + return 'gu_github_oauth_' . md5( $state ); + } + + /** + * Build S256 PKCE challenge. + * + * @param string $verifier PKCE verifier. + * + * @return string + */ + private function get_oauth_code_challenge( $verifier ) { + $hash = hash( 'sha256', $verifier, true ); + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for RFC7636 base64url PKCE encoding. + return rtrim( strtr( base64_encode( $hash ), '+/', '-_' ), '=' ); + } + /** * Add remote install settings fields. * diff --git a/tests/test-github-oauth.php b/tests/test-github-oauth.php new file mode 100644 index 000000000..5af8ce8db --- /dev/null +++ b/tests/test-github-oauth.php @@ -0,0 +1,54 @@ +setAccessible( true ); + + return $reflection->invokeArgs( $api, $args ); + } + + /** + * Verify PKCE S256 challenge output. + */ + public function test_get_oauth_code_challenge() { + $api = $this->get_api(); + $challenge = $this->invoke_private_method( $api, 'get_oauth_code_challenge', [ 'abc123' ] ); + + $this->assertSame( 'bKE9UspwyIPg8LsQHkJaiehiTeUdstI5JZOvaoQRgJA', $challenge ); + } + + /** + * Verify transient key derivation from OAuth state. + */ + public function test_get_oauth_transient_key() { + $api = $this->get_api(); + $key = $this->invoke_private_method( $api, 'get_oauth_transient_key', [ 'state-value' ] ); + + $this->assertSame( 'gu_github_oauth_' . md5( 'state-value' ), $key ); + } +}