auth_base_url = rtrim( $auth_base_url, '/' ); $this->auth_public_base_url = $auth_public_base_url !== null ? rtrim( $auth_public_base_url, '/' ) : $this->auth_base_url; $this->event_url = $event_url !== null && $event_url !== '' ? $event_url : self::event_url_from_redirect( $redirect_uri ); } public function start_flow(): string { $verifier_raw = random_bytes( 32 ); $verifier = self::b64url( $verifier_raw ); $challenge = self::b64url( hash( 'sha256', $verifier, true ) ); $state = bin2hex( random_bytes( 32 ) ); set_transient( $this->transient_key( $state ), [ 'verifier' => $verifier, 'state' => $state, 'created_at' => time(), // Bind state to the admin + login session that started the flow // (RFC 6749 §10.12), the binding a WP nonce would carry (ORL-246). 'user_id' => get_current_user_id(), 'session' => self::session_fingerprint(), ], self::PKCE_TTL ); $params = [ 'response_type' => 'code', 'client_id' => $this->client_id, 'redirect_uri' => $this->redirect_uri, 'code_challenge' => $challenge, 'code_challenge_method' => 'S256', 'scope' => self::SCOPES, 'state' => $state, // OIDC `prompt=create` — fresh WP installs almost always need a // new Webapp publisher account. Auth lands unauthenticated users // on /signup; the signup page links back to /signin if they // already have one. Sessioned users skip both screens. 'prompt' => 'create', ]; return $this->auth_public_base_url . '/authorize?' . http_build_query( $params ); } /** * Read the durable approval state for this installation's exact callback. * * @return array{status:string,registration_id:?string,redirect_uri:string,requested_at:?string} */ public function redirect_registration_status(): array { return $this->expect_json( $this->post_json( $this->auth_base_url . '/v1/oauth-redirect-registrations/status', [ 'client_id' => $this->client_id, 'redirect_uri' => $this->redirect_uri, ] ), 'redirect registration status' ); } /** * Submit this exact callback for operator review. The stable key makes * retries and double-clicks one durable request at auth. * * @return array{status:string,registration_id:?string,redirect_uri:string,requested_at:?string} */ public function request_redirect_registration(): array { $request_key = 'wp-redirect-' . hash( 'sha256', $this->client_id . "\n" . $this->redirect_uri ); return $this->expect_json( $this->post_json( $this->auth_base_url . '/v1/oauth-redirect-registrations/request', [ 'client_id' => $this->client_id, 'redirect_uri' => $this->redirect_uri, ], [ 'Idempotency-Key' => $request_key ] ), 'redirect registration request' ); } public function handle_callback( string $code, string $state ): void { $key = $this->transient_key( $state ); $entry = get_transient( $key ); if ( ! is_array( $entry ) || empty( $entry['verifier'] ) ) { throw new \RuntimeException( 'OAuth callback: state unknown' ); } // Only the session that minted state may redeem it. Left in place on // mismatch so another admin cannot cancel the owner's pending connect; // an entry without a binding (minted before ORL-246) fails closed. $user_id = get_current_user_id(); $session = self::session_fingerprint(); if ( $user_id <= 0 || $session === '' || (int) ( $entry['user_id'] ?? 0 ) !== $user_id || ! hash_equals( (string) ( $entry['session'] ?? '' ), $session ) ) { throw new \RuntimeException( 'OAuth callback: state was issued to a different session' ); } $created_at = (int) ( $entry['created_at'] ?? 0 ); if ( $created_at > 0 && ( time() - $created_at ) > self::PKCE_TTL ) { delete_transient( $key ); throw new \RuntimeException( 'OAuth callback: state expired' ); } delete_transient( $key ); $token_resp = $this->post_form( $this->auth_base_url . '/token', [ 'grant_type' => 'authorization_code', 'code' => $code, 'client_id' => $this->client_id, 'redirect_uri' => $this->redirect_uri, 'code_verifier' => (string) $entry['verifier'], ] ); $tokens = $this->expect_json( $token_resp, 'token exchange' ); $access = (string) ( $tokens['access_token'] ?? '' ); $refresh = (string) ( $tokens['refresh_token'] ?? '' ); $expires = (int) ( $tokens['expires_in'] ?? 0 ); if ( $access === '' || $refresh === '' ) { throw new \RuntimeException( 'OAuth callback: token response missing access_token or refresh_token' ); } $userinfo = $this->get_userinfo( $access ); $route = $this->register_webhook_route( $access ); $event_secret = (string) ( $route['secret'] ?? '' ); if ( $event_secret === '' ) { throw new \RuntimeException( 'OAuth callback: webhook route response missing secret' ); } $wallet_id = (string) ( $userinfo['wallet_id'] ?? '' ); Prweb_Token_Store::store( [ 'access_token' => $access, 'refresh_token' => $refresh, 'expires_at' => time() + max( 0, $expires - 30 ), 'pds_user_id' => $userinfo['pds_user_id'] ?? null, 'wallet_id' => $wallet_id !== '' ? $wallet_id : null, 'event_secret' => $event_secret, ] ); // Drop any cached wallet→org mapping from a prior connection so the // next payout-policy AJAX re-resolves against the freshly bound wallet. if ( $wallet_id !== '' && class_exists( 'Prweb_Ajax_Payouts' ) ) { Prweb_Ajax_Payouts::invalidate_org_cache_for_wallet( $wallet_id ); } } public function refresh(): void { $rt = Prweb_Token_Store::get_refresh(); if ( $rt === null || $rt === '' ) { throw new Prweb_OAuth_Refresh_Disconnected_Exception( 'OAuth refresh: no refresh_token stored' ); } $resp = $this->post_form( $this->auth_base_url . '/token', [ 'grant_type' => 'refresh_token', 'refresh_token' => $rt, 'client_id' => $this->client_id, ] ); // Distinguish revoked-refresh (4xx) from transient outage (5xx / network). // 4xx → clear stored tokens so is_connected() flips false and the UI can // re-prompt Connect. 5xx / transport → leave tokens intact for the next try. if ( is_wp_error( $resp ) ) { throw new Prweb_OAuth_Refresh_Transient_Exception( esc_html( 'OAuth refresh: HTTP error: ' . $resp->get_error_message() ) ); } $code = (int) wp_remote_retrieve_response_code( $resp ); if ( $code >= 400 && $code < 500 ) { Prweb_Token_Store::clear(); $body = (string) wp_remote_retrieve_body( $resp ); $excerpt = self::scrub_excerpt( $body ); throw new Prweb_OAuth_Refresh_Disconnected_Exception( esc_html( "OAuth refresh: refresh_token rejected (HTTP {$code}): {$excerpt}" ) ); } if ( $code >= 500 ) { $body = (string) wp_remote_retrieve_body( $resp ); $excerpt = self::scrub_excerpt( $body ); throw new Prweb_OAuth_Refresh_Transient_Exception( esc_html( "OAuth refresh: upstream error (HTTP {$code}): {$excerpt}" ) ); } $tokens = $this->expect_json( $resp, 'refresh' ); $access = (string) ( $tokens['access_token'] ?? '' ); $refresh = (string) ( $tokens['refresh_token'] ?? '' ); $expires = (int) ( $tokens['expires_in'] ?? 0 ); if ( $access === '' || $refresh === '' ) { throw new Prweb_OAuth_Refresh_Transient_Exception( 'OAuth refresh: response missing access_token or refresh_token' ); } Prweb_Token_Store::store( [ 'access_token' => $access, 'refresh_token' => $refresh, 'expires_at' => time() + max( 0, $expires - 30 ), 'pds_user_id' => Prweb_Token_Store::get_pds_user_id(), 'wallet_id' => Prweb_Token_Store::get_wallet_id(), 'event_secret' => Prweb_Token_Store::get_event_secret(), ] ); } /** * Best-effort revoke of a single token at the auth service. Returns true * if the auth service answered 2xx, false otherwise. Never throws — caller * is expected to clear local state regardless of remote success. * * @param string $token The opaque token to revoke. * @param string $type_hint "refresh_token" or "access_token". */ public function revoke_token( string $token, string $type_hint ): bool { if ( $token === '' ) { return true; } $resp = $this->post_form( $this->auth_base_url . '/revoke', [ 'token' => $token, 'token_type_hint' => $type_hint, 'client_id' => $this->client_id, ] ); if ( is_wp_error( $resp ) ) { return false; } $code = (int) wp_remote_retrieve_response_code( $resp ); return $code >= 200 && $code < 300; } /** * Revoke both refresh and access tokens at the auth service (best-effort) * and clear the local Token_Store. Returns the per-token revoke outcomes * so callers can audit the results. * * @return array{refresh:bool,access:bool} */ public function revoke_all(): array { $refresh = Prweb_Token_Store::get_refresh(); $access = Prweb_Token_Store::get_access(); $route = $access !== null ? $this->revoke_webhook_route( $access ) : true; $rev_refresh = $refresh !== null ? $this->revoke_token( $refresh, 'refresh_token' ) : true; $rev_access = $access !== null ? $this->revoke_token( $access, 'access_token' ) : true; Prweb_Token_Store::clear(); return [ 'route' => $route, 'refresh' => $rev_refresh, 'access' => $rev_access ]; } public function refresh_if_needed(): void { $expires_at = Prweb_Token_Store::get_expires_at(); if ( $expires_at === null ) { return; } if ( ( $expires_at - time() ) < self::REFRESH_SKEW ) { $this->refresh(); } } public function is_connected(): bool { return Prweb_Token_Store::is_connected(); } // ------------------------------------------------------------------------- // Internals // ------------------------------------------------------------------------- private function transient_key( string $state ): string { return Prweb_Brand::option( 'oauth_pkce_' ) . $state; } /** sha256 of the WP login session token ('' when there is none); the raw token is never stored. */ private static function session_fingerprint(): string { $token = (string) wp_get_session_token(); return $token === '' ? '' : hash( 'sha256', $token ); } private function get_userinfo( string $access_token ): array { $resp = wp_remote_get( $this->auth_base_url . '/userinfo', [ 'timeout' => self::HTTP_TIMEOUT, 'redirection' => 0, 'headers' => [ 'Authorization' => 'Bearer ' . $access_token, 'Accept' => 'application/json', ], ] ); return $this->expect_json( $resp, 'userinfo' ); } private function register_webhook_route( string $access_token ): array { return $this->expect_json( $this->post_json( $this->auth_base_url . '/v1/wp-webhook-route', [ 'redirect_uri' => $this->redirect_uri, 'event_url' => $this->event_url, ], [ 'Authorization' => 'Bearer ' . $access_token ] ), 'webhook route registration' ); } private function revoke_webhook_route( string $access_token ): bool { $resp = $this->post_json( $this->auth_base_url . '/v1/wp-webhook-route/revoke', [ 'redirect_uri' => $this->redirect_uri, 'event_url' => $this->event_url, ], [ 'Authorization' => 'Bearer ' . $access_token ] ); if ( is_wp_error( $resp ) ) { return false; } $code = (int) wp_remote_retrieve_response_code( $resp ); return $code >= 200 && $code < 300; } private static function event_url_from_redirect( string $redirect_uri ): string { $parts = wp_parse_url( $redirect_uri ); if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { throw new \InvalidArgumentException( 'OAuth redirect URI cannot determine WordPress event endpoint' ); } $origin = (string) $parts['scheme'] . '://' . (string) $parts['host']; if ( isset( $parts['port'] ) ) { $origin .= ':' . (int) $parts['port']; } $path = (string) ( $parts['path'] ?? '' ); $admin_pos = strpos( $path, '/wp-admin/' ); $prefix = $admin_pos === false ? '' : substr( $path, 0, $admin_pos ); return rtrim( $origin . $prefix, '/' ) . '/wp-json/pds/v1/events'; } private function post_form( string $url, array $fields ): mixed { return wp_remote_post( $url, [ 'timeout' => self::HTTP_TIMEOUT, 'redirection' => 0, 'headers' => [ 'Content-Type' => 'application/x-www-form-urlencoded', 'Accept' => 'application/json', ], 'body' => http_build_query( $fields ), ] ); } private function post_json( string $url, array $fields, array $headers = [] ): mixed { return wp_remote_post( $url, [ 'timeout' => self::HTTP_TIMEOUT, 'redirection' => 0, 'headers' => array_merge( [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], $headers ), 'body' => wp_json_encode( $fields ), ] ); } private function expect_json( mixed $resp, string $stage ): array { if ( is_wp_error( $resp ) ) { throw new \RuntimeException( esc_html( "OAuth {$stage}: HTTP error: " . $resp->get_error_message() ) ); } $code = (int) wp_remote_retrieve_response_code( $resp ); $body = (string) wp_remote_retrieve_body( $resp ); if ( $code < 200 || $code >= 300 ) { $excerpt = self::scrub_excerpt( $body ); throw new \RuntimeException( esc_html( "OAuth {$stage}: HTTP {$code}: {$excerpt}" ) ); } $decoded = json_decode( $body, true ); if ( ! is_array( $decoded ) ) { throw new \RuntimeException( esc_html( "OAuth {$stage}: response was not JSON" ) ); } return $decoded; } /** * Defence-in-depth: even if the auth service shouldn't echo credentials, * scrub well-known sensitive keys before slicing the error excerpt. * Replaces JSON values for those keys with "[REDACTED]" — best-effort, * regex-based so it works on any 4xx body shape. */ private static function scrub_excerpt( string $body ): string { $sensitive = [ 'code', 'code_verifier', 'refresh_token', 'access_token', 'client_secret', 'prvKey', 'privateKey', 'ownerCredentials', 'sellerCredentials', 'buyerCredentials', 'senderCredentials', 'userCredentials', 'funderCredentials', ]; foreach ( $sensitive as $key ) { // "key":"value" → "key":"[REDACTED]" (handles \" inside value) $pattern = '/("' . preg_quote( $key, '/' ) . '"\s*:\s*)"(?:[^"\\\\]|\\\\.)*"/'; $body = (string) preg_replace( $pattern, '$1"[REDACTED]"', $body ); } return substr( $body, 0, 200 ); } private static function b64url( string $bytes ): string { return rtrim( strtr( base64_encode( $bytes ), '+/', '-_' ), '=' ); } }