using_index_permalinks() ) { // <- null that early * * The audience check below needs this URL, so without the fallback a bearer * request would fatal with "Call to a member function using_index_permalinks() * on null" and every authenticated call would come back a 500. It needs all * three of: a plugin that resolves the user that early, pretty permalinks, and * a bearer token on the request — which is exactly a real tool call after * sign-in, and never the connection test before it. * * Bailing out early instead is not an option: _wp_get_current_user() caches * whatever the filter chain returns, so declining to resolve here would cache * "logged out" for the rest of the request. So this reproduces core's own * construction without the global, and hands back to core the moment it exists. */ function novasira_auth_rest_url(string $path): string { $structure = (string) get_option('permalink_structure'); $rewrite_ready = isset($GLOBALS['wp_rewrite']) && $GLOBALS['wp_rewrite'] instanceof WP_Rewrite; // core only reaches for $wp_rewrite on a site with pretty permalinks, so // every other case is already safe and stays with core. Reproducing the URL // here is a last resort rather than a replacement: the string is compared // against the `resource` recorded on a token, and two constructions that // drift apart would reject a perfectly good one. if ($structure === '' || $rewrite_ready) { return rest_url($path); } $path = '/' . ltrim($path, '/'); // using_index_permalinks() is just this test against the same option. $index = str_contains($structure, 'index.php') ? 'index.php/' : ''; $url = home_url('/' . $index . rest_get_url_prefix() . $path, 'rest'); $server_name = isset($_SERVER['SERVER_NAME']) ? sanitize_text_field(wp_unslash((string) $_SERVER['SERVER_NAME'])) : ''; if (is_ssl() && $server_name !== '' && wp_parse_url(home_url(), PHP_URL_HOST) === $server_name) { $url = set_url_scheme($url, 'https'); } /** Part of the URL's identity — a site that moves its REST root does it here, and skipping the filter would return a different string than rest_url() does. */ return (string) apply_filters('rest_url', $url, $path, null, 'rest'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core's own filter, applied as rest_url() does. } /** * The canonical resource identifier for this site's MCP endpoint. * * Tokens are bound to this value (RFC 8707). A token minted for one site must * never be accepted by another, which is what stops a hostile server the user * also connected to from replaying their token here. */ function novasira_auth_resource_id(): string { return untrailingslashit(novasira_auth_rest_url('mcp/novasira')); } /** * Every endpoint a token of ours is valid for. * * One entry today. It stays an array and a filter because the day this plugin * serves a second transport is the day a token minted for the first has to keep * working, and discovering that through a wave of invalid_target refusals is * the expensive way to find out. * * @return list */ function novasira_auth_valid_audiences(): array { $audiences = [untrailingslashit(novasira_auth_rest_url('mcp/novasira'))]; /** * Filters the endpoints a Novasira access token is accepted on. * * @param list $audiences Absolute endpoint URLs. */ return array_values(array_unique(array_filter((array) apply_filters('novasira_auth_valid_audiences', $audiences)))); } /** * Reduce a URL to what actually identifies the endpoint it points at. * * Scheme and `www.` are dropped, the host is lowercased, and every address * WordPress can serve the same REST route on is folded to one path — the pretty * form, the index.php form and both ?rest_route= forms are the same endpoint * reached four ways. * * @return array{host: string, route: string}|null Null when the URL is unusable. */ function novasira_auth_url_identity(string $url): ?array { $parts = wp_parse_url(trim($url)); if (!is_array($parts) || empty($parts['host'])) { return null; } $host = strtolower(trim((string) $parts['host'], '[]')); // www is not a different site. Treating it as one is how somebody who // pasted the address their browser shows them gets refused. $host = preg_replace('/^www\./', '', $host) ?? $host; // A WordPress in a subfolder is a different site from one in a sibling // folder on the same host, so the install root belongs to the identity of // the site rather than to the route. $home = wp_parse_url(home_url()); $base = rtrim((string) ($home['path'] ?? ''), '/'); $path = '/' . ltrim((string) ($parts['path'] ?? ''), '/'); // Is this URL actually inside our install? Claiming our folder for a URL // that is not under it would fold a WordPress at the root of the same host // onto ours — a different site, running a different MCP server. $under = $base === '' || $path === $base || str_starts_with($path, $base . '/'); $root = $under ? $base : ''; // ?rest_route=/mcp/novasira names the route in the query instead of the // path, and names it bare — there is nothing to strip off it. parse_str((string) ($parts['query'] ?? ''), $query); if (isset($query['rest_route']) && is_string($query['rest_route'])) { $route = $query['rest_route']; } else { $route = $path; // Order matters: the install root comes off first, or the prefixes // below never match on a subfolder install. if ($root !== '') { $route = substr($route, strlen($root)); } // What is left is addressing, not identity. $prefix = trim((string) rest_get_url_prefix(), '/'); $route = (string) preg_replace('#^/index\.php#', '', $route); $route = (string) preg_replace('#^/' . preg_quote($prefix, '#') . '(?=/|$)#', '', $route); } return [ 'host' => $host . $root, 'route' => '/' . trim($route, '/'), ]; } /** * Is this `resource` one of our own MCP endpoints? * * RFC 8707 binds a token to the resource it was issued for, and that binding is * what stops a hostile server the user also connected to from replaying their * token here. It has to keep doing that — but byte equality against one string * is not how to do it, because a client sends whatever address its user typed. * `www.`, `https` where WordPress records `http` (a proxy terminating TLS * upstream), and the ?rest_route= form of the very same route would all be * refused with `invalid_target`, which reaches the user as nothing more than * "couldn't connect". * * Matching on identity rather than spelling accepts every address that reaches * THIS site's MCP endpoint and still rejects every address that does not: the * host must be ours, so another origin never matches however it is written. */ function novasira_auth_audience_matches(string $resource): bool { $asked = novasira_auth_url_identity($resource); if ($asked === null) { return false; } foreach (novasira_auth_valid_audiences() as $audience) { $ours = novasira_auth_url_identity($audience); if ($ours !== null && $ours['host'] === $asked['host'] && $ours['route'] === $asked['route']) { return true; } } return false; } /** * Issuer identifier. Must match the URL its metadata document is fetched from. * * The site itself, which is right on every install that runs one OAuth-enabled * MCP server — and that is the shape this was proven in. It is filterable * because of the install that runs two: the authorization-server document lives * at a path derived from the issuer, so two plugins claiming the site root claim * the same document, and whichever answers first answers for both. A client then * reads our resource document, is told the authorization server is this site, * fetches the other plugin's metadata, registers there, and arrives here holding * a token for an endpoint we do not serve. * * Moving the issuer under a path of our own is the whole fix, and it needs no * other change: the well-known paths below and the matcher are derived from * whatever this returns, so a site with a second server adds * * add_filter('novasira_auth_issuer', fn() => home_url('/novasira-auth')); * * and the document becomes ours alone. It is not the default because a client * that ignores `authorization_servers` and probes the site root regardless would * then find nothing at all — on a single-server install, which is nearly all of * them, the root is the address that works. */ function novasira_auth_issuer(): string { /** * Filters this authorization server's issuer identifier. * * @param string $issuer An absolute URL with no trailing slash. */ return untrailingslashit((string) apply_filters('novasira_auth_issuer', untrailingslashit(home_url()))); } function novasira_auth_metadata_url(): string { return novasira_auth_rest_url('novasira/v1/auth/protected-resource'); } /** * Where a client sends the user to approve access. * * A path with no query of its own. RFC 6749 permits a query component and * requires clients to merge their own parameters into it, but clients that * append `?response_type=…` regardless are common enough that an endpoint * carrying a query string is unreachable for them — the second `?` turns * `action` into nonsense and admin-post.php answers with a blank page. Nothing * to merge, nothing to break. * * Falls back to the admin-post form when rewrites are unavailable (plain * permalinks), which is the only case where the query string is unavoidable, * and when an administrator has said the pretty path is blocked here. */ function novasira_auth_authorize_url(): string { // Set by hand from the Connect screen, for a host whose WAF or server rule // answers /novasira-auth/* with a 403 or a 404 while admin-post.php works. // The metadata document is fetched at every sign-in, so flipping this heals // every client on its next attempt without anyone touching a client config. if (get_option('novasira_auth_authorize_fallback')) { return admin_url('admin-post.php?action=novasira_auth_authorize'); } if (get_option('permalink_structure')) { return home_url('/novasira-auth/authorize'); } return admin_url('admin-post.php?action=novasira_auth_authorize'); } /** * A public, same-scheme URL for this plugin's own mark, or '' when there is none. * * Two things stop an icon from ever being displayed. The plugin URL is built * from whatever scheme the request arrived on, so a site reached over https can * publish an http icon — mixed content, which a browser refuses to load. And a * client cannot show what it was never told about, so the same URL has to be * offered everywhere a client might look. * * Returns '' when the file is absent rather than publishing a 404: a client * that fetches a broken logo_uri draws nothing, which is where it started, and * an empty field is at least honest. */ function novasira_auth_icon_url(): string { $relative = 'assets/novasira-icon.svg'; if (!is_readable(NOVASIRA_DIR . $relative)) { return ''; } $url = NOVASIRA_URL . $relative; // Follow the site's own scheme rather than the current request's. if (str_starts_with((string) home_url(), 'https://')) { $url = set_url_scheme($url, 'https'); } /** * Filters the icon this site advertises to AI clients. * * @param string $url Absolute URL to an SVG or PNG, or '' for none. */ return (string) apply_filters('novasira_auth_icon_url', $url); } // --------------------------------------------------------------------------- // Clients // --------------------------------------------------------------------------- /** * @return array> */ function novasira_auth_clients(): array { $raw = get_option(NOVASIRA_AUTH_CLIENTS_OPTION, []); return is_array($raw) ? $raw : []; } /** * @return array|null */ function novasira_auth_client(string $client_id): ?array { $clients = novasira_auth_clients(); return isset($clients[$client_id]) && is_array($clients[$client_id]) ? $clients[$client_id] : null; } /** * Persist a client record. * * @param array $client */ function novasira_auth_save_client(string $client_id, array $client): void { $clients = novasira_auth_clients(); $clients[$client_id] = $client; update_option(NOVASIRA_AUTH_CLIENTS_OPTION, $clients, false); } function novasira_auth_forget_client(string $client_id): void { $clients = novasira_auth_clients(); unset($clients[$client_id]); update_option(NOVASIRA_AUTH_CLIENTS_OPTION, $clients, false); } /** * Resolve a client id to a client record. * * Registered clients only. A Client ID Metadata Document — an https client id * this server fetches and reads — would mean an unauthenticated caller choosing * a URL this site then requests, and this plugin makes no outbound requests at * all. Dynamic registration covers every shipping AI client, so the capability * is absent rather than half-guarded. * * @return array|null */ function novasira_auth_resolve_client(string $client_id): ?array { return $client_id === '' ? null : novasira_auth_client($client_id); } /** * Whether a redirect URI is acceptable for a client. * * Exact string match against the registered set — no prefix and no wildcard * matching. A loose comparison here is the classic open redirect that lets an * attacker walk off with an authorization code. * * @param array $registered */ function novasira_auth_redirect_uri_allowed(string $candidate, array $registered): bool { foreach ($registered as $uri) { if (hash_equals((string) $uri, $candidate)) { return true; } } return false; } /** * Whether a redirect URI is safe to register at all. * * HTTPS everywhere, except loopback — desktop and CLI clients legitimately * listen on 127.0.0.1, and OAuth 2.1 permits exactly that. */ function novasira_auth_redirect_uri_is_valid(string $uri): bool { $parts = wp_parse_url($uri); if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { return false; } if (!empty($parts['fragment'])) { return false; } $scheme = strtolower((string) $parts['scheme']); // An IPv6 host arrives bracketed, as the URL syntax requires — // http://[::1]:5000. Comparing that against '::1' fails, which quietly // refuses registration to any client that binds IPv6 loopback. It is the // same machine either way. $host = strtolower(trim((string) $parts['host'], '[]')); if ($scheme === 'https') { return true; } if ($scheme === 'http' && in_array($host, ['127.0.0.1', '::1', 'localhost'], true)) { return true; } // Custom schemes (myapp://callback) are how many native clients register. // Never one a browser would execute or read locally. if (in_array($scheme, ['javascript', 'data', 'vbscript', 'file', 'blob'], true)) { return false; } return preg_match('/^[a-z][a-z0-9+.\-]*$/', $scheme) === 1 && str_contains($uri, '://'); } /** * Sanitize a redirect URI from a request. * * esc_url_raw() alone strips any scheme outside wp_allowed_protocols(), which * turned every custom-scheme callback a native client registered into an empty * string at the authorize step. The URI's own scheme is allowed only when * novasira_auth_redirect_uri_is_valid() accepts it. */ function novasira_auth_sanitize_redirect_uri(string $raw): string { $raw = trim($raw); $scheme = strtolower((string) wp_parse_url($raw, PHP_URL_SCHEME)); $protocols = ['https', 'http']; if ($scheme !== '' && !in_array($scheme, $protocols, true) && novasira_auth_redirect_uri_is_valid($raw)) { $protocols[] = $scheme; } return esc_url_raw($raw, $protocols); } // --------------------------------------------------------------------------- // Authorization codes // --------------------------------------------------------------------------- /** * Store a pending authorization and return its code. * * The transient is keyed by the code's hash, not the code, so the database row * cannot be read back into a usable code. * * @param array $payload */ function novasira_auth_issue_code(array $payload): string { $code = wp_generate_password(48, false, false); set_transient(NOVASIRA_AUTH_CODE_PREFIX . hash('sha256', $code), $payload, NOVASIRA_AUTH_CODE_TTL); return $code; } /** * Redeem an authorization code. Single use: the record is deleted on read, so a * replayed code finds nothing even while it is still inside its TTL. * * @return array|null */ function novasira_auth_consume_code(string $code): ?array { $key = NOVASIRA_AUTH_CODE_PREFIX . hash('sha256', $code); $payload = get_transient($key); delete_transient($key); return is_array($payload) ? $payload : null; } // --------------------------------------------------------------------------- // Tokens // --------------------------------------------------------------------------- /** * The best name we have for a client, and never an empty one. * * In order: what it registered as, the host it lives on, and finally the * identifier itself. "Unknown application" is what a list says when it has * stopped trying. */ function novasira_auth_client_display_name(string $client_id): string { if ($client_id === '') { return __('An application', 'novasira'); } $client = novasira_auth_client($client_id); $name = trim((string) ($client['client_name'] ?? '')); if ($name !== '' && $name !== 'Unnamed client') { return $name; } // A client id that is a URL names its own owner well enough to be useful. $host = (string) wp_parse_url($client_id, PHP_URL_HOST); if ($host !== '') { return $host; } return $client_id; } /** * Mint an access token (and a refresh token) for a user. * * The user id is carried in the token string itself so validation is a single * user-meta read rather than a scan of every user on the site. The id is not a * secret — Basic auth already puts the username on the wire — and it is useless * without the random half, which is all that is ever compared. * * @param array $scopes * @return array{access_token: string, refresh_token: string, expires_in: int, token_id: string} */ function novasira_auth_issue_token(int $user_id, string $client_id, array $scopes, string $resource): array { $token_id = wp_generate_password(12, false, false); $secret = wp_generate_password(48, false, false); $refresh_secret = wp_generate_password(48, false, false); $now = time(); $record = [ 'id' => $token_id, 'hash' => hash('sha256', $secret), 'refresh_hash' => hash('sha256', $refresh_secret), 'client_id' => $client_id, // The name is recorded with the token rather than looked up when the // list is drawn, so a connection whose client record is later deleted // still has something to be called. 'client_name' => novasira_auth_client_display_name($client_id), 'scopes' => array_values($scopes), 'resource' => $resource, 'created' => $now, 'expires' => $now + NOVASIRA_AUTH_TOKEN_TTL, 'refresh_expires' => $now + NOVASIRA_AUTH_REFRESH_TTL, 'last_used' => 0, 'last_ip' => '', ]; $tokens = novasira_auth_user_tokens($user_id); $tokens[$token_id] = $record; novasira_auth_save_user_tokens($user_id, $tokens); return [ 'access_token' => 'novasira_at_' . $user_id . '.' . $token_id . '.' . $secret, 'refresh_token' => 'novasira_rt_' . $user_id . '.' . $token_id . '.' . $refresh_secret, 'expires_in' => NOVASIRA_AUTH_TOKEN_TTL, 'token_id' => $token_id, ]; } /** * @return array> */ function novasira_auth_user_tokens(int $user_id): array { $raw = get_user_meta($user_id, NOVASIRA_AUTH_TOKENS_META, true); return is_array($raw) ? $raw : []; } /** * @param array> $tokens */ function novasira_auth_save_user_tokens(int $user_id, array $tokens): void { if ($tokens === []) { delete_user_meta($user_id, NOVASIRA_AUTH_TOKENS_META); return; } update_user_meta($user_id, NOVASIRA_AUTH_TOKENS_META, $tokens); } /** * Split a token string into its parts without trusting any of them. * * @return array{user_id: int, token_id: string, secret: string}|null */ function novasira_auth_parse_token(string $token, string $prefix): ?array { if (!str_starts_with($token, $prefix)) { return null; } $parts = explode('.', substr($token, strlen($prefix)), 3); if (count($parts) !== 3) { return null; } $user_id = (int) $parts[0]; if ($user_id <= 0 || $parts[1] === '' || $parts[2] === '') { return null; } return ['user_id' => $user_id, 'token_id' => $parts[1], 'secret' => $parts[2]]; } /** * Validate an access token. * * @return array{user_id: int, record: array}|null */ function novasira_auth_validate_token(string $token): ?array { $parsed = novasira_auth_parse_token($token, 'novasira_at_'); if ($parsed === null) { return null; } $tokens = novasira_auth_user_tokens($parsed['user_id']); $record = $tokens[$parsed['token_id']] ?? null; if (!is_array($record)) { return null; } // Constant-time: a timing difference here would let an attacker recover a // valid token one byte at a time. if (!hash_equals((string) ($record['hash'] ?? ''), hash('sha256', $parsed['secret']))) { return null; } if ((int) ($record['expires'] ?? 0) < time()) { return null; } // The account may have been deleted since the token was granted. $user = get_userdata($parsed['user_id']); if (!$user) { return null; } return ['user_id' => $parsed['user_id'], 'record' => $record]; } /** * Record that a token was used. Throttled, because this runs on every MCP call * and is not worth a database write each time. */ function novasira_auth_touch_token(int $user_id, string $token_id): void { $tokens = novasira_auth_user_tokens($user_id); if (!isset($tokens[$token_id])) { return; } $now = time(); if ($now - (int) ($tokens[$token_id]['last_used'] ?? 0) < 60) { return; } $tokens[$token_id]['last_used'] = $now; $tokens[$token_id]['last_ip'] = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash((string) $_SERVER['REMOTE_ADDR'])) : ''; novasira_auth_save_user_tokens($user_id, $tokens); } function novasira_auth_revoke_token(int $user_id, string $token_id): bool { $tokens = novasira_auth_user_tokens($user_id); if (!isset($tokens[$token_id])) { return false; } unset($tokens[$token_id]); novasira_auth_save_user_tokens($user_id, $tokens); return true; } /** * Exchange a refresh token for a new access token, rotating both. * * Rotation matters: if a refresh token leaks and the attacker uses it, the * legitimate client's next refresh fails, which surfaces the compromise instead * of letting it run quietly for six months. * * @return array{access_token: string, refresh_token: string, expires_in: int, token_id: string}|null */ function novasira_auth_refresh(string $refresh_token, string $client_id): ?array { $parsed = novasira_auth_parse_token($refresh_token, 'novasira_rt_'); if ($parsed === null) { return null; } $tokens = novasira_auth_user_tokens($parsed['user_id']); $record = $tokens[$parsed['token_id']] ?? null; if (!is_array($record)) { return null; } if (!hash_equals((string) ($record['refresh_hash'] ?? ''), hash('sha256', $parsed['secret']))) { return null; } if ((int) ($record['refresh_expires'] ?? 0) < time()) { return null; } // A refresh token is bound to the client it was issued to. if (!hash_equals((string) ($record['client_id'] ?? ''), $client_id)) { return null; } novasira_auth_revoke_token($parsed['user_id'], $parsed['token_id']); return novasira_auth_issue_token( $parsed['user_id'], $client_id, (array) ($record['scopes'] ?? []), (string) ($record['resource'] ?? novasira_auth_resource_id()) ); } /** * Drop tokens that can no longer be used, so the connections list stays honest. */ function novasira_auth_prune_tokens(int $user_id): void { $tokens = novasira_auth_user_tokens($user_id); $now = time(); $kept = []; foreach ($tokens as $id => $record) { if ((int) ($record['refresh_expires'] ?? 0) >= $now || (int) ($record['expires'] ?? 0) >= $now) { $kept[$id] = $record; } } if (count($kept) !== count($tokens)) { novasira_auth_save_user_tokens($user_id, $kept); } } // --------------------------------------------------------------------------- // Request-scoped state // --------------------------------------------------------------------------- /** * The token backing the current request, if it was authenticated by bearer. * * Set once by the resolver and read by scope enforcement and the connections * UI. Null means this request came in some other way — a browser cookie or an * application password — and the scope rules do not apply to it. * * @param array|null $record * @return array|null */ function novasira_auth_current_token(?array $record = null): ?array { static $current = null; if ($record !== null) { $current = $record; } return $current; } /** Whether the current request was authenticated by one of our bearer tokens. */ function novasira_auth_is_token_request(): bool { return novasira_auth_current_token() !== null; } /** * Is this REST route Novasira's own MCP server? * * Only `mcp/novasira`. Other plugins' MCP servers live under the same `mcp/` * namespace, and neither tokens, CORS headers nor challenges from here may * reach them. */ function novasira_auth_is_own_mcp_route(string $route): bool { $route = '/' . ltrim($route, '/'); return $route === '/mcp/novasira' || str_starts_with($route, '/mcp/novasira/'); } /** * Is this request addressed to Novasira's MCP endpoint? * * Read from the URL rather than from REST state, because * `determine_current_user` runs before the REST server has parsed a route. * This is only a first filter: the URL path is not what decides the route — * a `rest_route` value in the query or body overrides it — so the route that * is actually dispatched is checked again in novasira_auth_restrict_token_route(). */ function novasira_auth_request_is_mcp(): bool { $uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash((string) $_SERVER['REQUEST_URI'])) : ''; $path = (string) wp_parse_url($uri, PHP_URL_PATH); if ($path === '') { return false; } $path = '/' . ltrim($path, '/'); // Pretty permalinks: /wp-json/mcp/novasira, including on a site installed // in a subdirectory — so the prefix comes from the real REST URL — and the // plain form, ?rest_route=/mcp/novasira. // get_rest_url() needs $wp_rewrite, which does not exist yet this early. $home = trim((string) wp_parse_url(home_url('/'), PHP_URL_PATH), '/'); $prefix = '/' . ltrim($home . '/' . trim((string) rest_get_url_prefix(), '/'), '/') . '/'; if (str_starts_with($path, $prefix) && novasira_auth_is_own_mcp_route(substr($path, strlen($prefix)))) { return true; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing read only; nothing changes state here. $route = isset($_GET['rest_route']) ? sanitize_text_field(wp_unslash((string) $_GET['rest_route'])) : ''; if ($route !== '' && novasira_auth_is_own_mcp_route($route)) { return true; } /** * Allow another MCP transport to accept these tokens. * * @param bool $is_mcp Whether this request targets an MCP endpoint. * @param string $path The request path. */ return (bool) apply_filters('novasira_auth_request_is_mcp', false, $path); } /** * Read the bearer token from the request, if any. * * Three places, because the header does not always survive the journey. Apache * with CGI or FastCGI drops Authorization before PHP sees it, and the usual * .htaccess remedy re-exposes it as REDIRECT_HTTP_AUTHORIZATION; some SAPIs * surface it only through getallheaders(). Missing those fallbacks is why * "it works on my machine" bearer auth so often fails on shared hosting, and it * reads to the user exactly like a wrong password. */ function novasira_auth_bearer_from_request(): string { $header = ''; foreach (['HTTP_AUTHORIZATION', 'REDIRECT_HTTP_AUTHORIZATION'] as $key) { if (!empty($_SERVER[$key])) { $header = sanitize_text_field(wp_unslash((string) $_SERVER[$key])); break; } } if ($header === '' && function_exists('getallheaders')) { foreach ((array) getallheaders() as $name => $value) { if (strtolower((string) $name) === 'authorization') { $header = sanitize_text_field((string) $value); break; } } } if ($header === '' || stripos($header, 'bearer ') !== 0) { return ''; } return trim(substr($header, 7)); } // --------------------------------------------------------------------------- // Request authentication // --------------------------------------------------------------------------- /** * Resolve a bearer token to a WordPress user. * * Hooked to `determine_current_user` beside core's application-password * validator. Resolving here rather than inside the MCP transport means every * layer downstream keeps working untouched: the transport's capability check, * the adapter's session manager, and each ability's own permission callback all * simply see a logged-in user. * * @param int|false $user_id * @return int|false */ function novasira_auth_determine_current_user($user_id) { // Something already authenticated this request — a cookie or an application // password. Never override it. if (!empty($user_id)) { return $user_id; } $token = novasira_auth_bearer_from_request(); if ($token === '') { return $user_id; } // A token is only good for the MCP endpoints it was granted for. // // Scopes are enforced on the tool call, so anything reached by another route // never meets them: a connection approved for reading alone would otherwise // have authenticated as the consenting user against the whole REST API, and // the approval screen would have been describing a limit that did not exist. if (!novasira_auth_request_is_mcp()) { return $user_id; } $valid = novasira_auth_validate_token($token); if ($valid === null) { return $user_id; } // Audience binding (RFC 8707). A token minted for another site must not be // usable here even when it is otherwise valid. $bound = (string) ($valid['record']['resource'] ?? ''); if ($bound !== '' && !novasira_auth_audience_matches($bound)) { return $user_id; } novasira_auth_current_token($valid['record']); novasira_auth_touch_token($valid['user_id'], (string) $valid['record']['id']); return $valid['user_id']; } add_filter('determine_current_user', 'novasira_auth_determine_current_user', 20); /** * Refuse a bearer-authenticated request whose dispatched route is not ours. * * novasira_auth_determine_current_user() can only see the URL, and WordPress * lets a `rest_route` parameter in the query or body override the route the * path names. Without this, POST /wp-json/mcp/novasira with * `rest_route=/wp/v2/users` would run a core route as the consenting user, and * no scope would ever be checked. Here the route is the real one. * * @param mixed $result * @param mixed $server * @param mixed $request * @return mixed */ function novasira_auth_restrict_token_route($result, $server, $request) { if (novasira_auth_current_token() === null || !$request instanceof WP_REST_Request) { return $result; } $route = (string) $request->get_route(); if (novasira_auth_is_own_mcp_route($route) || apply_filters('novasira_auth_request_is_mcp', false, $route)) { return $result; } return new WP_Error( 'novasira_token_wrong_route', __('This access token is only valid for the Novasira MCP endpoint.', 'novasira'), ['status' => 401] ); } add_filter('rest_pre_dispatch', 'novasira_auth_restrict_token_route', 1, 3); /** * Build the challenge header that tells a client where to authenticate. * * This is the primary discovery path. Clients must prefer the metadata URL * given here over probing well-known locations, which matters because those * paths are rewritten or intercepted on a lot of real hosting. */ function novasira_auth_challenge_header(?string $error = null, ?string $scope = null): string { $parts = ['Bearer resource_metadata="' . esc_url_raw(novasira_auth_metadata_url()) . '"']; if ($error !== null) { $parts[] = 'error="' . $error . '"'; } // Advertise everything this server understands, not the subset we would // tick by default. Clients echo this list straight into their authorization // request, so naming less here is how a connection ends up unable to ask // for a permission it will need — before anybody has seen a screen. $parts[] = 'scope="' . ($scope ?? implode(' ', array_keys(novasira_auth_scopes()))) . '"'; return implode(', ', $parts); } /** * Attach the challenge to unauthorised MCP responses. * * Neither WordPress nor the MCP adapter emits `WWW-Authenticate` on a REST 401, * so without this a client has no way to discover that signing in is even * available and simply reports a failed connection. * * @param mixed $result * @param mixed $server * @param mixed $request * @return mixed */ function novasira_auth_add_challenge_header($result, $server, $request) { if (!$result instanceof WP_REST_Response || !$request instanceof WP_REST_Request) { return $result; } if (!novasira_auth_is_own_mcp_route((string) $request->get_route())) { return $result; } // Without these a browser-based client cannot read the challenge at all: // WWW-Authenticate is not on the CORS safelist, so fetch() hides it and the // client sees a bare 401 with no idea that signing in is possible. The // wildcard origin is safe precisely because it forbids credentials — a // cross-origin fetch carrying this site's cookies is refused by the browser // when the origin is `*`, so nothing here is reachable without a token the // caller already holds. $result->header('Access-Control-Allow-Origin', '*'); $result->header('Access-Control-Expose-Headers', 'WWW-Authenticate, Mcp-Session-Id'); if ($result->get_status() === 401) { $result->header('WWW-Authenticate', novasira_auth_challenge_header()); return $result; } // A scope refusal names the missing scope in a header a client can read — // but it must NOT turn the response into a 403 with an auth challenge. // Measured elsewhere on a live site: a tool the token had no scope for // produced a 403 plus WWW-Authenticate, the client read that as "not signed // in", ran the whole authorization dance again, got the same grant, tried // the same tool, failed identically. A browser window per tool call, // forever. The tool call itself already failed with an explanation inside a // perfectly good 200, and that is what the client should be reading. $needed = novasira_auth_flag_insufficient_scope(); if ($needed !== null) { $result->header('X-Novasira-Insufficient-Scope', $needed); } return $result; } add_filter('rest_post_dispatch', 'novasira_auth_add_challenge_header', 10, 3); // Scope enforcement sits on the adapter's pre-call filter, which short-circuits // on WP_Error. Registered here so it is active for every transport. add_filter('mcp_adapter_pre_tool_call', 'novasira_auth_enforce_tool_scope', 5, 3); /** * Tell a connecting client what this server is called and what it looks like. * * A connector with no icon is drawn as the first letter of its name, so the * site appears in client UIs as a grey "N". The handshake is where a client * looks for this, and it costs one filter to answer. * * @param mixed $result * @param mixed $mcp * @return mixed */ function novasira_auth_describe_server($result, $mcp = null) { if (!is_object($result) || !method_exists($result, 'toArray')) { return $result; } $data = $result->toArray(); if (!is_array($data) || !isset($data['serverInfo']) || !is_array($data['serverInfo'])) { return $result; } $site = trim((string) get_bloginfo('name')); $data['serverInfo']['title'] = $site !== '' ? 'Novasira — ' . $site : 'Novasira'; $data['serverInfo']['websiteUrl'] = home_url('/'); $icon = novasira_auth_icon_url(); if ($icon !== '') { $data['serverInfo']['icons'] = [ [ 'src' => $icon, 'mimeType' => 'image/svg+xml', 'sizes' => ['any'], ], ]; } try { return $result::fromArray($data); } catch (\Throwable $e) { // A schema that will not take the extra fields is not worth failing the // handshake over: the connection matters, the title does not. return $result; } } add_filter('mcp_adapter_initialize_response', 'novasira_auth_describe_server', 10, 2);