'POST', 'callback' => [ $this, 'init_oauth' ], 'permission_callback' => [ $this, 'check_permission' ], ] ); register_rest_route( self::NAMESPACE, '/oauth/callback', [ 'methods' => 'GET', 'callback' => [ $this, 'handle_callback' ], 'permission_callback' => [ $this, 'allow_public_access' ], 'args' => [ 'code' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'state' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], // Not named `error` - that's a reserved WordPress core query var // (see WP::$public_query_vars), and WP::parse_request() unsets // $_GET['error'] as part of its own request lifecycle before the // REST API ever reads it, silently dropping this signal. 'oauth_error' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], ], ] ); } /** * Allow public access to the OAuth callback endpoint. * * This endpoint receives browser redirects from the external OAuth provider. * Request authenticity is verified via a one-time state token stored in a transient. */ public function allow_public_access() { return true; } /** * POST /mintall/v1/oauth/init * * Generate PKCE pair, create OAuth session on backend, return redirect URL. */ public function init_oauth() { // Generate PKCE pair. $code_verifier = wp_generate_password( 64, false, false ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- PKCE challenge, not code. $code_challenge = rtrim( strtr( base64_encode( hash( 'sha256', $code_verifier, true ) ), '+/', '-_' ), '=' ); $callback_url = rest_url( 'mintall/v1/oauth/callback' ); // Collect site metadata. $site_url = home_url(); $parsed = wp_parse_url( $site_url ); $domain = $parsed['host'] ?? 'localhost'; $site_name = get_bloginfo( 'name' ); // Include port for loopback hosts. if ( in_array( $domain, [ 'localhost', '127.0.0.1', '[::1]' ], true ) && ! empty( $parsed['port'] ) ) { $domain .= ':' . $parsed['port']; } $site_metadata = [ 'site_name' => $site_name ?: $domain, 'wc_version' => defined( 'WC_VERSION' ) ? WC_VERSION : '', 'wp_version' => get_bloginfo( 'version' ), 'php_version' => phpversion(), 'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '', 'locale' => get_locale(), 'timezone' => wp_timezone_string(), ]; // Create OAuth session on backend. $result = Mintall_Client::post( '/oauth/sessions', null, [ 'platform_type' => 'woocommerce', 'domain' => $domain, 'primary_domain' => $site_url, 'callback_url' => $callback_url, 'code_challenge' => $code_challenge, 'code_challenge_method' => 'S256', 'site_metadata' => $site_metadata, ] ); if ( is_wp_error( $result ) ) { return $this->error_response( $result->get_error_message(), 500 ); } $session_id = $result['session_id'] ?? ''; if ( ! $session_id ) { return $this->error_response( 'Failed to create OAuth session.', 500 ); } // Generate state for CSRF protection. $state = wp_generate_password( 32, false, false ); // Store PKCE verifier and session data in transient (10-minute TTL). set_transient( 'mintall_oauth_' . $state, [ 'code_verifier' => $code_verifier, 'session_id' => $session_id, 'callback_url' => $callback_url, ], 600 ); // Build redirect URL to Mintall Keep consent screen. $redirect_url = add_query_arg( [ 'session_id' => $session_id, 'redirect_uri' => $callback_url, 'state' => $state, ], rtrim( MINTALL_APP_URL, '/' ) . '/connect' ); return new \WP_REST_Response( [ 'redirect_url' => $redirect_url ] ); } /** * GET /mintall/v1/oauth/callback * * Receive authorization code from Mintall Keep, exchange for API key. */ public function handle_callback( $request ) { $admin_url = admin_url( 'admin.php?page=mintall-keep' ); // Handle user cancellation. $error = $request->get_param( 'oauth_error' ); if ( $error ) { wp_safe_redirect( add_query_arg( 'mintall_oauth', 'cancelled', $admin_url ) ); exit; } $code = $request->get_param( 'code' ); $state = $request->get_param( 'state' ); if ( ! $code || ! $state ) { wp_safe_redirect( add_query_arg( [ 'mintall_oauth' => 'error', 'reason' => 'missing_params', ], $admin_url ) ); exit; } // Verify state against stored transient. $oauth_data = get_transient( 'mintall_oauth_' . $state ); if ( ! $oauth_data ) { wp_safe_redirect( add_query_arg( [ 'mintall_oauth' => 'error', 'reason' => 'invalid_state', ], $admin_url ) ); exit; } // The transient is deleted only after a fully successful // exchange-and-save (below). Keeping it on failure lets the merchant // retry by reloading the callback URL — the backend's /oauth/exchange // is idempotent for an already-exchanged session. The practical retry // window is the backend's 5-minute authorization-code lifetime, which // runs out before this transient's 10-minute TTL. A successful connect // still invalidates the state immediately, so the callback URL left in // browser history cannot re-run a completed connect. $code_verifier = $oauth_data['code_verifier']; $callback_url = $oauth_data['callback_url']; // Exchange authorization code for API key. $result = Mintall_Client::post( '/oauth/exchange', null, [ 'code' => $code, 'redirect_uri' => $callback_url, 'code_verifier' => $code_verifier, ] ); if ( is_wp_error( $result ) ) { wp_safe_redirect( add_query_arg( [ 'mintall_oauth' => 'error', 'reason' => 'exchange_failed', ], $admin_url ) ); exit; } // Store connection locally. A null return means the local write failed // (e.g. a stale table shape) even though the backend exchange // succeeded; surface it as an error rather than a false "success". $connection = Connection_Manager::save_remote_connection( $result ); if ( ! $connection ) { wp_safe_redirect( add_query_arg( [ 'mintall_oauth' => 'error', 'reason' => 'save_failed', ], $admin_url ) ); exit; } delete_transient( 'mintall_oauth_' . $state ); wp_safe_redirect( add_query_arg( 'mintall_oauth', 'success', $admin_url ) ); exit; } }