Developer settings * > OAuth Apps > New OAuth App, callback URL = this site's * admin-post.php?action=esipher_github_oauth_callback) and pastes its * Client ID/Secret into Esipher's own settings — the same "your account, * your server" trust model as the rest of this plugin, just extended to * cover GitHub access instead of stopping short of it. * * @package Esipher */ namespace Esipher; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Builds the authorize URL, exchanges a callback code for an access * token, and stores/retrieves that token. */ class Github_Oauth { const OPTION_CLIENT_ID = 'esipher_github_client_id'; const OPTION_CLIENT_SECRET = 'esipher_github_client_secret'; const OPTION_TOKEN = 'esipher_github_token'; const OPTION_ACCOUNT_LOGIN = 'esipher_github_account_login'; const OPTION_TOKEN_SCOPES = 'esipher_github_token_scopes'; const STATE_TRANSIENT = 'esipher_github_oauth_state'; /** * Scopes requested (space-delimited, per the OAuth spec): * * - 'repo' — full control of private + public repos: read a * private repo's contents, create a webhook on it, * and trigger/read Actions runs. There's no narrower * GitHub OAuth scope that still covers webhook * management. * - 'workflow' — REQUIRED on top of 'repo' to create or update any * file under .github/workflows/ via the Contents API. * Without it, ensure_workflow_file()'s PUT is refused * by GitHub with a "Not Found"-class error and the * whole connect flow dies before the webhook is ever * created. */ const SCOPE = 'repo workflow'; /** * @return bool Whether a Client ID/Secret pair has been configured. */ public function is_configured() { return '' !== $this->client_id() && '' !== $this->client_secret(); } /** * @return bool Whether a usable access token is stored. */ public function is_connected() { return '' !== $this->token(); } /** * Whether the stored token actually carries every scope in * self::SCOPE. A token minted before 'workflow' was added to that * list authenticates fine but silently can't commit the build * workflow file, so the admin page uses this to prompt a reconnect * rather than letting connect_project() fail with a bare * "Not Found". Returns true when no scopes were recorded (a token * from before this bookkeeping existed) so a working connection is * never nagged on a false negative — GitHub's own API errors still * catch a genuinely under-scoped token in that case. * * @return bool */ public function has_required_scopes() { $recorded = (string) get_option( self::OPTION_TOKEN_SCOPES, '' ); if ( '' === $recorded ) { return true; } $granted = preg_split( '/[\s,]+/', $recorded, -1, PREG_SPLIT_NO_EMPTY ); foreach ( preg_split( '/\s+/', self::SCOPE, -1, PREG_SPLIT_NO_EMPTY ) as $needed ) { if ( ! in_array( $needed, $granted, true ) ) { return false; } } return true; } /** * @return string */ public function client_id() { return (string) get_option( self::OPTION_CLIENT_ID, '' ); } /** * @return string */ public function client_secret() { return (string) get_option( self::OPTION_CLIENT_SECRET, '' ); } /** * @return string GitHub login of the connected account, or '' if * not connected yet. */ public function account_login() { return (string) get_option( self::OPTION_ACCOUNT_LOGIN, '' ); } /** * @param string $client_id GitHub OAuth App Client ID. * @param string $client_secret GitHub OAuth App Client Secret. */ public function save_credentials( $client_id, $client_secret ) { update_option( self::OPTION_CLIENT_ID, sanitize_text_field( $client_id ), false ); update_option( self::OPTION_CLIENT_SECRET, sanitize_text_field( $client_secret ), false ); } /** * Build the URL to send the admin to GitHub's own authorize screen. * A fresh, single-use state value is stashed in a short-lived * transient and echoed back by GitHub on the callback, so * exchange_code() can reject a forged/replayed callback. * * @param string $redirect_uri This site's own callback URL. * * @return string|null Null if no Client ID is configured yet. */ public function authorize_url( $redirect_uri ) { if ( ! $this->is_configured() ) { return null; } $state = wp_generate_password( 32, false ); set_transient( self::STATE_TRANSIENT, $state, 10 * MINUTE_IN_SECONDS ); return add_query_arg( array( 'client_id' => rawurlencode( $this->client_id() ), 'redirect_uri' => rawurlencode( $redirect_uri ), 'scope' => rawurlencode( self::SCOPE ), 'state' => rawurlencode( $state ), ), 'https://github.com/login/oauth/authorize' ); } /** * Exchange an authorization code for an access token and store it. * * @param string $code The ?code= GitHub sent to the callback. * @param string $state The ?state= GitHub echoed back. * @param string $redirect_uri Must exactly match what authorize_url() * sent — GitHub itself enforces this. * * @return true|\WP_Error */ public function exchange_code( $code, $state, $redirect_uri ) { $expected_state = get_transient( self::STATE_TRANSIENT ); delete_transient( self::STATE_TRANSIENT ); if ( ! $expected_state || ! hash_equals( (string) $expected_state, (string) $state ) ) { return new \WP_Error( 'esipher_github_oauth_state', __( 'GitHub sign-in expired or was tampered with — please try connecting again.', 'esipher' ) ); } $response = wp_remote_post( 'https://github.com/login/oauth/access_token', array( 'headers' => array( 'Accept' => 'application/json' ), 'body' => array( 'client_id' => $this->client_id(), 'client_secret' => $this->client_secret(), 'code' => $code, 'redirect_uri' => $redirect_uri, ), 'timeout' => 15, ) ); if ( is_wp_error( $response ) ) { return $response; } $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $body ) || empty( $body['access_token'] ) ) { $message = is_array( $body ) && ! empty( $body['error_description'] ) ? $body['error_description'] : __( 'GitHub did not return an access token.', 'esipher' ); return new \WP_Error( 'esipher_github_oauth_exchange', $message ); } $this->store_token( (string) $body['access_token'] ); // GitHub echoes the scopes actually granted (the user can // withhold some on the consent screen). Record them so // has_required_scopes() can spot a token that predates a change // to self::SCOPE and prompt a reconnect. update_option( self::OPTION_TOKEN_SCOPES, isset( $body['scope'] ) ? (string) $body['scope'] : '', false ); $user = ( new Github_Api_Client( $this ) )->get_authenticated_user(); if ( ! is_wp_error( $user ) && ! empty( $user['login'] ) ) { update_option( self::OPTION_ACCOUNT_LOGIN, sanitize_text_field( $user['login'] ), false ); } return true; } /** * Forget the stored token and account — does not revoke the token * on GitHub's side (that needs the OAuth App's own Client * ID/Secret in a DELETE call GitHub restricts to the app itself); * revoking from GitHub's Settings > Applications screen is the * user's own escape hatch if that's wanted too. */ public function disconnect() { delete_option( self::OPTION_TOKEN ); delete_option( self::OPTION_ACCOUNT_LOGIN ); delete_option( self::OPTION_TOKEN_SCOPES ); } /** * @return string Decrypted access token, or '' if none is stored * or decryption fails (e.g. wp_salt() rotated). */ public function token() { $stored = get_option( self::OPTION_TOKEN, '' ); if ( '' === $stored ) { return ''; } return $this->decrypt( (string) $stored ); } /** * @param string $access_token Raw token from GitHub. */ private function store_token( $access_token ) { update_option( self::OPTION_TOKEN, $this->encrypt( $access_token ), false ); } /** * Lightweight at-rest obfuscation for the token, keyed off this * install's own AUTH_KEY/AUTH_SALT — not a substitute for * filesystem/database access control (nothing stored in wp_options * ever is), but it does mean a raw database dump or backup file * doesn't hand over a usable GitHub token in plain text the way a * bare update_option() would. * * @param string $plaintext * * @return string Base64-encoded "iv:ciphertext", or '' if OpenSSL * isn't available. */ private function encrypt( $plaintext ) { if ( ! function_exists( 'openssl_encrypt' ) ) { return base64_encode( $plaintext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- last-resort fallback only when OpenSSL is unavailable; documented above as best-effort, not real encryption either way. } $key = $this->encryption_key(); $iv = openssl_random_pseudo_bytes( 16 ); $ciphertext = openssl_encrypt( $plaintext, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv ); return base64_encode( $iv . $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- encoding binary ciphertext for storage as a text option, not obfuscating plaintext. } /** * @param string $stored As produced by encrypt(). * * @return string Decrypted plaintext, or '' on any failure. */ private function decrypt( $stored ) { $raw = base64_decode( $stored, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- reversing encrypt()'s storage encoding, not deobfuscating plaintext. if ( false === $raw ) { return ''; } if ( ! function_exists( 'openssl_decrypt' ) ) { return $raw; } if ( strlen( $raw ) <= 16 ) { return ''; } $iv = substr( $raw, 0, 16 ); $ciphertext = substr( $raw, 16 ); $plaintext = openssl_decrypt( $ciphertext, 'aes-256-cbc', $this->encryption_key(), OPENSSL_RAW_DATA, $iv ); return false !== $plaintext ? $plaintext : ''; } /** * @return string 32-byte key derived from this install's own salts. */ private function encryption_key() { $secret = ( defined( 'AUTH_KEY' ) ? AUTH_KEY : '' ) . ( defined( 'AUTH_SALT' ) ? AUTH_SALT : '' ); if ( '' === $secret ) { $secret = wp_salt( 'auth' ); } return hash( 'sha256', $secret, true ); } }