delete_transient -> authenticate -> save_token). */ class FiftyOneDegreesOauthStart { /** * WP nonce action shared with the Connect button render in * google-analytics.php. The * button calls wp_nonce_field(self::NONCE_ACTION) and this handler * verifies the same slug via check_admin_referer. */ public const NONCE_ACTION = 'fiftyonedegrees_oauth_start'; /** * Entry point. Hooked on admin_post_fiftyonedegrees_oauth_start by * fiftyonedegrees.php::setup_oauth_actions(). */ public static function handle() { // a. Login + capability. Unauthenticated requests go to wp-login; // there is no notice channel for them. An authenticated non-admin // also lands on the login screen — that matches WP's own pattern // for admin-post handlers that require manage_options. if (!is_user_logged_in() || !current_user_can('manage_options')) { wp_safe_redirect(wp_login_url()); static::halt(); return; } // b. CSRF: WP nonce. check_admin_referer() calls wp_die() on a // missing or bad nonce, which is the correct behavior for an // admin-post handler. Tests stub it to throw instead. check_admin_referer(self::NONCE_ACTION); $user_id = (int) get_current_user_id(); // c. Multisite is out of scope for this release (roadmap: 1.0.13). if (is_multisite()) { self::reject('multisite_unsupported', $user_id); return; } // d. HTTPS gate. We read the scheme from home_url() — the // DB-stored siteurl — rather than $_SERVER. is_ssl() depends on // request-time server vars that may not reflect the external // scheme when WP sits behind a TLS-terminating proxy without a // matching site URL configuration. Trade-off: a site on a TLS // proxy where the admin has not yet updated General Settings to // https:// will fail this gate even though the request itself // is encrypted. if (parse_url((string) home_url(), PHP_URL_SCHEME) !== 'https') { self::reject('https_required', $user_id); return; } // e-f. Generate PKCE verifier + challenge, then build a state // string. create_state stashes the {user_id, verifier} pair in // a per-nonce transient that the callback will look up via // verify_state. try { $verifier = FiftyOneDegreesOauthState::generate_code_verifier(); $challenge = FiftyOneDegreesOauthState::code_challenge_from($verifier); $state = FiftyOneDegreesOauthState::create_state($user_id, $verifier); } catch (FiftyOneDegreesOauthStateException $e) { // Typed branch: secret_corrupt / encode_failure / invalid_user_id. // $e->reason is a slug that maps 1:1 to an oauth.notice.* key. self::reject($e->reason, $user_id); return; } catch (\Throwable $e) { // Last-resort catch for random_bytes() CSPRNG failure or any // future non-typed crash from the state engine. Without this // the request would bubble out as a WSOD and the transient // table stays clean — but the admin sees nothing actionable. error_log('51Degrees OAuth start exception: ' . $e->getMessage()); self::reject('start_failed', $user_id); return; } // g. Build the relay start URL and hand off. We do NOT build a Google // consent URL or hold a Google client here — the relay owns the shared // client and secret. We pass the resource key (for the relay's // domain-binding check), the site callback the relay forwards to, the // scope, our signed state (carried as `nonce` and echoed back verbatim // so verify_state still works on callback), and the PKCE challenge. The // matching verifier stays in our transient. $resource = (string) get_option(Options::RESOURCE_KEY); if ($resource === '') { // No resource key configured: the relay would reject /start. Fail // with the generic start branch rather than redirecting the admin // into a relay 400. self::reject('start_failed', $user_id); return; } $url = FiftyOneDegreesOauthRelayClient::start_url( $resource, self::site_callback_url(), Google_Service_GoogleAnalyticsAdmin::ANALYTICS_EDIT, $state, $challenge ); // h. Off to the relay. wp_redirect (not wp_safe_redirect) — see class // docblock for the host-allowlist rationale; the relay is an external // host. wp_redirect($url); static::halt(); } /** * The site callback URL the relay forwards the authorization code to. * Must match the `site_url` baked into the signed state by the state * engine so verify_state's host check lines up on the callback side. * * @return string */ protected static function site_callback_url() { return admin_url( 'options-general.php?page=51Degrees&tab=google-analytics&oauth=callback' ); } /** * Stores a notice slug, fires the observability action, and PRGs * back to the GA admin tab. Mirrors FiftyOneDegreesOauthCallback::reject * so the same `fiftyonedegrees_oauth_rejection` hook fires from both * sides of the flow; the leaf slug matches a key under oauth.notice.* * in languages/oauth-strings.yaml. */ private static function reject($branch, $user_id, array $context = []) { do_action('fiftyonedegrees_oauth_rejection', $branch, $user_id, $context); FiftyOneDegreesOauthNotice::set($branch); wp_safe_redirect(admin_url( 'options-general.php?page=51Degrees&tab=google-analytics' )); static::halt(); } /** * Test seam over `exit`. Production exits after the wp_redirect / * wp_safe_redirect to prevent any further admin_post handler from * running. Tests override to flag and return so PHPUnit isn't killed. */ protected static function halt() { exit; } }