Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<owner>/<repo>`. 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.
Expand Down
294 changes: 294 additions & 0 deletions src/Git_Updater/API/GitHub_API.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down Expand Up @@ -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( '<img class="git-oauth-icon" src="%s" alt="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 '<p><strong>' . esc_html__( 'OAuth token updated from GitHub.', 'git-updater' ) . '</strong></p>';
}

if ( str_starts_with( $status, 'error-' ) ) {
echo '<p><strong>' . esc_html__( 'GitHub OAuth was not completed. You can retry below.', 'git-updater' ) . '</strong></p>';
}

if ( empty( $credentials['client_id'] ) ) {
echo '<p>' . esc_html__( 'To enable OAuth login, set GU_GITHUB_OAUTH_CLIENT_ID in wp-config.php or filter gu_github_oauth_credentials.', 'git-updater' ) . '</p>';

return;
}

printf(
'<p><a class="button button-secondary" href="%s">%s</a></p>',
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.
*
Expand Down
54 changes: 54 additions & 0 deletions tests/test-github-oauth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

use Fragen\Git_Updater\API\GitHub_API;

/**
* Test GitHub OAuth helpers.
*/
class Test_GitHub_OAuth extends \WP_UnitTestCase {

/**
* Build an API instance for helper method tests.
*
* @return GitHub_API
*/
private function get_api() {
return new GitHub_API();
}

/**
* Read private helper method values through reflection.
*
* @param GitHub_API $api API instance.
* @param string $method Private method name.
* @param array $args Method args.
*
* @return mixed
*/
private function invoke_private_method( $api, $method, $args = [] ) {
$reflection = new ReflectionMethod( $api, $method );
$reflection->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 );
}
}