'(transport)', 'status' => 'denied', 'detail' => __( 'Bearer token presented over insecure HTTP', 'agent-abilities-for-mcp' ), ) ); } return $user_id; } // 8. Resolve the token to its row in a single indexed lookup. The row // resolver already gates on (active + unexpired), so a null row covers // every present-but-invalid case - unknown, inactive, expired - and a // present-but-invalid OAuth token simply fails to resolve a user rather // than hard-failing the request. Audit a denial ONLY when the bearer // matches a REAL stored token row that is now invalid (expired or // deactivated) - genuine credential misuse. A bearer that matches NO // stored token at all is unauthenticated, since anyone can fabricate the // aafm_oat_ prefix, so logging it would let an attacker grow the activity // log unbounded; skip the audit for that case. No client_id is known here // - the lookup that would resolve one is exactly what just failed. $row = aafm_oauth_get_access_token_row( $credential ); if ( null === $row ) { if ( function_exists( 'aafm_oauth_log_event' ) && aafm_oauth_access_token_row_exists( $credential ) ) { aafm_oauth_log_event( 'bearer', 'denied' ); } return $user_id; } // 9. Audience binding (RFC 8707): the token must have been minted for THIS // endpoint. A token scoped to a different audience resolves no user here. if ( ! hash_equals( aafm_endpoint_url(), (string) $row['resource'] ) ) { if ( function_exists( 'aafm_oauth_log_event' ) ) { aafm_oauth_log_event( 'bearer', 'denied', array( 'client_id' => (string) $row['client_id'] ) ); } return $user_id; } // 10. Re-enforce client deactivation. is_active is checked at authorize-time, but a token // already in a client's hands keeps working unless its owning client is re-checked here - // so disabling a compromised client invalidates its live access tokens immediately. if ( aafm_oauth_client_is_deactivated( (string) $row['client_id'] ) ) { if ( function_exists( 'aafm_oauth_log_event' ) ) { aafm_oauth_log_event( 'bearer', 'denied', array( 'client_id' => (string) $row['client_id'] ) ); } return $user_id; } // 10b. Per-connection state (1.10.0). An expired grant ends the connection the way a deactivated // client does, and a grant whose state cannot be certified is refused. A PAUSED grant is not refused // here: it keeps a valid login and is answered with a 403 at the transport gate, so a client shows an // error instead of looping through sign-in and refresh. A token with no consent row, and a site that // has not run the v9 installer yet, read as the defaults (aafm_oauth_grant_state()). // 10c. Roles that can connect. Narrowing the owner's list ends the grants of users it no longer // covers; a site that never saved a list keeps every grant (aafm_oauth_user_still_may_connect()). // Short-circuit order matters: the roles check never runs for a missing or expired grant. $grant = aafm_oauth_grant_state( (int) $row['wp_user_id'], (string) $row['client_id'] ); if ( null === $grant || $grant['expired'] || ! aafm_oauth_user_still_may_connect( (int) $row['wp_user_id'] ) ) { if ( function_exists( 'aafm_oauth_log_event' ) ) { aafm_oauth_log_event( 'bearer', 'denied', array( 'client_id' => (string) $row['client_id'] ) ); } return $user_id; } // 11. Token capability map. The token resolves to the approver's FULL account by default // (unchanged behaviour). A site that registers aafm_oauth_token_capabilities replaces the // token's capabilities for the rest of this MCP request: each listed capability is set // outright, a capability the approver lacks included, and every other capability is denied, // in every user_has_cap check the request makes. The resolved identity is unchanged. aafm_oauth_apply_token_capability_scope( (int) $row['wp_user_id'], isset( $row['scope'] ) ? (string) $row['scope'] : '', (string) $row['client_id'] ); // 12. M16: record the resolved client_id. The activity-log rows attribute calls by it, the // allowlist keys its per-connection scopes on it (aafm_ability_allowed_for_principal()), and // aafm_oauth_confine_bearer_to_mcp_handler() reads it as the marker that the current user came // from our bearer. The grant store beside it carries the connection's pause, preset and rate // for the transport gate, the allowlist and the rate limiter. Both are written only after the // token has resolved a user through every guard above, so they cannot change this resolution. // Resolving is not using: the transport gate stamps last use once it admits the request. aafm_oauth_current_client_id( (string) $row['client_id'] ); aafm_oauth_current_grant( array_merge( $grant, array( 'client_id' => (string) $row['client_id'], 'user_id' => (int) $row['wp_user_id'], ) ) ); return (int) $row['wp_user_id']; } finally { $resolving = false; } } /** * Optionally replace the capabilities an OAuth token may exercise for this request. * * The identity a token resolves to is never changed here: the token always acts AS the * approving WordPress user. What this offers is a hook that sets the capabilities checked on * the current MCP request, keyed on the scope the grant was minted with. * * By default it does nothing - the `aafm_oauth_token_capabilities` filter returns null, so no * restriction is applied and existing "acts with the approver's full caps" behaviour is * preserved (non-breaking; live tokens are never silently reduced). A hook that returns a * capability => bool map instead installs a request-scoped `user_has_cap` filter that sets * each listed capability outright, a capability the approver lacks included, and denies every * capability not in the map. That filter applies to every user_has_cap check for the remainder * of THIS request, whichever user it is for. The request only reaches here on the MCP route * with a valid OAuth bearer, so the map never reaches an unrelated request. * * @param int $user_id The resolved approver (unchanged; passed for hook context). * @param string $scope The scope the token was minted with (may be ''). * @param string $client_id The client the token belongs to. * @return void */ function aafm_oauth_apply_token_capability_scope( int $user_id, string $scope, string $client_id ): void { /** * Filter the capabilities an OAuth-authenticated request may exercise. * * Return null (the default) to apply NO restriction - the token acts with the approving * user's full capabilities, exactly as before. Return an array of capability => bool to replace * the capabilities for the rest of this MCP request: each listed capability is granted or denied * outright, a capability the approver lacks included, and any capability not in the map is * denied. The map applies to every user_has_cap check in that request, whichever user it is for. * * @param array|null $caps Capability allow-map, or null for no restriction. * @param string $scope The scope the token was minted with (may be ''). * @param string $client_id The client the token belongs to. * @param int $user_id The approving WordPress user id. */ $caps = apply_filters( 'aafm_oauth_token_capabilities', null, $scope, $client_id, $user_id ); if ( ! is_array( $caps ) ) { return; // Default (and any non-array return): acts as the approver, unchanged. } // Normalise to a capability => bool allow-map once, outside the per-check closure. $allow = array(); foreach ( $caps as $cap => $granted ) { $allow[ (string) $cap ] = (bool) $granted; } add_filter( 'user_has_cap', static function ( $allcaps ) use ( $allow ) { if ( ! is_array( $allcaps ) ) { return $allcaps; } // Deny everything the token was not explicitly granted, then apply the allow-map. foreach ( array_keys( $allcaps ) as $cap ) { $allcaps[ $cap ] = isset( $allow[ $cap ] ) ? $allow[ $cap ] : false; } foreach ( $allow as $cap => $granted ) { $allcaps[ $cap ] = $granted; } return $allcaps; }, 10, 1 ); } /** * Remember (or read) whether REST routing began after WordPress parsed this request. * * Set by aafm_oauth_forget_anonymous_user_on_mcp_route() when rest_api_init fires while WordPress is * running parse_request with core's rest_api_loaded() still hooked, as it does inside * rest_api_loaded(). rest_api_init fires once per process, on the first rest_get_server() from any * caller, so a count of it alone cannot tell this request's REST routing from a REST server some * plugin built earlier; this flag can. Cleared on `shutdown`, like the client id store, so a * persistent worker starts each request without it. * * @param bool|null $began True to record, false to forget, null to read. * @return bool */ function aafm_oauth_rest_routing_began( ?bool $began = null ): bool { static $flag = false; if ( null !== $began ) { $flag = $began; } return $flag; } /** * Forget the REST routing flag at the end of the request. * * @return void */ function aafm_oauth_forget_rest_routing(): void { aafm_oauth_rest_routing_began( false ); } add_action( 'shutdown', 'aafm_oauth_forget_rest_routing' ); /** * Whether WordPress routed this request to the MCP REST route. * * The answer is core's own: the rest_route WordPress::parse_request() settled on, untrailingslashed * as rest_api_loaded() does, matched by aafm_is_mcp_route() as core's router matches it. It is * false until WordPress has parsed the request and is serving it as REST: REST_REQUEST is defined * (core defines it in rest_api_loaded(), the same signal core's Application Passwords wait for), or * rest_api_init fired inside core's REST dispatch during parse_request * (aafm_oauth_rest_routing_began()). That is safe for a healthy MCP call: * WP_REST_Server::serve_request() forgets a cached anonymous user before dispatch, so the bearer * resolves then. Entry points that never parse (wp-admin, admin-ajax, admin-post, wp-comments-post, * cron, CLI) and requests answered during parse_request (the discovery documents) never match, even * on a site where a plugin built the REST server before the parse. What is left is code that builds * the REST server inside an early parse_request callback on a non-REST request, and, as for core's * Application Passwords, code that defines REST_REQUEST itself on a request core does not serve as * REST. * * @return bool True only when core routed the request to the MCP endpoint. */ function aafm_oauth_request_targets_mcp_route(): bool { $wp = $GLOBALS['wp'] ?? null; if ( ! did_action( 'parse_request' ) || ! $wp instanceof WP ) { return false; } if ( ! ( defined( 'REST_REQUEST' ) && REST_REQUEST ) && ! aafm_oauth_rest_routing_began() ) { return false; } $route = $wp->query_vars['rest_route'] ?? null; return is_string( $route ) && aafm_is_mcp_route( untrailingslashit( $route ) ); } /** * Forget a cached anonymous user when an MCP-routed request carries our bearer. * * Runs first on rest_api_init, and does anything only while WordPress is still running * parse_request: core serves a parsed rest_route from rest_api_loaded(), a parse_request callback, * which defines REST_REQUEST and then obtains the REST server, initializing it (and firing * rest_api_init) only if none exists. A server built later (during a page render on a site that * unhooked rest_api_loaded()) or earlier (a plugin at plugins_loaded) is outside it, and this does * nothing. Inside it, with rest_api_loaded() still hooked, it records that REST routing began after * the parse (aafm_oauth_rest_routing_began()). WP::init() looks the user up before WordPress parses * the request, when the bearer cannot resolve yet, and caches "nobody". Once core has defined * REST_REQUEST, this makes the same clear core's WP_REST_Server::serve_request() makes, one step * earlier, so in the normal routing order the tool registry built on rest_api_init sees the * approver. A server some parse_request callback builds before core's dispatch clears nothing. On a * site where code built the REST server before the parse, rest_api_init has already run and * registration sees whoever was resolved then. It only ever forgets a cached user that does not * exist, and only with an aafm_oat_ bearer on a request core routed to the MCP endpoint. * * @return void */ function aafm_oauth_forget_anonymous_user_on_mcp_route(): void { global $current_user; if ( ! doing_action( 'parse_request' ) ) { return; } if ( false !== has_action( 'parse_request', 'rest_api_loaded' ) ) { aafm_oauth_rest_routing_began( true ); } if ( ! ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ! $current_user instanceof WP_User || $current_user->exists() || ! function_exists( 'aafm_is_mcp_route' ) ) { return; } $credential = aafm_oauth_read_bearer_token(); if ( null === $credential || 0 !== strncmp( $credential, AAFM_OAUTH_ACCESS_TOKEN_PREFIX, strlen( AAFM_OAUTH_ACCESS_TOKEN_PREFIX ) ) ) { return; } if ( aafm_oauth_request_targets_mcp_route() ) { $current_user = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- the same clear core makes in WP_REST_Server::serve_request(). } } /** * Refuse a user resolved from our bearer at any handler on the MCP path but an MCP transport's. * * Core runs the first handler whose route matches, so a route another plugin registers inside our * namespace could run ahead of the adapter's on the MCP path. Runs on rest_request_before_callbacks, * after core matched the handler and before the dispatch runs its permission_callback and callback * (core's Allow-header pass still calls that permission_callback afterwards; see Limits). When the * request is the MCP route and the current user came from an aafm_oat_ bearer, any handler whose * callback is not an HttpTransport method gets the same 401 an unauthenticated MCP call gets. * * Limits (named residuals U-1b and U-1c): a second HttpTransport server registered at a route * matching ours is let through; a foreign handler's argument validate and sanitize callbacks, core's * Allow-header pass over its permission_callback, and any rest_request_before_callbacks filter that * runs before this one still run with the bearer's user; and a filter another plugin adds at the same * last priority after this one can undo the refusal. * * @param mixed $response The response so far (WP_Error, a short-circuit value, or null). * @param mixed $handler The matched route handler. * @param mixed $request The request. * @return mixed $response unchanged, or a 401 WP_Error. */ function aafm_oauth_confine_bearer_to_mcp_handler( $response, $handler, $request ) { if ( is_wp_error( $response ) || ! $request instanceof WP_REST_Request || ! function_exists( 'aafm_is_mcp_route' ) || ! aafm_is_mcp_route( $request->get_route() ) ) { return $response; } get_current_user_id(); if ( '' === aafm_oauth_current_client_id() ) { return $response; } $callback = is_array( $handler ) ? ( $handler['callback'] ?? null ) : null; if ( is_array( $callback ) && isset( $callback[0] ) && $callback[0] instanceof \WP\MCP\Transport\HttpTransport ) { return $response; } return new WP_Error( 'aafm_unauthenticated', __( 'Authentication required.', 'agent-abilities-for-mcp' ), array( 'status' => 401 ) ); } /** * Extract the bearer credential from the Authorization header, if present. * * Checks HTTP_AUTHORIZATION first, then the FastCGI-only * REDIRECT_HTTP_AUTHORIZATION fallback. The `Bearer ` scheme is matched * case-insensitively per RFC 6750. * * @return string|null The raw credential, or null when no bearer is present. */ function aafm_oauth_read_bearer_token(): ?string { $header = ''; if ( isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) { $header = trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ) ); } elseif ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) { $header = trim( sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) ); } if ( '' === $header ) { return null; } // Case-insensitive "Bearer " scheme; the remainder is the credential. if ( 0 !== strncasecmp( $header, 'Bearer ', 7 ) ) { return null; } $credential = trim( substr( $header, 7 ) ); return '' === $credential ? null : $credential; } /** * Fetch a full access-token row by the SHA-256 hash of the raw token. * * Returns the same row that {@see aafm_oauth_validate_access_token()} matches - * active and unexpired - so the two functions always agree: a token that * validates also returns a row here, and one that does not validate returns * null. The row carries at least `resource` (the audience) and `wp_user_id`. * * This gates authentication for every bearer request, so it is the highest-stakes instance of the * stale-reader class: a bare $wpdb->get_row() hands back the PREVIOUS * query's row when this one fails, which here would authenticate the caller as whichever OTHER * token happened to be looked up last on this connection - a cross-token identity confusion, not * merely a missing row. Routed through aafm_wpdb_row() so a failed lookup denies, exactly like a * genuinely absent/inactive/expired token already does. * * @param string $raw The raw access token presented by the client. * @return array|null The row as ARRAY_A, or null when not found / inactive / expired * / the lookup itself failed. */ function aafm_oauth_get_access_token_row( string $raw ): ?array { global $wpdb; $table = $wpdb->prefix . 'aafm_oauth_access_tokens'; $now = gmdate( 'Y-m-d H:i:s', time() ); $view = aafm_wpdb_row( $wpdb->prepare( // Keep this WHERE clause in sync with aafm_oauth_validate_access_token() in tokens.php - the two must never disagree on the active/unexpired predicate. 'SELECT * FROM %i WHERE token_hash = %s AND is_active = 1 AND expires_at > %s', $table, hash( 'sha256', $raw ), $now ) ); return ( $view['ok'] && is_array( $view['value'] ) ) ? $view['value'] : null; } /** * Whether an access-token row exists for this raw token at all, ignoring the active/unexpired * predicate {@see aafm_oauth_get_access_token_row()} applies. * * Used at the determine_current_user resolver to tell a genuine-but-invalid credential (a token that * was once issued and is now expired or belongs to a deactivated client - worth auditing as credential * misuse) apart from a bearer that matches NO stored token, which anyone can fabricate simply by using * the aafm_oat_ prefix. Auditing the latter would let an unauthenticated caller grow the activity log * at will, so the denial audit is gated on this existence check. token_hash is a UNIQUE key, so this * is a single indexed lookup. * * Routed through aafm_wpdb_scalar() rather than a bare get_var() - a failed * query here could otherwise inherit a stale non-null value left over from an unrelated earlier * query in the same request and wrongly report a fabricated bearer as a real, once-issued token, * defeating the anti-flood gate this existence check exists to enforce. * * @param string $raw The raw access token presented by the client. * @return bool True when a row with this token hash exists, regardless of its active/expiry state. */ function aafm_oauth_access_token_row_exists( string $raw ): bool { global $wpdb; $table = $wpdb->prefix . 'aafm_oauth_access_tokens'; $view = aafm_wpdb_scalar( $wpdb->prepare( 'SELECT id FROM %i WHERE token_hash = %s LIMIT 1', $table, hash( 'sha256', $raw ) ) ); return $view['ok'] && null !== $view['value']; }