, keys:array}|WP_Error */ public static function resolve( string $client_id ) { if ( ! AgentAdapt_Fetcher::is_https_url( $client_id ) ) { return new WP_Error( 'invalid_client', 'client_id must be an https URL' ); } $row = self::row( $client_id ); if ( ! self::accepted( self::hash( $client_id ), $row ? (string) $row['status'] : 'ok' ) ) { return new WP_Error( 'invalid_client', 'this agent is not accepted' ); } $now = time(); $meta = $row ? json_decode( (string) $row['metadata'], true ) : null; if ( is_array( $meta ) && self::is_public( $meta ) && ! self::public_clients_allowed() ) { return new WP_Error( 'invalid_client', 'this store does not accept public clients' ); } if ( $row && (int) $row['expires_at'] > $now && is_array( $meta ) && ( ! empty( $row['jwks'] ) || self::is_public( $meta ) ) ) { return self::shape( $row ); } if ( ! $row ) { $ip = AgentAdapt_Http::client_ip(); if ( ! AgentAdapt_Rate_Limit::hit( 'new-agent-ip:' . $ip, self::NEW_PER_IP_PER_HOUR, HOUR_IN_SECONDS )['allowed'] || ! AgentAdapt_Rate_Limit::hit( 'new-agent', self::NEW_PER_HOUR, HOUR_IN_SECONDS )['allowed'] ) { return new WP_Error( 'rate_limited', 'too many new agents' ); } } return self::refresh( $client_id, $row ); } /** * Keys for a kid, refetching the JWKS once if the kid is new to us. * * @param array $agent Resolved agent. * @param string|null $kid kid from the JWS header. * @return array */ public static function keys_for( array $agent, ?string $kid ): array { if ( null === $kid || isset( $agent['keys'][ $kid ] ) ) { return $agent['keys']; } $row = self::row( $agent['client_id'] ); if ( $row && ( time() - (int) $row['jwks_fetched_at'] ) >= self::KID_REFETCH_COOLDOWN ) { $fresh = self::refresh( $agent['client_id'], $row ); if ( ! is_wp_error( $fresh ) ) { return $fresh['keys']; } } return $agent['keys']; } /** * Fetch + validate metadata and JWKS, then store. * * @param string $client_id Client ID URL. * @param array|null $row Existing row. * @return array|WP_Error */ private static function refresh( string $client_id, ?array $row ) { $meta = AgentAdapt_Fetcher::get_json( $client_id ); if ( is_wp_error( $meta ) ) { return new WP_Error( 'invalid_client', 'client metadata could not be fetched' ); } $err = self::validate_metadata( $client_id, $meta['json'] ); if ( is_wp_error( $err ) ) { return $err; } if ( self::is_public( $meta['json'] ) ) { // A public client has no keys to fetch: PKCE and the resource // binding carry its security (validate_public()). $jwks = array( 'json' => null, 'ttl' => $meta['ttl'] ); } else { $jwks = AgentAdapt_Fetcher::get_json( (string) $meta['json']['jwks_uri'] ); if ( is_wp_error( $jwks ) ) { return new WP_Error( 'invalid_client', 'jwks_uri could not be fetched' ); } if ( ! AgentAdapt_Jose::keys_from_jwks( $jwks['json'] ) ) { return new WP_Error( 'invalid_client', 'jwks_uri holds no usable public signing key' ); } } global $wpdb; $now = time(); $data = array( 'client_hash' => self::hash( $client_id ), 'client_id' => $client_id, 'name' => mb_substr( sanitize_text_field( (string) ( $meta['json']['client_name'] ?? '' ) ), 0, 191 ), 'logo_uri' => AgentAdapt_Fetcher::is_https_url( (string) ( $meta['json']['logo_uri'] ?? '' ) ) ? (string) $meta['json']['logo_uri'] : '', 'metadata' => wp_json_encode( $meta['json'] ), 'jwks' => null === $jwks['json'] ? null : wp_json_encode( $jwks['json'] ), 'status' => $row['status'] ?? 'ok', 'fetched_at' => $now, 'jwks_fetched_at' => $now, 'expires_at' => $now + min( $meta['ttl'], $jwks['ttl'] ), 'first_seen' => (int) ( $row['first_seen'] ?? $now ), 'last_seen' => $now, ); $wpdb->replace( AgentAdapt_Schema::table( 'agents' ), $data ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return self::shape( $data ); } /** * Spec 4.1 rules for a client metadata document. * * @param string $client_id URL it was fetched from. * @param array $m Document. * @return true|WP_Error */ public static function validate_metadata( string $client_id, array $m ) { if ( ( $m['client_id'] ?? null ) !== $client_id ) { return new WP_Error( 'invalid_client', 'client_id in the metadata does not equal the URL it was fetched from' ); } if ( self::is_public( $m ) ) { return self::validate_public( $client_id, $m ); } if ( 'private_key_jwt' !== ( $m['token_endpoint_auth_method'] ?? '' ) ) { return new WP_Error( 'invalid_client', 'token_endpoint_auth_method must be private_key_jwt' ); } $host = strtolower( (string) wp_parse_url( $client_id, PHP_URL_HOST ) ); if ( ! self::same_domain( (string) ( $m['jwks_uri'] ?? '' ), $host ) ) { return new WP_Error( 'invalid_client', 'jwks_uri must be https on the client_id domain' ); } foreach ( (array) ( $m['redirect_uris'] ?? array() ) as $uri ) { if ( ! is_string( $uri ) || ! self::same_domain( $uri, $host ) ) { return new WP_Error( 'invalid_client', 'every redirect_uri must be https on the client_id domain' ); } } return true; } /** * Rules for a public client's metadata (OAuth Client ID Metadata * Document; MCP authorization): a name, and redirect URIs that are either * https on the client_id's domain or loopback (RFC 8252 §7.3). Accepted * only while something this store serves takes public clients. * * @param string $client_id URL it was fetched from. * @param array $m Document. * @return true|WP_Error */ private static function validate_public( string $client_id, array $m ) { if ( ! self::public_clients_allowed() ) { return new WP_Error( 'invalid_client', 'token_endpoint_auth_method must be private_key_jwt' ); } if ( '' === trim( (string) ( $m['client_name'] ?? '' ) ) ) { return new WP_Error( 'invalid_client', 'client_name is required' ); } $host = strtolower( (string) wp_parse_url( $client_id, PHP_URL_HOST ) ); $uris = (array) ( $m['redirect_uris'] ?? array() ); if ( ! $uris ) { return new WP_Error( 'invalid_client', 'redirect_uris is required' ); } foreach ( $uris as $uri ) { if ( ! is_string( $uri ) || ! ( self::same_domain( $uri, $host ) || self::loopback( $uri ) ) ) { return new WP_Error( 'invalid_client', 'every redirect_uri must be https on the client_id domain, or a loopback address' ); } } return true; } /** * A public client: one with no means of authenticating itself. * * @param array $m Metadata. */ public static function is_public( array $m ): bool { return 'none' === ( $m['token_endpoint_auth_method'] ?? '' ); } /** * Whether a stored agent is a public client, by hash. * * @param string $client_hash Agent hash. */ public static function is_public_hash( string $client_hash ): bool { global $wpdb; $meta = json_decode( (string) $wpdb->get_var( $wpdb->prepare( 'SELECT metadata FROM %i WHERE client_hash = %s', AgentAdapt_Schema::table( 'agents' ), $client_hash ) ), true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return is_array( $meta ) && self::is_public( $meta ); } /** * Whether anything this store serves accepts public clients. Free core * doesn't; the MCP server does, because stock MCP clients sign in that way. */ public static function public_clients_allowed(): bool { /** * Accept public clients (token_endpoint_auth_method "none") for * Bearer resources. Off unless an MCP server is offered. * * @param bool $allowed False. */ return true === apply_filters( 'agentadapt_public_clients', false ); } /** * Whether a redirect URI is registered for this agent: exactly, or, for a * public client's loopback redirect, on whatever port it opened (RFC 8252 * §7.3: native apps listen on an ephemeral port). * * @param array $agent Resolved agent. * @param string $uri Requested redirect_uri. */ public static function redirect_allowed( array $agent, string $uri ): bool { $registered = array_filter( (array) ( $agent['meta']['redirect_uris'] ?? array() ), 'is_string' ); if ( in_array( $uri, $registered, true ) ) { return true; } if ( empty( $agent['public'] ) || ! self::loopback( $uri ) ) { return false; } $want = wp_parse_url( $uri ); foreach ( $registered as $r ) { $have = wp_parse_url( $r ); if ( self::loopback( $r ) && strtolower( (string) $have['host'] ) === strtolower( (string) $want['host'] ) && ( $have['path'] ?? '' ) === ( $want['path'] ?? '' ) && ( $have['query'] ?? '' ) === ( $want['query'] ?? '' ) ) { return true; } } return false; } /** * An http loopback URL (127.0.0.1, [::1] or localhost), no credentials, no fragment. * * @param string $url URL. */ private static function loopback( string $url ): bool { $p = wp_parse_url( $url ); return is_array( $p ) && 'http' === strtolower( (string) ( $p['scheme'] ?? '' ) ) && in_array( strtolower( (string) ( $p['host'] ?? '' ) ), array( '127.0.0.1', '[::1]', 'localhost' ), true ) && ! isset( $p['user'] ) && ! isset( $p['pass'] ) && ! isset( $p['fragment'] ); } /** * HTTPS URL whose host is $host or a subdomain of it. * * @param string $url URL. * @param string $host client_id host. */ private static function same_domain( string $url, string $host ): bool { if ( ! AgentAdapt_Fetcher::is_https_url( $url ) ) { return false; } $h = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); return $h === $host || str_ends_with( $h, '.' . $host ); } /** * Stored row, or null. * * @param string $client_id Client ID URL. * @return array|null */ public static function row( string $client_id ): ?array { global $wpdb; $t = AgentAdapt_Schema::table( 'agents' ); $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE client_hash = %s", $t, self::hash( $client_id ) ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return $row ?: null; } /** * Whether the store accepts an agent right now. Every place that admits an * agent asks this: resolving it (token endpoint, browser assertions), each * API request, and each page its browser loads, so a change takes effect * on the agent's next request. * * @param string $client_hash Agent hash. * @param string $status Stored status (ok, blocked, allowed). */ public static function accepted( string $client_hash, string $status ): bool { if ( 'blocked' === $status ) { return false; } /** * Whether to accept an agent the merchant has not blocked. A filter * can only refuse: a blocked agent never reaches it. * * @param bool $accepted True. * @param string $client_hash Agent hash. * @param string $status Stored status. */ return true === apply_filters( 'agentadapt_agent_accepted', true, $client_hash, $status ); } /** * Block or unblock an agent. * * @param string $client_id Client ID URL. * @param bool $blocked Whether to block. */ public static function set_blocked( string $client_id, bool $blocked ): void { global $wpdb; $hash = self::hash( $client_id ); $t = AgentAdapt_Schema::table( 'agents' ); if ( self::row( $client_id ) ) { $wpdb->update( $t, array( 'status' => $blocked ? 'blocked' : 'ok' ), array( 'client_hash' => $hash ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } else { // Blocking a client_id never seen yet must still stick. $wpdb->insert( $t, array( 'client_hash' => $hash, 'client_id' => $client_id, 'status' => $blocked ? 'blocked' : 'ok', 'first_seen' => time() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } } /** * Row → resolved agent. * * @param array $row Row. * @return array */ private static function shape( array $row ): array { $meta = json_decode( (string) $row['metadata'], true ); return array( 'client_id' => (string) $row['client_id'], 'client_hash' => (string) $row['client_hash'], 'name' => (string) $row['name'], 'logo_uri' => (string) $row['logo_uri'], 'meta' => is_array( $meta ) ? $meta : array(), 'public' => is_array( $meta ) && self::is_public( $meta ), 'keys' => empty( $row['jwks'] ) ? array() : AgentAdapt_Jose::keys_from_jwks( (array) json_decode( (string) $row['jwks'], true ) ), ); } }