user_login ) && (string) $user->user_login !== '' ) { return (string) $user->user_login; } } return 'system'; } /** * Process the OAuth callback before wp-admin renders admin-header.php. * * WordPress invokes page callbacks after the admin chrome has already * started output, so a callback renderer cannot safely call * wp_safe_redirect(). Handling the callback at admin_init keeps the * OAuth return path header-clean and avoids the hidden callback page * being rendered with an unset admin title. * * No WordPress nonce is checked here, deliberately (ORL-246, WP.org * review round 2). This request is a browser redirect FROM the * authorization server, which cannot know or echo a WP nonce. CSRF on an * OAuth redirect is defended per RFC 6749 §10.12 + RFC 7636, all of which * this path enforces: * - The flow can only be started by an admin through a nonce-checked * AJAX call (Prweb_Ajax_Account::ajax_oauth_start(): check_ajax_referer() * + manage_options), which mints `state`. * - `state` is 256 bits of random_bytes(), stored in a server-side * transient (Prweb_Oauth_Client::start_flow()). handle_callback() * requires that transient to exist, enforces PKCE_TTL (300s) and * deletes it: unguessable, single-use, time-boxed. * - The transient records the minting user ID and a sha256 of their * login session token, and handle_callback() refuses any other user * or session. This is the user+session binding a WP nonce carries, * and what RFC 6749 §10.12 asks of `state`. * - PKCE (S256) binds the code exchange to the verifier held in that * transient, so an injected or replayed `code` cannot be redeemed. * - current_user_can() runs before error/code/state are read, and each * is unslashed + sanitized at the point of read. * Do not add a nonce to satisfy a scanner: the authorization server would * not return it. If a reviewer still requires one, carry it inside * `state` and verify it with wp_verify_nonce() in addition to the above. */ public function maybe_handle_oauth_callback(): void { if ( ! isset( $_GET['page'] ) ) { return; } $page = sanitize_key( wp_unslash( (string) $_GET['page'] ) ); if ( self::SLUG_OAUTH_CB !== $page ) { return; } if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'propertyrightsweb' ) ); } $error = isset( $_GET['error'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['error'] ) ) : ''; $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['code'] ) ) : ''; $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['state'] ) ) : ''; if ( $error !== '' || $code === '' || $state === '' ) { $detail = $error !== '' ? $error : __( 'missing code or state in callback URL', 'propertyrightsweb' ); $this->redirect_after_oauth_failure( $detail ); } try { Prweb_Plugin::oauth()->handle_callback( $code, $state ); $this->events->audit( 'oauth.consent_granted', [ 'subject_user' => $this->canonical_subject_user(), 'client_id' => PRWEB_OAUTH_CLIENT_ID, 'scope' => 'openid profile email assets:write offers:write wallet:read', ] ); } catch ( \Throwable $e ) { $this->redirect_after_oauth_failure( $e->getMessage() ); } // If the first-run wizard is in progress, record the consent // boundary and advance it to wallet_ready. This is the OAuth // round-trip seam: the consent UI itself lives in the auth service, // but the publisher funnel remains a four-milestone plugin flow. // The plugin emits the funnel events on the publisher's // behalf because the auth service does not write to wp_prweb_events; // the JS state machine on the next page load knows the publisher is // already at wallet_ready and renders that step's loading state. if ( Prweb_Ajax_Onboarding::wizard_in_progress() ) { $onboarding = Prweb_Plugin::ajax_onboarding(); if ( $onboarding !== null ) { $onboarding->record_step_completed_from_php( 'connect', Prweb_Wizard_Stages::OUTCOME_ADVANCED ); $onboarding->record_step_started_from_php( 'wallet_ready' ); } Prweb_Ajax_Onboarding::set_stage( 'wallet_ready' ); wp_safe_redirect( admin_url( 'admin.php?page=' . self::SLUG_DASHBOARD . '&prweb_connected=1' ) ); exit; } wp_safe_redirect( admin_url( 'admin.php?page=' . self::SLUG_SETTINGS . '&prweb_connected=1' ) ); exit; } private function redirect_after_oauth_failure( string $detail ): void { $detail = sanitize_text_field( $detail ); if ( Prweb_Ajax_Onboarding::wizard_in_progress() ) { Prweb_Ajax_Onboarding::set_stage( Prweb_Wizard_Stages::entry_step() ); } wp_safe_redirect( add_query_arg( [ 'page' => self::SLUG_SETTINGS, 'section' => 'identity', 'prweb_oauth_error' => $detail, ], admin_url( 'admin.php' ) ) ); exit; } /** * Fallback renderer — only reached if maybe_handle_oauth_callback() didn't * fire (e.g. the request hit page render directly). Tells the publisher to * retry from Settings rather than rendering a half-broken page. */ public function render_oauth_callback(): void { $back = esc_url( admin_url( 'admin.php?page=' . self::SLUG_SETTINGS ) ); $heading = sprintf( /* translators: %s is the brand name (Webapp). */ esc_html__( '%s · OAuth callback', 'propertyrightsweb' ), esc_html( Prweb_Brand::NAME ) ); echo '

' . esc_html( $heading ) . '

'; printf( '

%s

', esc_html__( 'OAuth callback processing did not run before the admin page rendered. Return to Settings and try connecting again.', 'propertyrightsweb' ) ); printf( '

%s

', esc_url( $back ), esc_html__( 'Back to Settings', 'propertyrightsweb' ) ); echo '
'; } }