$client_id, 'redirect_uri' => self::get_redirect_uri(), 'scope' => $scope, 'state' => $state, 'code_challenge' => $challenge, 'code_challenge_method' => 'S256', ]; return add_query_arg($params, self::get_authorize_url()); } public static function handle_callback_if_present(): ?array { // Called from settings page; returns ['ok' => bool, 'message' => string]. // OAuth callback: GET params come from authorization server redirect. CSRF is handled by state (PKCE), not WP nonce. // phpcs:disable WordPress.Security.NonceVerification.Recommended if (!isset($_GET['code']) && !isset($_GET['error'])) { return null; } if (!current_user_can('manage_options')) { return ['ok' => false, 'message' => 'Unauthorized']; } $error = isset($_GET['error']) ? sanitize_text_field(wp_unslash($_GET['error'])) : ''; if ($error !== '') { return ['ok' => false, 'message' => 'OAuth error: ' . $error]; } $code = isset($_GET['code']) ? sanitize_text_field(wp_unslash($_GET['code'])) : ''; $state = isset($_GET['state']) ? sanitize_text_field(wp_unslash($_GET['state'])) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended if ($code === '' || $state === '') { return ['ok' => false, 'message' => 'Missing code/state']; } $verifier = self::consume_pkce_verifier($state); if ($verifier === null) { return ['ok' => false, 'message' => 'Invalid or expired OAuth state']; } $token = self::exchange_code_for_token($code, $verifier); if (!$token['ok']) { return $token; } AgenizedApi::report_plugin_activated('woocommerce'); return ['ok' => true, 'message' => 'Connected to Agenized']; } private static function exchange_code_for_token(string $code, string $code_verifier): array { $api_base = Defaults::merchant_api_base_url(); $api_base = rtrim($api_base, '/'); $client_id = self::get_client_id(); $body = [ 'grant_type' => 'authorization_code', 'client_id' => $client_id, 'code' => $code, 'redirect_uri' => self::get_redirect_uri(), 'code_verifier' => $code_verifier, ]; $res = wp_remote_post( $api_base . '/v1/oauth/token', [ 'timeout' => 20, 'headers' => ['Content-Type' => 'application/json'], 'body' => wp_json_encode($body), ] ); if (is_wp_error($res)) { return ['ok' => false, 'message' => $res->get_error_message()]; } $code_status = wp_remote_retrieve_response_code($res); $raw = wp_remote_retrieve_body($res); $data = json_decode($raw, true); if ($code_status < 200 || $code_status >= 300) { $detail = is_array($data) && isset($data['detail']) ? (string) $data['detail'] : $raw; return ['ok' => false, 'message' => 'Token exchange failed: ' . $detail]; } $access = is_array($data) && isset($data['access_token']) ? (string) $data['access_token'] : ''; $refresh = is_array($data) && isset($data['refresh_token']) ? (string) $data['refresh_token'] : ''; $expires_in = is_array($data) && isset($data['expires_in']) ? (int) $data['expires_in'] : 0; if ($access === '' || $refresh === '' || $expires_in <= 0) { return ['ok' => false, 'message' => 'Token response missing fields']; } Options::update_string(Options::OPT_ACCESS_TOKEN, $access); Options::update_string(Options::OPT_REFRESH_TOKEN, $refresh); update_option(Options::OPT_TOKEN_EXPIRES_AT, time() + $expires_in - 30, false); // 30s clock skew return ['ok' => true, 'message' => 'OK']; } public static function get_valid_access_token(): ?string { $access = Options::get_string(Options::OPT_ACCESS_TOKEN, ''); $refresh = Options::get_string(Options::OPT_REFRESH_TOKEN, ''); if ($access === '' || $refresh === '') { return null; } $expires_at = (int) get_option(Options::OPT_TOKEN_EXPIRES_AT, 0); if ($expires_at > 0 && time() >= $expires_at) { $refreshed = self::refresh_token(); if (!$refreshed['ok']) { return null; } $access = Options::get_string(Options::OPT_ACCESS_TOKEN, ''); } return $access !== '' ? $access : null; } public static function refresh_token(): array { $api_base = Defaults::merchant_api_base_url(); $api_base = rtrim($api_base, '/'); $client_id = self::get_client_id(); $refresh = Options::get_string(Options::OPT_REFRESH_TOKEN, ''); if ($refresh === '') { return ['ok' => false, 'message' => 'Missing refresh token']; } $body = [ 'grant_type' => 'refresh_token', 'client_id' => $client_id, 'refresh_token' => $refresh, ]; $res = wp_remote_post( $api_base . '/v1/oauth/token', [ 'timeout' => 20, 'headers' => ['Content-Type' => 'application/json'], 'body' => wp_json_encode($body), ] ); if (is_wp_error($res)) { return ['ok' => false, 'message' => $res->get_error_message()]; } $code_status = wp_remote_retrieve_response_code($res); $raw = wp_remote_retrieve_body($res); $data = json_decode($raw, true); if ($code_status < 200 || $code_status >= 300) { $detail = is_array($data) && isset($data['detail']) ? (string) $data['detail'] : $raw; return ['ok' => false, 'message' => 'Refresh failed: ' . $detail]; } $access = is_array($data) && isset($data['access_token']) ? (string) $data['access_token'] : ''; $new_refresh = is_array($data) && isset($data['refresh_token']) ? (string) $data['refresh_token'] : ''; $expires_in = is_array($data) && isset($data['expires_in']) ? (int) $data['expires_in'] : 0; if ($access === '' || $new_refresh === '' || $expires_in <= 0) { return ['ok' => false, 'message' => 'Refresh response missing fields']; } Options::update_string(Options::OPT_ACCESS_TOKEN, $access); Options::update_string(Options::OPT_REFRESH_TOKEN, $new_refresh); update_option(Options::OPT_TOKEN_EXPIRES_AT, time() + $expires_in - 30, false); return ['ok' => true, 'message' => 'OK']; } private static function transient_key(string $state): string { $user_id = get_current_user_id(); return self::TRANSIENT_PREFIX . $user_id . '_' . md5($state); } private static function store_pkce(string $state, string $verifier): void { set_transient(self::transient_key($state), ['verifier' => $verifier], self::TRANSIENT_TTL_SECONDS); } private static function consume_pkce_verifier(string $state): ?string { $key = self::transient_key($state); $data = get_transient($key); delete_transient($key); if (!is_array($data) || !isset($data['verifier']) || !is_string($data['verifier'])) { return null; } return $data['verifier']; } private static function generate_code_verifier(): string { // RFC 7636: length 43-128 of unreserved characters. $bytes = random_bytes(32); return self::base64url_encode($bytes); } private static function code_challenge_s256(string $verifier): string { return self::base64url_encode(hash('sha256', $verifier, true)); } private static function base64url_encode(string $bin): string { return rtrim(strtr(base64_encode($bin), '+/', '-_'), '='); } }