From e3ad9a6356f338c122b717af370032c5b980f4c9 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 22 Mar 2026 22:22:30 +0000 Subject: [PATCH 1/2] Add browser-based agent authorization flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth-style consent flow for agents to obtain bearer tokens: 1. Agent opens authorize URL with agent_slug + redirect_uri 2. User logs in (if needed) and sees consent screen 3. User clicks Authorize → token minted → redirected to callback with token 4. User clicks Deny → redirected with error Security: WordPress nonce CSRF protection, redirect_uri validated against localhost/same-site/registered domains (filterable via datamachine_authorize_allowed_domains). Enables self-service token acquisition for local and remote agents without manual token copying. --- data-machine.php | 3 + inc/Core/Auth/AgentAuthorize.php | 509 +++++++++++++++++++++++++++++++ 2 files changed, 512 insertions(+) create mode 100644 inc/Core/Auth/AgentAuthorize.php diff --git a/data-machine.php b/data-machine.php index 765bee5b6..749313711 100644 --- a/data-machine.php +++ b/data-machine.php @@ -117,6 +117,9 @@ function () { // Agent runtime authentication middleware. new \DataMachine\Core\Auth\AgentAuthMiddleware(); + // Agent browser-based authorization flow. + new \DataMachine\Core\Auth\AgentAuthorize(); + // Load abilities require_once __DIR__ . '/inc/Abilities/AuthAbilities.php'; require_once __DIR__ . '/inc/Abilities/File/FileConstants.php'; diff --git a/inc/Core/Auth/AgentAuthorize.php b/inc/Core/Auth/AgentAuthorize.php new file mode 100644 index 000000000..3099786a8 --- /dev/null +++ b/inc/Core/Auth/AgentAuthorize.php @@ -0,0 +1,509 @@ + \WP_REST_Server::READABLE, + 'callback' => array( $this, 'handle_authorize_get' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'agent_slug' => array( + 'required' => true, + 'type' => 'string', + ), + 'redirect_uri' => array( + 'required' => true, + 'type' => 'string', + ), + 'label' => array( + 'required' => false, + 'type' => 'string', + 'default' => '', + ), + ), + ) + ); + + // POST: Handle authorize/deny submission. + register_rest_route( + 'datamachine/v1', + '/agent/authorize', + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'handle_authorize_post' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'agent_slug' => array( + 'required' => true, + 'type' => 'string', + ), + 'redirect_uri' => array( + 'required' => true, + 'type' => 'string', + ), + 'label' => array( + 'required' => false, + 'type' => 'string', + 'default' => '', + ), + 'action' => array( + 'required' => true, + 'type' => 'string', + 'enum' => array( 'authorize', 'deny' ), + ), + '_wpnonce' => array( + 'required' => true, + 'type' => 'string', + ), + ), + ) + ); + } + + /** + * Handle GET request — show consent screen or redirect to login. + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response|void Response or redirect. + */ + public function handle_authorize_get( \WP_REST_Request $request ) { + $agent_slug = sanitize_text_field( $request->get_param( 'agent_slug' ) ); + $redirect_uri = esc_url_raw( $request->get_param( 'redirect_uri' ) ); + $label = sanitize_text_field( $request->get_param( 'label' ) ); + + // Validate redirect_uri. + $uri_error = $this->validate_redirect_uri( $redirect_uri ); + if ( $uri_error ) { + return $uri_error; + } + + // If not logged in, redirect to wp-login with return URL. + if ( ! is_user_logged_in() ) { + $authorize_url = rest_url( 'datamachine/v1/agent/authorize' ); + $authorize_url = add_query_arg( + array( + 'agent_slug' => $agent_slug, + 'redirect_uri' => rawurlencode( $redirect_uri ), + 'label' => $label, + ), + $authorize_url + ); + + $login_url = wp_login_url( $authorize_url ); + + header( 'Location: ' . $login_url ); + exit; + } + + // Look up the agent. + $agents_repo = new Agents(); + $agent = $agents_repo->get_by_slug( $agent_slug ); + + if ( ! $agent ) { + return new \WP_Error( + 'agent_not_found', + sprintf( 'Agent "%s" not found.', $agent_slug ), + array( 'status' => 404 ) + ); + } + + // Check user has access to this agent. + $user_id = get_current_user_id(); + + if ( ! $this->user_can_authorize( $user_id, $agent ) ) { + return new \WP_Error( + 'access_denied', + 'You do not have access to this agent.', + array( 'status' => 403 ) + ); + } + + // Render consent screen. + $this->render_consent_screen( $agent, $redirect_uri, $label ); + exit; + } + + /** + * Handle POST request — process authorize or deny. + * + * @param \WP_REST_Request $request Request object. + * @return void Redirects to redirect_uri. + */ + public function handle_authorize_post( \WP_REST_Request $request ) { + $agent_slug = sanitize_text_field( $request->get_param( 'agent_slug' ) ); + $redirect_uri = esc_url_raw( $request->get_param( 'redirect_uri' ) ); + $label = sanitize_text_field( $request->get_param( 'label' ) ); + $action = sanitize_text_field( $request->get_param( 'action' ) ); + $nonce = $request->get_param( '_wpnonce' ); + + // Validate redirect_uri. + $uri_error = $this->validate_redirect_uri( $redirect_uri ); + if ( $uri_error ) { + return $uri_error; + } + + // Must be logged in. + if ( ! is_user_logged_in() ) { + header( 'Location: ' . add_query_arg( 'error', 'not_authenticated', $redirect_uri ) ); + exit; + } + + // Verify nonce. + if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) { + header( 'Location: ' . add_query_arg( 'error', 'invalid_nonce', $redirect_uri ) ); + exit; + } + + // Handle deny. + if ( 'deny' === $action ) { + header( 'Location: ' . add_query_arg( 'error', 'access_denied', $redirect_uri ) ); + exit; + } + + // Look up agent. + $agents_repo = new Agents(); + $agent = $agents_repo->get_by_slug( $agent_slug ); + + if ( ! $agent ) { + header( 'Location: ' . add_query_arg( 'error', 'agent_not_found', $redirect_uri ) ); + exit; + } + + // Verify access. + $user_id = get_current_user_id(); + if ( ! $this->user_can_authorize( $user_id, $agent ) ) { + header( 'Location: ' . add_query_arg( 'error', 'access_denied', $redirect_uri ) ); + exit; + } + + // Mint token. + $tokens_repo = new AgentTokens(); + $token_label = ! empty( $label ) ? $label : 'authorize-flow-' . gmdate( 'Y-m-d' ); + + $result = $tokens_repo->create_token( + (int) $agent['agent_id'], + $agent['agent_slug'], + $token_label, + null, // All capabilities. + null // No expiry. + ); + + if ( ! $result ) { + header( 'Location: ' . add_query_arg( 'error', 'token_creation_failed', $redirect_uri ) ); + exit; + } + + do_action( + 'datamachine_log', + 'info', + 'Agent token issued via authorize flow', + array( + 'agent_id' => (int) $agent['agent_id'], + 'agent_slug' => $agent['agent_slug'], + 'user_id' => $user_id, + 'token_id' => $result['token_id'], + 'label' => $token_label, + ) + ); + + // Redirect with token. + $callback_url = add_query_arg( + array( + 'token' => $result['raw_token'], + 'agent_slug' => $agent['agent_slug'], + 'agent_id' => (int) $agent['agent_id'], + ), + $redirect_uri + ); + + header( 'Location: ' . $callback_url ); + exit; + } + + /** + * Check if a user can authorize token creation for an agent. + * + * User must be the owner OR have admin-level access grant. + * + * @param int $user_id User ID. + * @param array $agent Agent row. + * @return bool + */ + private function user_can_authorize( int $user_id, array $agent ): bool { + // Owner can always authorize. + if ( (int) $agent['owner_id'] === $user_id ) { + return true; + } + + // Check access grants. + $access_repo = new AgentAccess(); + return $access_repo->user_can_access( (int) $agent['agent_id'], $user_id, 'admin' ); + } + + /** + * Validate redirect_uri is safe. + * + * Allows: localhost (any port), 127.0.0.1, and same-site URLs. + * + * @param string $uri Redirect URI. + * @return \WP_Error|null Error or null if valid. + */ + private function validate_redirect_uri( string $uri ): ?\WP_Error { + if ( empty( $uri ) ) { + return new \WP_Error( + 'missing_redirect_uri', + 'redirect_uri is required.', + array( 'status' => 400 ) + ); + } + + $parsed = wp_parse_url( $uri ); + $host = $parsed['host'] ?? ''; + + // Allow localhost. + if ( in_array( $host, array( 'localhost', '127.0.0.1', '::1' ), true ) ) { + return null; + } + + // Allow same network (*.extrachill.com or the network domain). + $site_host = wp_parse_url( network_home_url(), PHP_URL_HOST ); + if ( $host === $site_host || str_ends_with( $host, '.' . $site_host ) ) { + return null; + } + + // Allow registered external domains (filterable for third-party agents). + $allowed_domains = apply_filters( 'datamachine_authorize_allowed_domains', array() ); + foreach ( $allowed_domains as $domain ) { + if ( $host === $domain || str_ends_with( $host, '.' . $domain ) ) { + return null; + } + } + + return new \WP_Error( + 'invalid_redirect_uri', + sprintf( 'redirect_uri host "%s" is not allowed. Use localhost or a same-site URL, or register the domain via datamachine_authorize_allowed_domains filter.', $host ), + array( 'status' => 400 ) + ); + } + + /** + * Render the consent screen HTML. + * + * Minimal, self-contained page — no admin chrome needed. + * + * @param array $agent Agent row. + * @param string $redirect_uri Callback URI. + * @param string $label Optional token label. + */ + private function render_consent_screen( array $agent, string $redirect_uri, string $label ): void { + $nonce = wp_create_nonce( self::NONCE_ACTION ); + $user = wp_get_current_user(); + $action_url = rest_url( 'datamachine/v1/agent/authorize' ); + + $site_name = get_bloginfo( 'name' ); + + $agent_name = esc_html( $agent['agent_name'] ); + $agent_slug = esc_html( $agent['agent_slug'] ); + $owner = get_userdata( (int) $agent['owner_id'] ); + $owner_name = $owner ? esc_html( $owner->display_name ) : 'Unknown'; + $user_name = esc_html( $user->display_name ); + + $parsed_uri = wp_parse_url( $redirect_uri ); + $uri_display = esc_html( ( $parsed_uri['host'] ?? '' ) . ( isset( $parsed_uri['port'] ) ? ':' . $parsed_uri['port'] : '' ) ); + + header( 'Content-Type: text/html; charset=utf-8' ); + + echo ' + + + + + + Authorize Agent — ' . esc_html( $site_name ) . ' + + + +
+
+

Authorize Agent

+
' . esc_html( $site_name ) . '
+
+ +
+
Agent
+
' . $agent_name . ' (' . $agent_slug . ')
+
Owner
+
' . $owner_name . '
+
Redirect
+
' . $uri_display . '
+
+ +
+ This will create a bearer token granting ' . $agent_name . ' API access to this site with your permissions. The token does not expire. +
+ +
+ + + + + +
+ + +
+
+ +
Signed in as ' . $user_name . '
+
+ +'; + } +} From 47a4de51d5cc27e5a8bfb4f916ac17de56ac7751 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 22 Mar 2026 22:34:05 +0000 Subject: [PATCH 2/2] Add per-agent redirect URI validation and auth callback handler - Redirect URIs validated against agent_config.allowed_redirect_uris instead of a global allowlist. Scopes blast radius per-agent. - Localhost and same-site always allowed (dev + network). - URI patterns support: exact match, wildcard path, domain-only. - AgentAuthCallback receives tokens from external DM authorize flows and stores them in datamachine_external_tokens option. - REST endpoints for listing/retrieving stored external tokens. --- data-machine.php | 3 + inc/Core/Auth/AgentAuthCallback.php | 387 ++++++++++++++++++++++++++++ inc/Core/Auth/AgentAuthorize.php | 109 +++++--- 3 files changed, 469 insertions(+), 30 deletions(-) create mode 100644 inc/Core/Auth/AgentAuthCallback.php diff --git a/data-machine.php b/data-machine.php index 749313711..88d88a82c 100644 --- a/data-machine.php +++ b/data-machine.php @@ -120,6 +120,9 @@ function () { // Agent browser-based authorization flow. new \DataMachine\Core\Auth\AgentAuthorize(); + // Agent auth callback handler (receives tokens from external DM instances). + new \DataMachine\Core\Auth\AgentAuthCallback(); + // Load abilities require_once __DIR__ . '/inc/Abilities/AuthAbilities.php'; require_once __DIR__ . '/inc/Abilities/File/FileConstants.php'; diff --git a/inc/Core/Auth/AgentAuthCallback.php b/inc/Core/Auth/AgentAuthCallback.php new file mode 100644 index 000000000..68edbe912 --- /dev/null +++ b/inc/Core/Auth/AgentAuthCallback.php @@ -0,0 +1,387 @@ + \WP_REST_Server::READABLE, + 'callback' => array( $this, 'handle_callback' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'token' => array( + 'required' => false, + 'type' => 'string', + ), + 'agent_slug' => array( + 'required' => false, + 'type' => 'string', + ), + 'agent_id' => array( + 'required' => false, + 'type' => 'integer', + ), + 'error' => array( + 'required' => false, + 'type' => 'string', + ), + ), + ) + ); + + // Retrieve stored token for a remote site + agent (authenticated). + register_rest_route( + 'datamachine/v1', + '/agent/auth/tokens', + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'list_external_tokens' ), + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ) + ); + + // Get a specific external token by key (authenticated). + register_rest_route( + 'datamachine/v1', + '/agent/auth/tokens/(?P[a-zA-Z0-9._\-/]+)', + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_external_token' ), + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => array( + 'key' => array( + 'required' => true, + 'type' => 'string', + ), + ), + ) + ); + } + + /** + * Handle the OAuth callback — store token or show error. + * + * @param \WP_REST_Request $request Request object. + * @return void Renders HTML result page. + */ + public function handle_callback( \WP_REST_Request $request ) { + $error = sanitize_text_field( $request->get_param( 'error' ) ); + + if ( ! empty( $error ) ) { + $this->render_result_page( false, $this->get_error_message( $error ) ); + exit; + } + + $token = sanitize_text_field( $request->get_param( 'token' ) ); + $agent_slug = sanitize_text_field( $request->get_param( 'agent_slug' ) ); + $agent_id = (int) $request->get_param( 'agent_id' ); + + if ( empty( $token ) ) { + $this->render_result_page( false, 'No token received in callback.' ); + exit; + } + + if ( empty( $agent_slug ) ) { + $this->render_result_page( false, 'No agent_slug received in callback.' ); + exit; + } + + // Detect the remote site from the Referer header or token prefix. + $remote_site = $this->detect_remote_site( $request ); + + // Store the token. + $storage_key = $this->store_token( $remote_site, $agent_slug, $token, $agent_id ); + + do_action( + 'datamachine_log', + 'info', + 'External agent token received via callback', + array( + 'remote_site' => $remote_site, + 'agent_slug' => $agent_slug, + 'agent_id' => $agent_id, + 'storage_key' => $storage_key, + ) + ); + + $this->render_result_page( + true, + sprintf( + 'Token received for agent %s from %s. Stored as %s.', + esc_html( $agent_slug ), + esc_html( $remote_site ), + esc_html( $storage_key ) + ) + ); + exit; + } + + /** + * List all stored external tokens (metadata only — never expose raw tokens via REST). + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response + */ + public function list_external_tokens( \WP_REST_Request $request ): \WP_REST_Response { + $tokens = get_option( self::OPTION_KEY, array() ); + $result = array(); + + foreach ( $tokens as $key => $data ) { + $result[] = array( + 'key' => $key, + 'remote_site' => $data['remote_site'] ?? '', + 'agent_slug' => $data['agent_slug'] ?? '', + 'agent_id' => $data['agent_id'] ?? 0, + 'received_at' => $data['received_at'] ?? '', + 'has_token' => ! empty( $data['token'] ), + ); + } + + return rest_ensure_response( $result ); + } + + /** + * Get a specific external token by storage key. + * + * Returns the actual token value — admin-only. + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response + */ + public function get_external_token( \WP_REST_Request $request ): \WP_REST_Response { + $key = sanitize_text_field( $request->get_param( 'key' ) ); + $tokens = get_option( self::OPTION_KEY, array() ); + + if ( ! isset( $tokens[ $key ] ) ) { + return new \WP_REST_Response( + array( 'error' => 'Token not found for key: ' . $key ), + 404 + ); + } + + return rest_ensure_response( $tokens[ $key ] ); + } + + /** + * Store an external token. + * + * @param string $remote_site Remote site domain. + * @param string $agent_slug Agent slug on the remote site. + * @param string $token Raw bearer token. + * @param int $agent_id Agent ID on the remote site. + * @return string Storage key. + */ + private function store_token( string $remote_site, string $agent_slug, string $token, int $agent_id ): string { + $key = $remote_site . '/' . $agent_slug; + $tokens = get_option( self::OPTION_KEY, array() ); + + $tokens[ $key ] = array( + 'remote_site' => $remote_site, + 'agent_slug' => $agent_slug, + 'agent_id' => $agent_id, + 'token' => $token, + 'received_at' => gmdate( 'Y-m-d H:i:s' ), + ); + + update_option( self::OPTION_KEY, $tokens, false ); + + return $key; + } + + /** + * Detect the remote site from the request context. + * + * @param \WP_REST_Request $request Request object. + * @return string Remote site domain. + */ + private function detect_remote_site( \WP_REST_Request $request ): string { + // Try Referer header first. + $referer = $request->get_header( 'referer' ); + if ( ! empty( $referer ) ) { + $parsed = wp_parse_url( $referer ); + if ( ! empty( $parsed['host'] ) ) { + return $parsed['host']; + } + } + + // Fall back to Origin header. + $origin = $request->get_header( 'origin' ); + if ( ! empty( $origin ) ) { + $parsed = wp_parse_url( $origin ); + if ( ! empty( $parsed['host'] ) ) { + return $parsed['host']; + } + } + + return 'unknown'; + } + + /** + * Get human-readable error message. + * + * @param string $error Error code. + * @return string + */ + private function get_error_message( string $error ): string { + $messages = array( + 'access_denied' => 'Authorization was denied by the user.', + 'not_authenticated' => 'User was not logged in on the remote site.', + 'agent_not_found' => 'The requested agent was not found on the remote site.', + 'token_creation_failed' => 'The remote site failed to create a token.', + 'invalid_nonce' => 'Security validation failed. Please try again.', + ); + + return $messages[ $error ] ?? sprintf( 'Authorization failed: %s', esc_html( $error ) ); + } + + /** + * Render a simple result page. + * + * @param bool $success Whether the operation succeeded. + * @param string $message HTML message to display. + */ + private function render_result_page( bool $success, string $message ): void { + $site_name = get_bloginfo( 'name' ); + $icon = $success ? '✓' : '✗'; + $color = $success ? '#00a32a' : '#d63638'; + $title = $success ? 'Authorization Complete' : 'Authorization Failed'; + $bg_color = $success ? '#edfaef' : '#fcf0f1'; + + header( 'Content-Type: text/html; charset=utf-8' ); + + echo ' + + + + + + ' . esc_html( $title ) . ' — ' . esc_html( $site_name ) . ' + + + +
+
' . $icon . '
+

' . esc_html( $title ) . '

+

' . $message . '

+

You can close this window.

+
+ +'; + } + + /** + * Get a stored external token programmatically. + * + * @param string $remote_site Remote site domain (e.g., "extrachill.com"). + * @param string $agent_slug Agent slug on the remote site. + * @return string|null Raw token or null if not stored. + */ + public static function get_token( string $remote_site, string $agent_slug ): ?string { + $key = $remote_site . '/' . $agent_slug; + $tokens = get_option( self::OPTION_KEY, array() ); + + if ( isset( $tokens[ $key ] ) && ! empty( $tokens[ $key ]['token'] ) ) { + return $tokens[ $key ]['token']; + } + + return null; + } +} diff --git a/inc/Core/Auth/AgentAuthorize.php b/inc/Core/Auth/AgentAuthorize.php index 3099786a8..53bf34eb0 100644 --- a/inc/Core/Auth/AgentAuthorize.php +++ b/inc/Core/Auth/AgentAuthorize.php @@ -124,8 +124,20 @@ public function handle_authorize_get( \WP_REST_Request $request ) { $redirect_uri = esc_url_raw( $request->get_param( 'redirect_uri' ) ); $label = sanitize_text_field( $request->get_param( 'label' ) ); - // Validate redirect_uri. - $uri_error = $this->validate_redirect_uri( $redirect_uri ); + // Look up the agent first — we need it for redirect URI validation. + $agents_repo = new Agents(); + $agent = $agents_repo->get_by_slug( $agent_slug ); + + if ( ! $agent ) { + return new \WP_Error( + 'agent_not_found', + sprintf( 'Agent "%s" not found.', $agent_slug ), + array( 'status' => 404 ) + ); + } + + // Validate redirect_uri against agent's allowed URIs. + $uri_error = $this->validate_redirect_uri( $redirect_uri, $agent ); if ( $uri_error ) { return $uri_error; } @@ -148,18 +160,6 @@ public function handle_authorize_get( \WP_REST_Request $request ) { exit; } - // Look up the agent. - $agents_repo = new Agents(); - $agent = $agents_repo->get_by_slug( $agent_slug ); - - if ( ! $agent ) { - return new \WP_Error( - 'agent_not_found', - sprintf( 'Agent "%s" not found.', $agent_slug ), - array( 'status' => 404 ) - ); - } - // Check user has access to this agent. $user_id = get_current_user_id(); @@ -189,10 +189,15 @@ public function handle_authorize_post( \WP_REST_Request $request ) { $action = sanitize_text_field( $request->get_param( 'action' ) ); $nonce = $request->get_param( '_wpnonce' ); - // Validate redirect_uri. - $uri_error = $this->validate_redirect_uri( $redirect_uri ); - if ( $uri_error ) { - return $uri_error; + // Look up agent for redirect URI validation. + $agents_repo = new Agents(); + $agent_for_uri = $agents_repo->get_by_slug( $agent_slug ); + + if ( $agent_for_uri ) { + $uri_error = $this->validate_redirect_uri( $redirect_uri, $agent_for_uri ); + if ( $uri_error ) { + return $uri_error; + } } // Must be logged in. @@ -294,14 +299,20 @@ private function user_can_authorize( int $user_id, array $agent ): bool { } /** - * Validate redirect_uri is safe. + * Validate redirect_uri against the agent's allowed URIs. + * + * Always allows: localhost (any port), 127.0.0.1, same-site URLs. + * External domains must be registered in the agent's config: + * agent_config.allowed_redirect_uris = ["https://saraichinwag.com/*"] * - * Allows: localhost (any port), 127.0.0.1, and same-site URLs. + * This scopes the blast radius per-agent — a compromised agent can only + * redirect to its own registered domains, not arbitrary URLs. * - * @param string $uri Redirect URI. + * @param string $uri Redirect URI. + * @param array|null $agent Agent row (with decoded agent_config). * @return \WP_Error|null Error or null if valid. */ - private function validate_redirect_uri( string $uri ): ?\WP_Error { + private function validate_redirect_uri( string $uri, ?array $agent = null ): ?\WP_Error { if ( empty( $uri ) ) { return new \WP_Error( 'missing_redirect_uri', @@ -313,32 +324,70 @@ private function validate_redirect_uri( string $uri ): ?\WP_Error { $parsed = wp_parse_url( $uri ); $host = $parsed['host'] ?? ''; - // Allow localhost. + // Always allow localhost (local agent development). if ( in_array( $host, array( 'localhost', '127.0.0.1', '::1' ), true ) ) { return null; } - // Allow same network (*.extrachill.com or the network domain). + // Always allow same network (*.extrachill.com or the network domain). $site_host = wp_parse_url( network_home_url(), PHP_URL_HOST ); if ( $host === $site_host || str_ends_with( $host, '.' . $site_host ) ) { return null; } - // Allow registered external domains (filterable for third-party agents). - $allowed_domains = apply_filters( 'datamachine_authorize_allowed_domains', array() ); - foreach ( $allowed_domains as $domain ) { - if ( $host === $domain || str_ends_with( $host, '.' . $domain ) ) { - return null; + // Check agent's allowed_redirect_uris in agent_config. + if ( $agent ) { + $config = $agent['agent_config'] ?? array(); + $allowed_uris = $config['allowed_redirect_uris'] ?? array(); + + foreach ( $allowed_uris as $pattern ) { + if ( $this->uri_matches_pattern( $uri, $pattern ) ) { + return null; + } } } return new \WP_Error( 'invalid_redirect_uri', - sprintf( 'redirect_uri host "%s" is not allowed. Use localhost or a same-site URL, or register the domain via datamachine_authorize_allowed_domains filter.', $host ), + sprintf( + 'redirect_uri host "%s" is not allowed for agent "%s". Register it in the agent\'s allowed_redirect_uris config.', + $host, + $agent['agent_slug'] ?? 'unknown' + ), array( 'status' => 400 ) ); } + /** + * Check if a URI matches an allowed pattern. + * + * Supports: + * - Exact match: "https://saraichinwag.com/callback" + * - Wildcard path: "https://saraichinwag.com/*" + * - Domain-only: "saraichinwag.com" (matches any path on that domain) + * + * @param string $uri The redirect URI to check. + * @param string $pattern The allowed pattern. + * @return bool + */ + private function uri_matches_pattern( string $uri, string $pattern ): bool { + // Domain-only pattern (no scheme). + if ( ! str_contains( $pattern, '://' ) ) { + $parsed = wp_parse_url( $uri ); + $host = $parsed['host'] ?? ''; + return $host === $pattern || str_ends_with( $host, '.' . $pattern ); + } + + // Wildcard path pattern. + if ( str_ends_with( $pattern, '/*' ) ) { + $base = rtrim( substr( $pattern, 0, -2 ), '/' ); + return str_starts_with( rtrim( $uri, '/' ), $base ); + } + + // Exact match. + return rtrim( $uri, '/' ) === rtrim( $pattern, '/' ); + } + /** * Render the consent screen HTML. *