'string', 'sanitize_callback' => 'sanitize_text_field']; // Public: OAuth discovery metadata (RFC 9728) is meant to be read by anyone. register_rest_route('novasira/v1', '/auth/protected-resource', [ 'methods' => 'GET', 'permission_callback' => '__return_true', 'callback' => 'novasira_auth_protected_resource_metadata', ]); // Public: OAuth discovery metadata (RFC 8414) is meant to be read by anyone. register_rest_route('novasira/v1', '/auth/authorization-server', [ 'methods' => 'GET', 'permission_callback' => '__return_true', 'callback' => 'novasira_auth_authorization_server_metadata', ]); // Public: dynamic client registration (RFC 7591) is called before any // credential exists. It grants nothing; it is rate-limited and capped, and // access still needs an administrator to approve on the consent screen. register_rest_route('novasira/v1', '/auth/register', [ 'methods' => 'POST', 'permission_callback' => '__return_true', 'callback' => 'novasira_auth_register_client', 'args' => [ 'redirect_uris' => ['type' => 'array', 'items' => ['type' => 'string']], 'client_name' => $text, 'client_uri' => ['type' => 'string', 'sanitize_callback' => 'esc_url_raw'], 'logo_uri' => ['type' => 'string', 'sanitize_callback' => 'esc_url_raw'], 'application_type' => ['type' => 'string', 'sanitize_callback' => 'sanitize_key'], ], ]); // Public: the token endpoint authenticates by proof of possession (the // authorization code plus its PKCE verifier, or a refresh token), not by // cookie — the caller is a background process with no browser session. register_rest_route('novasira/v1', '/auth/token', [ 'methods' => 'POST', 'permission_callback' => '__return_true', 'callback' => 'novasira_auth_token_endpoint', 'args' => [ 'grant_type' => $text, 'code' => $text, 'code_verifier' => $text, 'client_id' => $text, 'refresh_token' => $text, 'scope' => $text, 'resource' => ['type' => 'string', 'sanitize_callback' => 'esc_url_raw'], 'redirect_uri' => ['type' => 'string', 'sanitize_callback' => 'novasira_auth_sanitize_redirect_uri'], ], ]); // Public: revocation (RFC 7009) — presenting the token is the credential. register_rest_route('novasira/v1', '/auth/revoke', [ 'methods' => 'POST', 'permission_callback' => '__return_true', 'callback' => 'novasira_auth_revoke_endpoint', 'args' => [ 'token' => $text, 'token_type_hint' => $text, ], ]); } /** * CORS for the auth routes and the MCP endpoint. * * Clients that run in a browser — web IDEs, the MCP inspector, anything embedded * — preflight the token and registration calls and read WWW-Authenticate off the * refusal. WordPress only sends CORS headers for origins it already trusts, so * without this every browser-based sign-in fails on a wall of opaque CORS * errors. These endpoints are public by design and carry no cookie * authentication, and a wildcard origin forbids credentials, so it gives nothing * away. * * @param mixed $served * @param mixed $result * @param mixed $request * @param mixed $server * @return mixed */ function novasira_auth_send_cors_headers($served, $result, $request, $server) { if (!$request instanceof WP_REST_Request) { return $served; } $route = ltrim((string) $request->get_route(), '/'); if (!str_starts_with($route, 'novasira/v1/auth/') && !novasira_auth_is_own_mcp_route($route)) { return $served; } header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); header('Access-Control-Allow-Headers: Authorization, Content-Type, Mcp-Session-Id, MCP-Protocol-Version, Last-Event-ID'); header('Access-Control-Expose-Headers: WWW-Authenticate, Mcp-Session-Id'); header('Access-Control-Max-Age: 3600'); return $served; } add_filter('rest_pre_serve_request', 'novasira_auth_send_cors_headers', 10, 4); /** * Answer the preflight itself. * * Core replies to OPTIONS with its own CORS logic, which does not know about * these routes; a preflight that comes back without the right Allow-Headers * kills the real request before it is ever sent. * * @param mixed $result * @param mixed $server * @param mixed $request * @return mixed */ function novasira_auth_answer_preflight($result, $server, $request) { if (!$request instanceof WP_REST_Request || $request->get_method() !== 'OPTIONS') { return $result; } $route = ltrim((string) $request->get_route(), '/'); if (!str_starts_with($route, 'novasira/v1/auth/') && !novasira_auth_is_own_mcp_route($route)) { return $result; } $response = new WP_REST_Response(null, 204); $response->header('Access-Control-Allow-Origin', '*'); $response->header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); $response->header('Access-Control-Allow-Headers', 'Authorization, Content-Type, Mcp-Session-Id, MCP-Protocol-Version, Last-Event-ID'); $response->header('Access-Control-Max-Age', '3600'); return $response; } add_filter('rest_pre_dispatch', 'novasira_auth_answer_preflight', 10, 3); // --------------------------------------------------------------------------- // Discovery // --------------------------------------------------------------------------- /** * RFC 9728 — Protected Resource Metadata. */ function novasira_auth_protected_resource_metadata(): WP_REST_Response { $data = [ 'resource' => novasira_auth_resource_id(), 'authorization_servers' => [novasira_auth_issuer()], 'scopes_supported' => array_keys(novasira_auth_scopes()), 'bearer_methods_supported' => ['header'], 'resource_name' => trim((string) get_bloginfo('name')) . ' — Novasira', ]; // Advisory. Not required by RFC 9728, but the field name is the one from // RFC 7591 client metadata, so a client that draws an icon beside the // connector has one to draw. Omitted entirely when there is no icon file — // a published 404 is worse than a missing field. $icon = novasira_auth_icon_url(); if ($icon !== '') { $data['logo_uri'] = $icon; } $response = new WP_REST_Response($data); // Discovery documents are fetched by clients on other origins. $response->header('Access-Control-Allow-Origin', '*'); $response->header('Cache-Control', 'public, max-age=3600'); return $response; } /** * RFC 8414 — Authorization Server Metadata. * * `issuer` must be byte-identical to the URL a client used to reach this * document, or the client is required to reject it. */ function novasira_auth_authorization_server_metadata(): WP_REST_Response { $data = [ 'issuer' => novasira_auth_issuer(), 'authorization_endpoint' => novasira_auth_authorize_url(), 'token_endpoint' => novasira_auth_rest_url('novasira/v1/auth/token'), 'registration_endpoint' => novasira_auth_rest_url('novasira/v1/auth/register'), 'revocation_endpoint' => novasira_auth_rest_url('novasira/v1/auth/revoke'), 'scopes_supported' => array_keys(novasira_auth_scopes()), 'response_types_supported' => ['code'], 'grant_types_supported' => ['authorization_code', 'refresh_token'], // PKCE is mandatory under OAuth 2.1, and `plain` is not offered. 'code_challenge_methods_supported' => ['S256'], 'token_endpoint_auth_methods_supported' => ['none'], // False, and deliberately stated rather than omitted. A Client ID // Metadata Document is a URL an unauthenticated caller picks and this // server then fetches; Novasira makes no outbound requests, so the // capability is absent. Saying so stops a client wasting a round trip // on it before falling back to registration. 'client_id_metadata_document_supported' => false, 'authorization_response_iss_parameter_supported' => true, ]; // Not in RFC 8414, but several clients look here before they fall back to // drawing the first letter of the name. $icon = novasira_auth_icon_url(); if ($icon !== '') { $data['logo_uri'] = $icon; } $response = new WP_REST_Response($data); $response->header('Access-Control-Allow-Origin', '*'); $response->header('Cache-Control', 'public, max-age=3600'); return $response; } // --------------------------------------------------------------------------- // Client registration (RFC 7591) // --------------------------------------------------------------------------- /** * The bucket a registration attempt counts against. * * REMOTE_ADDR alone is the obvious key and it is the wrong one: behind * Cloudflare or any reverse proxy REMOTE_ADDR is the proxy, so every AI client * registering through it shares one hourly bucket and the twenty-first person on * the site that hour gets an unexplained refusal inside their own client. * * The claimed client IP (CF-Connecting-IP, or the last X-Forwarded-For hop — the * one appended by the nearest proxy, hardest for the origin caller to choose) is * CONCATENATED onto REMOTE_ADDR, never substituted for it. That is the entire * spoofing analysis: a forged claim can only subdivide the forger's own transport * bucket, it can never land in anybody else's. A forwarded header still does not * identify a caller here — it only partitions one. */ function novasira_auth_registration_bucket(): string { $transport = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash((string) $_SERVER['REMOTE_ADDR'])) : ''; $claimed = ''; if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) { $claimed = sanitize_text_field(wp_unslash((string) $_SERVER['HTTP_CF_CONNECTING_IP'])); } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { $forwarded = sanitize_text_field(wp_unslash((string) $_SERVER['HTTP_X_FORWARDED_FOR'])); $hops = explode(',', $forwarded); $claimed = trim((string) end($hops)); } // A claim that is not a public IP partitions nothing — collapse to the plain // transport bucket rather than letting garbage mint fresh buckets. if ($claimed !== '' && !filter_var($claimed, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { $claimed = ''; } return $claimed === '' ? $transport : $transport . '|' . $claimed; } /** * Whether a registration may proceed, and if not, why. * * Two separate ceilings: a short window per caller, so one source cannot spray * registrations, and a total across the site, so a slow drip cannot either. * Registrations nobody ever signed in with are swept first, which is what keeps * the total from being reached by abandoned attempts rather than real clients. * * @return string 'ok', 'window' or 'cap'. */ function novasira_auth_registration_within_limits(): string { novasira_auth_prune_unused_clients(); $max_clients = (int) apply_filters('novasira_auth_max_clients', 200); if (count(novasira_auth_clients()) >= $max_clients) { // Under cap pressure, unconsented hour-old registrations lose their seat. // Registration's own contract is that an unused client may simply // register again, so an hour of patience is all it is owed — while a // 24-hour sweep alone would let a flood of junk registrations lock every // legitimate client out for a day. novasira_auth_prune_unused_clients(HOUR_IN_SECONDS); if (count(novasira_auth_clients()) >= $max_clients) { return 'cap'; } } $bucket = novasira_auth_registration_bucket(); $key = 'novasira_auth_reg_' . md5($bucket); $count = (int) get_transient($key); $per_window = (int) apply_filters('novasira_auth_registrations_per_hour', 20); if ($count >= $per_window) { return 'window'; } // The coarser ceiling on the transport address alone is what makes the // per-bucket limit unspoofable in aggregate: without it, rotating claimed // IPs would mint unlimited fresh buckets from a single connection. $transport = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash((string) $_SERVER['REMOTE_ADDR'])) : ''; $transport_key = 'novasira_auth_regt_' . md5($transport); $transport_count = (int) get_transient($transport_key); $per_transport = (int) apply_filters('novasira_auth_registrations_per_hour_per_transport', 60); if ($transport_count >= $per_transport) { return 'window'; } set_transient($key, $count + 1, HOUR_IN_SECONDS); set_transient($transport_key, $transport_count + 1, HOUR_IN_SECONDS); return 'ok'; } /** * Forget registrations that were never used to sign in. * * A client record only matters once somebody approves it. One registered an hour * ago and never carried through is either an abandoned attempt or noise, and * either way it should not hold a seat against the cap forever. */ function novasira_auth_prune_unused_clients(int $max_age = DAY_IN_SECONDS): void { $clients = novasira_auth_clients(); if ($clients === []) { return; } $granted = []; foreach (novasira_auth_all_granted_client_ids() as $id) { $granted[$id] = true; } $now = time(); $cutoff = $now - $max_age; $keep = []; $dated = false; foreach ($clients as $id => $client) { // A record made by hand is waiting to be pasted into an AI client, and // one that holds a token is in use by somebody. Neither is an abandoned // attempt, whatever its age, so neither is ever swept. if (!empty($client['manual']) || isset($granted[$id])) { $keep[$id] = $client; continue; } // Two spellings of one fact: a record made by hand writes `created`, // dynamic registration writes `registered`. Reading only `created` dates // every dynamic registration to zero — and zero then reads as "keep // forever", so the one kind of record an anonymous caller can create // becomes the one kind the sweep never removes. A patient caller could // then fill the cap with registrations nobody consented to and every // legitimate client would be refused from then on, permanently. Reading // both keys is what lets the cap recover. $age_from = (int) ($client['created'] ?? 0); if ($age_from === 0) { $age_from = (int) ($client['registered'] ?? 0); } if ($age_from === 0) { // Undatable, so there is no age to compare against the cutoff. // Stamping it on first sight rather than keeping it forever is the // difference between a record that ages out one window late and one // that holds a seat until an administrator notices. Deleting it // outright is wrong in the other direction: a record we cannot date // is not evidence of abuse, and may be a sign-in half finished. $client['registered'] = $now; $keep[$id] = $client; $dated = true; continue; } if ($age_from > $cutoff) { $keep[$id] = $client; } } if ($dated || count($keep) !== count($clients)) { update_option(NOVASIRA_AUTH_CLIENTS_OPTION, $keep, false); } } /** * Client ids that hold a live token for any user on this site. * * One prepared query against usermeta rather than a loop over get_users(): the * sweep above runs on an endpoint anonymous callers can reach, and a per-user * read there is a denial of service with extra steps. * * @return list */ function novasira_auth_all_granted_client_ids(): array { global $wpdb; /** @var array $rows */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one meta key across every user, no API exists. $rows = (array) $wpdb->get_col( $wpdb->prepare( 'SELECT meta_value FROM %i WHERE meta_key = %s', $wpdb->usermeta, NOVASIRA_AUTH_TOKENS_META ) ); $ids = []; foreach ($rows as $row) { $tokens = maybe_unserialize($row); if (!is_array($tokens)) { continue; } foreach ($tokens as $token) { $id = is_array($token) ? (string) ($token['client_id'] ?? '') : ''; if ($id !== '') { $ids[$id] = true; } } } return array_keys($ids); } /** * Dynamic client registration. * * Open by design — that is what the protocol requires, and it is how every * shipping AI client obtains an identity. Registration grants nothing on its * own: no data is reachable until a signed-in administrator approves a consent * screen, so the worst a flood of registrations achieves is unused option rows, * and the limits below cap even that. * * @return WP_REST_Response */ function novasira_auth_register_client(WP_REST_Request $request): WP_REST_Response { // Registration has to answer unauthenticated callers — that is the point of // it — which makes it the one endpoint anyone on the internet can write to. // Unmetered, it grows an option row without limit. $limit = novasira_auth_registration_within_limits(); if ($limit !== 'ok') { // Retry-After turns an opaque refusal into one the client can schedule // around, and the two causes get their own sentence because only one of // them is something the site owner can act on. $response = novasira_auth_error( 'temporarily_unavailable', $limit === 'cap' ? __('This site has reached its connected-client limit. An administrator can remove unused clients under Novasira → Connect.', 'novasira') : __('This site has received too many client registrations from your network in the past hour. Wait a while and try again.', 'novasira'), 429 ); $response->header('Retry-After', '3600'); return $response; } $body = $request->get_json_params(); if (!is_array($body)) { $body = $request->get_params(); } $redirect_uris = array_values(array_filter(array_map( static fn($uri): string => is_string($uri) ? trim($uri) : '', (array) ($body['redirect_uris'] ?? []) ))); if ($redirect_uris === []) { return novasira_auth_error('invalid_redirect_uri', __('At least one redirect_uri is required.', 'novasira'), 400); } foreach ($redirect_uris as $uri) { if (!novasira_auth_redirect_uri_is_valid($uri)) { return novasira_auth_error( 'invalid_redirect_uri', sprintf( /* translators: %s: the redirect URI that was refused */ __('redirect_uri must be https, a loopback address, or a custom scheme: %s', 'novasira'), $uri ), 400 ); } } // Validated as sent, then stored sanitized. The authorize and token steps // run the same sanitizer, so the exact-match comparison stays exact. $redirect_uris = array_map('novasira_auth_sanitize_redirect_uri', $redirect_uris); $client_id = 'novasira-client-' . wp_generate_password(24, false, false); $now = time(); $client = [ 'client_id' => $client_id, 'client_name' => sanitize_text_field((string) ($body['client_name'] ?? 'Unnamed client')), 'client_uri' => esc_url_raw((string) ($body['client_uri'] ?? '')), 'logo_uri' => esc_url_raw((string) ($body['logo_uri'] ?? '')), 'redirect_uris' => $redirect_uris, 'grant_types' => ['authorization_code', 'refresh_token'], 'response_types' => ['code'], 'token_endpoint_auth_method' => 'none', 'application_type' => sanitize_key((string) ($body['application_type'] ?? 'native')), 'registered' => $now, 'source' => 'dcr', ]; novasira_auth_save_client($client_id, $client); // No client_secret is issued and none is accepted: a public client that // proves possession with PKCE has nothing to keep secret, and a secret this // endpoint handed to an anonymous caller would be a credential in a log // waiting to happen. return new WP_REST_Response([ 'client_id' => $client_id, 'client_id_issued_at' => $now, 'client_name' => $client['client_name'], 'redirect_uris' => $redirect_uris, 'grant_types' => $client['grant_types'], 'response_types' => $client['response_types'], 'token_endpoint_auth_method' => 'none', ], 201); } // --------------------------------------------------------------------------- // Token endpoint // --------------------------------------------------------------------------- function novasira_auth_token_endpoint(WP_REST_Request $request): WP_REST_Response { $params = $request->get_params(); $grant = (string) ($params['grant_type'] ?? ''); if ($grant === 'refresh_token') { return novasira_auth_grant_refresh($params); } if ($grant === 'authorization_code') { return novasira_auth_grant_authorization_code($params); } return novasira_auth_error('unsupported_grant_type', __('Supported grants: authorization_code, refresh_token.', 'novasira'), 400); } /** * @param array $params */ function novasira_auth_grant_authorization_code(array $params): WP_REST_Response { $code = (string) ($params['code'] ?? ''); $verifier = (string) ($params['code_verifier'] ?? ''); $client_id = (string) ($params['client_id'] ?? ''); if ($code === '' || $verifier === '') { return novasira_auth_error('invalid_request', __('code and code_verifier are required.', 'novasira'), 400); } $payload = novasira_auth_consume_code($code); if ($payload === null) { // Covers expiry, an unknown code, and — because consumption deletes the // record — any attempt to replay one that was already exchanged. One // message for all three: distinguishing them would turn this endpoint // into an oracle for which codes once existed. return novasira_auth_error('invalid_grant', __('This authorization code is not valid. It may have expired or already been used.', 'novasira'), 400); } if (!hash_equals((string) ($payload['client_id'] ?? ''), $client_id)) { return novasira_auth_error('invalid_grant', __('This code was issued to a different client.', 'novasira'), 400); } // PKCE: the verifier must hash to the challenge recorded at authorize time. $expected = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); if (!hash_equals((string) ($payload['code_challenge'] ?? ''), $expected)) { return novasira_auth_error('invalid_grant', __('PKCE verification failed.', 'novasira'), 400); } // Required, not optional. OAuth 2.1 says a token request must repeat the // redirect_uri when the authorization request carried one, and ours always // does — leaving the check conditional on the client bothering to send it // means the one party who benefits from skipping it is the party who stole // the code. if (!hash_equals((string) ($payload['redirect_uri'] ?? ''), (string) ($params['redirect_uri'] ?? ''))) { return novasira_auth_error('invalid_grant', __('redirect_uri does not match the authorization request.', 'novasira'), 400); } $issued = novasira_auth_issue_token( (int) ($payload['user_id'] ?? 0), $client_id, (array) ($payload['scopes'] ?? []), (string) ($payload['resource'] ?? novasira_auth_resource_id()) ); return novasira_auth_token_response($issued, (array) ($payload['scopes'] ?? [])); } /** * @param array $params */ function novasira_auth_grant_refresh(array $params): WP_REST_Response { $refresh = (string) ($params['refresh_token'] ?? ''); $client_id = (string) ($params['client_id'] ?? ''); if ($refresh === '') { return novasira_auth_error('invalid_request', __('refresh_token is required.', 'novasira'), 400); } $issued = novasira_auth_refresh($refresh, $client_id); if ($issued === null) { return novasira_auth_error('invalid_grant', __('This refresh token is not valid. Sign in again to reconnect.', 'novasira'), 400); } $validated = novasira_auth_validate_token($issued['access_token']); $scopes = (array) ($validated['record']['scopes'] ?? []); return novasira_auth_token_response($issued, $scopes); } /** * @param array{access_token: string, refresh_token: string, expires_in: int} $issued * @param array $scopes */ function novasira_auth_token_response(array $issued, array $scopes): WP_REST_Response { $response = new WP_REST_Response([ 'access_token' => $issued['access_token'], 'token_type' => 'Bearer', 'expires_in' => $issued['expires_in'], 'refresh_token' => $issued['refresh_token'], 'scope' => implode(' ', $scopes), ]); // Tokens must never be cached by an intermediary. $response->header('Cache-Control', 'no-store'); $response->header('Pragma', 'no-cache'); return $response; } // --------------------------------------------------------------------------- // Revocation (RFC 7009) // --------------------------------------------------------------------------- function novasira_auth_revoke_endpoint(WP_REST_Request $request): WP_REST_Response { $token = (string) ($request->get_param('token') ?? ''); // The token is the credential here — this route is unauthenticated, so // presenting it has to mean holding it. Parsing alone is not enough: a token // string carries its user id and token id in the clear, so anybody who // learned those could otherwise hang up somebody else's connection by // sending them back with the secret replaced. foreach (['novasira_at_' => 'hash', 'novasira_rt_' => 'refresh_hash'] as $prefix => $field) { $parsed = novasira_auth_parse_token($token, $prefix); if ($parsed === null) { continue; } $record = novasira_auth_user_tokens($parsed['user_id'])[$parsed['token_id']] ?? null; if (is_array($record) && hash_equals((string) ($record[$field] ?? ''), hash('sha256', $parsed['secret']))) { novasira_auth_revoke_token($parsed['user_id'], $parsed['token_id']); } break; } // RFC 7009: always 200, even for an unknown token. Telling a caller whether // a token existed would make this endpoint an oracle. return new WP_REST_Response(null, 200); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * An OAuth-shaped error. The spec defines the body; WP_Error would serialise * WordPress's own shape instead, which clients do not parse. */ function novasira_auth_error(string $code, string $description, int $status): WP_REST_Response { $response = new WP_REST_Response([ 'error' => $code, 'error_description' => $description, ], $status); $response->header('Cache-Control', 'no-store'); return $response; } // --------------------------------------------------------------------------- // The authorize path, and the .well-known fallback // --------------------------------------------------------------------------- /** * The authorize endpoint gets a clean path of its own — see * novasira_auth_authorize_url() for why a query string there is a liability. */ function novasira_auth_add_rewrites(): void { add_rewrite_rule('^novasira-auth/authorize/?$', 'index.php?novasira_auth_authorize=1', 'top'); } add_action('init', 'novasira_auth_add_rewrites'); add_filter('query_vars', static function (array $vars): array { $vars[] = 'novasira_auth_authorize'; return $vars; }); /** * Every path a client may ask for a discovery document on. * * There is more than one because the specifications disagree about where the * document lives. RFC 8414 *inserts* the well-known segment between the host and * the issuer's path; OpenID Connect *appends* it. Both are in the wild, and the * insert form only reaches WordPress when it owns the domain root — so a * subdirectory install serving the insert form alone would be undiscoverable. * * The resource document has a form that carries the resource path, and that one * belongs to this server alone. The bare form does not: there is one of it per * site, and whichever plugin answers first owns it for every other plugin * installed. So the bare form is claimed only where it is the sole form that * reaches WordPress, and it is filterable for installs that want it back. * * @return array{resource: list, server: list} */ function novasira_auth_discovery_paths(): array { $home = rtrim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/'); $resource = (string) wp_parse_url(novasira_auth_resource_id(), PHP_URL_PATH); // The server document's address is derived from the ISSUER, not from the // site. They are the same string by default; they differ on an install that // moved the issuer to stop sharing that document with another plugin, and // then every form below moves with it rather than staying on contested // ground. See novasira_auth_issuer(). $iss = rtrim((string) wp_parse_url(novasira_auth_issuer(), PHP_URL_PATH), '/'); // Degenerate under plain permalinks: the REST route lives in a query string, // so there is no path to carry and the scoped form collapses onto the bare // one. Under a subdirectory install the scoped form lands on the domain root, // which WordPress does not own. Either way the bare form is then the only // address a client can reach, and refusing to serve it would trade one // site's conflict for another site's broken sign-in. $bare = '/.well-known/oauth-protected-resource'; $scoped = rtrim($bare . $resource, '/'); $resource_paths = []; if ($scoped !== $bare) { $resource_paths[] = $scoped; } if ((bool) apply_filters('novasira_auth_serve_bare_wellknown', $resource_paths === [] || $home !== '')) { $resource_paths[] = $home . $bare; } return [ 'resource' => array_values(array_unique($resource_paths)), 'server' => array_values(array_unique([ // OpenID Connect's append form works under a subdirectory, which is // why it is first: it is the one shape that never needs the root. $iss . '/.well-known/openid-configuration', $iss . '/.well-known/oauth-authorization-server', '/.well-known/oauth-authorization-server' . $iss, '/.well-known/openid-configuration' . $iss, ])), ]; } /** * Every discovery URL worth showing on the Connect screen, in the order a client * would try them, each carrying the field that proves the document is this * site's and how much a miss matters. * * URLs are built from the site's origin plus the computed path, never * home_url($path): on a subdirectory install that would prepend the subdirectory * a second time and name an address nothing serves. * * `required` — the URL named in the challenge header. Nothing works without it. * `any` — the authorization-server document has interchangeable forms, so * one of them answering is enough. * `optional` — informational; a fallback we may not even be serving. * * @return list */ function novasira_auth_discovery_urls(): array { $parts = wp_parse_url(home_url()); $origin = ($parts['scheme'] ?? 'https') . '://' . ($parts['host'] ?? '') . (isset($parts['port']) ? ':' . $parts['port'] : ''); $paths = novasira_auth_discovery_paths(); $resource = novasira_auth_resource_id(); $issuer = novasira_auth_issuer(); $rows = []; foreach ($paths['resource'] as $i => $path) { $rows[] = [ $path, 'resource', $resource, $i === 0 ? 'required' : 'optional', $i === 0 ? __('Endpoint metadata', 'novasira') : __('Endpoint metadata (root form)', 'novasira'), ]; } $labels = [ __('Sign-in metadata (OpenID form)', 'novasira'), __('Sign-in metadata (OAuth form)', 'novasira'), __('Sign-in metadata (root form)', 'novasira'), __('Sign-in metadata (root OpenID form)', 'novasira'), ]; foreach ($paths['server'] as $i => $path) { $rows[] = [$path, 'issuer', $issuer, 'any', $labels[$i] ?? __('Sign-in metadata', 'novasira')]; } // A shape some clients derive rather than read: the server document suffixed // with the resource path. The matcher answers it, so it is worth listing. $resource_path = rtrim((string) wp_parse_url($resource, PHP_URL_PATH), '/'); if ($resource_path !== '') { $rows[] = [ '/.well-known/oauth-authorization-server' . $resource_path, 'issuer', $issuer, 'optional', __('Sign-in metadata (resource-suffixed form)', 'novasira'), ]; } $out = []; $seen = []; foreach ($rows as [$path, $field, $expected, $requirement, $label]) { $url = $origin . $path; if (isset($seen[$url])) { continue; } $seen[$url] = true; $out[] = [ 'url' => $url, 'field' => $field, 'expected' => $expected, 'requirement' => $requirement, 'label' => $label, ]; } return $out; } /** * Which discovery document a well-known path is asking for, if any. * * An exact-path list answers only the variants the RFCs strictly require, and * real clients ask for more: hosted assistants derive extra shapes — the * authorization-server document suffixed with the resource path, the bare * protected-resource form — and every unanswered shape falls through to the * theme's 404 page, which on some hosts is slow enough that the client gives up * before trying the next shape. * * So: any recognisable variant of the SERVER document is answered, because that * document has one owner per issuer and answering it can take nothing from * anybody. The RESOURCE document is different — the bare form is shared ground. * A second OAuth-enabled MCP plugin on the same site publishes its own resource * document, so our suffixed forms are answered only for OUR resource path; every * other suffix falls through to whoever owns it, and the bare form stays * delegated to novasira_auth_discovery_paths(), the one place that owns that * decision. * * @return string|null 'resource', 'server', or null. */ function novasira_auth_match_discovery_path(string $path): ?string { $marker = '/.well-known/'; $at = strpos($path, $marker); if ($at === false) { return null; } $prefix = rtrim(substr($path, 0, $at), '/'); $rest = substr($path, $at + strlen($marker)); $home = rtrim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/'); $iss = rtrim((string) wp_parse_url(novasira_auth_issuer(), PHP_URL_PATH), '/'); if (!in_array($prefix, ['', $home, $iss], true)) { return null; } $resource = rtrim((string) wp_parse_url(novasira_auth_resource_id(), PHP_URL_PATH), '/'); // The suffixes this server's own document may carry. // // RFC 8414 puts the issuer's path in the suffix (the insert form) and OpenID // Connect puts it in the prefix (the append form), so an empty suffix means // "the issuer is whatever the prefix said". While the issuer is the site — // which it is unless somebody moved it — an empty suffix is ours wherever it // appears, because that document has one owner and answering for it takes // nothing from anybody. Once the issuer has been moved to stop sharing that // document with another OAuth server, an empty suffix is ours only under our // own prefix; the contested site-root form goes back to whoever wants it, // which is the entire point of moving. $suffixes = [$iss, $resource]; if ($iss === $home) { $suffixes[] = ''; $suffixes[] = $home; } elseif ($prefix === $iss) { $suffixes[] = ''; } foreach (['oauth-authorization-server', 'openid-configuration'] as $doc) { if ($rest !== $doc && !str_starts_with($rest, $doc . '/')) { continue; } $suffix = rtrim(substr($rest, strlen($doc)), '/'); if (in_array($suffix, $suffixes, true)) { return 'server'; } return null; } if ($rest === 'oauth-protected-resource' || str_starts_with($rest, 'oauth-protected-resource/')) { $suffix = rtrim(substr($rest, strlen('oauth-protected-resource')), '/'); if ($suffix !== '' && $suffix === $resource) { return 'resource'; } if ($suffix === '' && in_array($path, novasira_auth_discovery_paths()['resource'], true)) { return 'resource'; } } return null; } /** * Answer the discovery paths directly, before WordPress routes anything. * * Not a rewrite rule, and on `init` at priority 1 rather than later. Rules need * a flush to take effect, do nothing on a site using plain permalinks, and lose * to WordPress's canonical redirect, which appends a trailing slash to these * paths and turns the result into a 404 — silent enough to look like the feature * simply does not work. Matching the request path this early has none of those * failure modes, and it is before redirect_canonical can get involved. */ function novasira_auth_serve_discovery(): void { $uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash((string) $_SERVER['REQUEST_URI'])) : ''; $path = wp_parse_url($uri, PHP_URL_PATH); if (!is_string($path) || !str_contains($path, '/.well-known/')) { return; } $path = rtrim($path, '/'); if ($path === '') { return; } /** * Whether this install should answer the shared well-known paths at all. * * These paths belong to the site rather than to any one plugin. A site * running a second OAuth-enabled MCP server can hand them over here instead * of discovering the conflict as somebody else's broken sign-in. * * @param bool $serve Whether to answer. */ if (!(bool) apply_filters('novasira_auth_serve_discovery', true)) { return; } $which = novasira_auth_match_discovery_path($path); if ($which === null) { return; } // Browsers preflight cross-origin fetches of these documents. Answering // OPTIONS with the JSON body and no method headers reads as a failed // preflight and a blocked fetch. $method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper(sanitize_text_field(wp_unslash((string) $_SERVER['REQUEST_METHOD']))) : 'GET'; if ($method === 'OPTIONS') { if (!headers_sent()) { status_header(204); header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, OPTIONS'); header('Access-Control-Allow-Headers: Authorization, Content-Type, MCP-Protocol-Version'); header('Access-Control-Max-Age: 3600'); } exit; } $data = $which === 'resource' ? novasira_auth_protected_resource_metadata()->get_data() : novasira_auth_authorization_server_metadata()->get_data(); if (!headers_sent()) { status_header(200); header('Content-Type: application/json; charset=utf-8'); header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, OPTIONS'); header('Access-Control-Allow-Headers: Authorization, Content-Type, MCP-Protocol-Version'); header('Cache-Control: public, max-age=3600'); } wp_send_json($data); } add_action('init', 'novasira_auth_serve_discovery', 1); /** * Serve the consent screen from the clean path. * * `parse_request` rather than `template_redirect`: the consent screen is not a * template and must not wait for a theme to load, and an unauthenticated visitor * has to meet auth_redirect() before WordPress decides this is a 404. * * @param mixed $wp */ function novasira_auth_maybe_serve_authorize($wp): void { // The query var only exists when the rewrite rule matched, and rewrite rules // go stale: a failed flush, a migrated database, a host that regenerates // permalinks without our rule. The advertised URL then 404s with nothing // anywhere saying why. $wp->request is the raw path from REQUEST_URI, set // before any rule runs — matching it directly makes the sign-in URL // independent of rewrite state altogether. $matched = is_object($wp) && !empty($wp->query_vars['novasira_auth_authorize']); $raw = is_object($wp) ? trim((string) ($wp->request ?? ''), '/') : ''; if (!$matched && $raw !== 'novasira-auth/authorize') { return; } if (!is_user_logged_in()) { auth_redirect(); exit; } novasira_auth_handle_authorize(); exit; } add_action('parse_request', 'novasira_auth_maybe_serve_authorize'); /** * Flush rewrites once, when the rules change. * * Adding a rule does nothing until the rewrite cache is rebuilt, and an existing * install never reactivates the plugin — so without this the sign-in path would * 404 for everybody who upgraded rather than installed fresh. Version-gated, so * it costs one flush rather than one per request. */ add_action('init', static function (): void { $stamp = '1'; if (get_option('novasira_auth_rewrite_version') === $stamp) { return; } flush_rewrite_rules(false); update_option('novasira_auth_rewrite_version', $stamp, false); }, 99);