relay -> our * site_url?oauth=callback&code=...&state=...), runs the full safety * gauntlet (HMAC verify, host check, capability, multisite guard, * paired transient + user_id match), exchanges the code for a token via * the Google API client (with PKCE verifier when present), saves the * token, and PRG-redirects to a clean URL so a browser refresh does * not replay the exchange. * * Order of operations on the happy path: * * verify_state -> delete_transient(nonce) -> authenticate($code) * -> update_option(GA_TOKEN) -> update_option(GA_AUTH_DATE) -> PRG * * The transient is consumed BEFORE the exchange call. This NARROWS the * state-replay window — Google's single-use enforcement on the authorization * code is what fully closes it. Two concurrent callbacks racing the same * state value can both pass verify_state (which only reads the transient, * see oauth-state.php::verify_state), both proceed to delete_transient, * and both call authenticate(): the second exchange fails because Google * burned the code on the first call. The early delete still helps any * later-arriving racer (e.g. a browser refresh) by collapsing them onto * the cheap `missing_transient` branch. The cost is that an exception * inside authenticate() also burns the transient (deliberate) — the user * simply restarts the flow via the start handler and gets a fresh state. * * Each rejection branch fires do_action('fiftyonedegrees_oauth_rejection', * $branch, $user_id, $context) for observability, sets a short-lived * notice transient, and PRG-redirects to a clean admin URL. The branch * slug matches the leaf key under oauth.notice.* in * languages/oauth-strings.yaml. */ class FiftyOneDegreesOauthCallback { /** * Entry point. Hooked on admin_init priority 5 so it runs before * the rest of the admin bootstrap notices the empty token. */ public static function handle() { // a. Early skip: not our callback. We're on every admin page load // at priority 5, so be cheap and silent here. if (!isset($_GET['oauth']) || $_GET['oauth'] !== 'callback') { return; } if (!isset($_GET['page']) || $_GET['page'] !== '51Degrees') { return; } if (!isset($_GET['tab']) || $_GET['tab'] !== 'google-analytics') { return; } // b. Capability + login. An unauthenticated request never had a // legitimate state, so we bounce to wp-login without consuming // anything. No notice — the user does not have an admin tab to // read it on. if (!is_user_logged_in() || !current_user_can('manage_options')) { wp_safe_redirect(wp_login_url()); static::halt(); return; } $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. Google-side error (user denied, missing scope, consent cancelled). // No code/state to verify — surface to the admin and stop. if (isset($_GET['error']) && $_GET['error'] !== '') { self::reject('google_error', $user_id, ['error' => (string) $_GET['error']]); return; } // e-i. State verify (HMAC, expiry, host, transient, user_id). // FiftyOneDegreesOauthStateException::$reason is a stable slug that // maps 1:1 to a leaf key under oauth.notice.* — translate directly // to a rejection branch. $state = isset($_GET['state']) ? (string) $_GET['state'] : ''; // Cap HTTP_HOST length defensively: DNS names are bounded at 253 // octets, and a multi-kB Host header from a malicious upstream // would otherwise feed unbounded input into hash/compare paths. // A value past the cap will fail host_mismatch — same outcome, // bounded cost. $raw_host = isset($_SERVER['HTTP_HOST']) ? (string) $_SERVER['HTTP_HOST'] : ''; $expected_host = substr($raw_host, 0, 253); try { $verified = FiftyOneDegreesOauthState::verify_state($state, $user_id, $expected_host); } catch (FiftyOneDegreesOauthStateException $e) { self::reject($e->reason, $user_id); return; } $nonce = $verified['nonce']; $transient_key = FiftyOneDegreesOauthState::TRANSIENT_PREFIX . $nonce; $code_verifier = isset($verified['transient']['code_verifier']) ? $verified['transient']['code_verifier'] : null; // j. Consume the transient BEFORE the exchange call. See class-level // comment for the replay-window discussion. delete_transient($transient_key); // k. Exchange the authorization code for an access token. Refuse an // empty code up front so the user sees the right diagnostic instead // of a generic "exchange_failed" caused by the library rejecting ''. // The transient is already gone — retry needs a fresh state // initiated by the start handler. $code = isset($_GET['code']) ? (string) $_GET['code'] : ''; if ($code === '') { self::reject('missing_code', $user_id); return; } // Exchange the authorization code through the relay, which holds the // client secret. We present the authorization code, the resource key // (the relay re-validates it) and the PKCE verifier; the relay adds the // secret and returns the token set as snake_case JSON (the same shape // the Google client produces), which we store verbatim. // // Rejection context for the exchange failure paths is deliberately a // small scalar: hook subscribers (logging plugins, error monitors) // serialize whatever we hand them, and a token response can echo // partial token material. The branch slug alone is enough for the // admin-facing notice; ops diagnostics go through error_log. $resource = (string) get_option(Options::RESOURCE_KEY); try { $token = static::exchange_code($code, $resource, $code_verifier); } catch (\Throwable $e) { // Defensive: the relay client maps failures to ['error' => ...] // rather than throwing, but a future change or a fatal in the // HTTP stack should still land in exchange_failed. error_log('51Degrees OAuth exchange exception: ' . $e->getMessage()); self::reject('exchange_failed', $user_id); return; } // The relay client returns ['error' => slug] on any transport / non-2xx // / malformed-response failure. Treat it as an exchange failure. if (!is_array($token) || isset($token['error'])) { $error_code = is_array($token) && isset($token['error']) ? (string) $token['error'] : 'unknown'; self::reject('exchange_failed', $user_id, ['error' => $error_code]); return; } // m. SUCCESS. update_option may return false when the new value is // identical to the current one (WP semantics) — that's still a // valid save, so we do not check the return value. update_option(Options::GA_TOKEN, $token); update_option(Options::GA_AUTH_DATE, time()); FiftyOneDegreesOauthNotice::set('success'); wp_safe_redirect(self::clean_admin_url('&oauth-success=1')); static::halt(); } /** * Stores the notice, fires the observability action, and PRG-redirects * to a clean admin URL (without code/state/error). The branch slug is * the same value used in the notice transient and in the * fiftyonedegrees_oauth_rejection action — keep them aligned with the * leaf keys 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(self::clean_admin_url()); static::halt(); } /** * Builds the bare admin URL for the GA tab. Callers pass extra query * pairs already in '&key=value' form for appending — admin_url() returns * a URL with at least one existing query parameter so this is safe. */ private static function clean_admin_url($extra = '') { return admin_url('options-general.php?page=51Degrees&tab=google-analytics' . $extra); } /** * Test seam over the relay code-for-token exchange. Production delegates * to the relay client (which holds no secret locally — the relay does). * Tests override to return a canned token array or ['error' => ...] * without performing real HTTP. * * @param string $code the authorization code * @param string $resource the site's resource key * @param null|string $code_verifier the PKCE verifier * @return array */ protected static function exchange_code($code, $resource, $code_verifier) { return FiftyOneDegreesOauthRelayClient::exchange($code, $resource, $code_verifier); } /** * Test seam over `exit`. handle() and reject() invoke this immediately * after wp_safe_redirect so unit tests can subclass and replace exit * with a tracking flag instead of killing the PHPUnit process. */ protected static function halt() { exit; } }