'disconnected'])); exit; } /** * Create a client id by hand. * * Registration normally happens on its own: the client asks, the site answers. * That call comes from the client's own servers, so a firewall that blocks * unknown POSTs stops it, and some clients ask for a client id up front rather * than registering at all. Either way the user is otherwise stuck with nothing to * type in. This gives them the two values those forms ask for. */ function novasira_auth_handle_add_client(): void { if (!current_user_can(novasira_auth_consent_cap())) { wp_die(esc_html__('You do not have permission to do this.', 'novasira')); } check_admin_referer('novasira_auth_add_client'); $name = isset($_POST['client_name']) ? sanitize_text_field(wp_unslash((string) $_POST['client_name'])) : ''; $raw = isset($_POST['redirect_uris']) ? sanitize_textarea_field(wp_unslash((string) $_POST['redirect_uris'])) : ''; $uris = []; foreach (preg_split('/[\r\n,]+/', $raw) ?: [] as $line) { $line = trim((string) $line); if ($line !== '') { $uris[] = $line; } } $error = ''; if ($name === '') { $error = 'name'; } elseif ($uris === []) { $error = 'uris'; } else { foreach ($uris as $uri) { if (!novasira_auth_redirect_uri_is_valid($uri)) { $error = 'invalid'; break; } } } if ($error !== '') { wp_safe_redirect(novasira_auth_connect_url(['novasira_auth_client_error' => $error]) . '#nv-auth-manual'); exit; } $client_id = 'novasira-client-' . wp_generate_password(24, false, false); novasira_auth_save_client($client_id, [ 'client_id' => $client_id, 'client_name' => $name, 'redirect_uris' => $uris, 'created' => time(), // Marks it exempt from the sweep that clears registrations nobody used: // one created by hand is waiting to be pasted somewhere, which can // reasonably take longer than a day. 'manual' => true, ]); wp_safe_redirect(novasira_auth_connect_url(['novasira_auth_client_added' => $client_id]) . '#nv-auth-manual'); exit; } function novasira_auth_handle_delete_client(): void { if (!current_user_can(novasira_auth_consent_cap())) { wp_die(esc_html__('You do not have permission to do this.', 'novasira')); } $client_id = isset($_POST['client_id']) ? sanitize_text_field(wp_unslash((string) $_POST['client_id'])) : ''; check_admin_referer('novasira_auth_delete_client_' . $client_id); novasira_auth_forget_client($client_id); wp_safe_redirect(novasira_auth_connect_url(['novasira_auth_client_deleted' => '1']) . '#nv-auth-manual'); exit; } /** * Switch the advertised sign-in address between the pretty path and the * admin-post form. * * Decided by hand rather than probed. The honest way to tell whether * /novasira-auth/authorize is reachable is to request it, and this plugin makes * no outbound requests — so instead the Connect screen prints the address, says * what a blocked one looks like, and offers this switch. Flipping it changes the * metadata document, which every client re-reads at every sign-in, so it heals * them all on their next attempt without anybody editing a client config. */ function novasira_auth_handle_set_fallback(): void { if (!current_user_can(novasira_auth_consent_cap())) { wp_die(esc_html__('You do not have permission to do this.', 'novasira')); } check_admin_referer('novasira_auth_set_fallback'); $on = isset($_POST['novasira_auth_fallback']) && sanitize_key(wp_unslash((string) $_POST['novasira_auth_fallback'])) === '1'; update_option('novasira_auth_authorize_fallback', $on ? 1 : 0, false); wp_safe_redirect(novasira_auth_connect_url(['novasira_auth_result' => $on ? 'fallback-on' : 'fallback-off']) . '#nv-auth-trouble'); exit; } // --------------------------------------------------------------------------- // Readers // --------------------------------------------------------------------------- /** * Every live connection for the current user, newest first. * * @return array> */ function novasira_auth_user_connections(): array { $user_id = get_current_user_id(); novasira_auth_prune_tokens($user_id); $rows = []; foreach (novasira_auth_user_tokens($user_id) as $id => $record) { $rows[] = [ 'id' => (string) $id, // Recorded with the token, so a connection whose client record has // since been deleted still has a name. Resolving is the fallback for // tokens minted before that was true. 'name' => trim((string) ($record['client_name'] ?? '')) !== '' ? (string) $record['client_name'] : novasira_auth_client_display_name((string) ($record['client_id'] ?? '')), 'scopes' => (array) ($record['scopes'] ?? []), 'created' => (int) ($record['created'] ?? 0), 'last_used' => (int) ($record['last_used'] ?? 0), 'last_ip' => (string) ($record['last_ip'] ?? ''), 'expires' => (int) ($record['expires'] ?? 0), ]; } usort($rows, static fn(array $a, array $b): int => $b['created'] <=> $a['created']); return $rows; } /** * Client ids created by hand, newest first. * * @return array> */ function novasira_auth_manual_clients(): array { $rows = []; foreach (novasira_auth_clients() as $id => $client) { if (empty($client['manual'])) { continue; } $rows[] = [ 'id' => (string) $id, 'name' => (string) ($client['client_name'] ?? ''), 'redirect_uris' => (array) ($client['redirect_uris'] ?? []), 'created' => (int) ($client['created'] ?? 0), ]; } usort($rows, static fn(array $a, array $b): int => $b['created'] <=> $a['created']); return $rows; } /** * Whether signing in can work here at all. * * Fails loudly rather than silently: without HTTPS no hosted client will complete * the flow, and a card that simply never works is worse than one that says why. * * @return array{ok: bool, reason: string, message: string} */ function novasira_auth_availability(): array { if (wp_parse_url(home_url(), PHP_URL_SCHEME) !== 'https') { $local = function_exists('wp_get_environment_type') && wp_get_environment_type() === 'local'; return [ 'ok' => false, 'reason' => 'no_https', 'message' => $local ? __('Signing in needs HTTPS. This looks like a local environment — use an application password here, or serve the site over HTTPS to test the full flow.', 'novasira') : __('Signing in needs HTTPS. Install a certificate and set your site address to https:// — worth doing regardless, since credentials cross the network in the clear without it.', 'novasira'), ]; } return ['ok' => true, 'reason' => 'available', 'message' => '']; } /** * Could an assistant running in somebody else's cloud reach this site? * * Browser assistants connect from the vendor's servers, not from the machine the * admin screen is open on. A hostname only this machine can resolve is therefore * unusable for them however well the site works locally, so those options are * withdrawn rather than left to fail with a timeout nobody can interpret. */ function novasira_auth_host_is_cloud_reachable(): bool { $host = strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST)); if ($host === '') { return false; } // Suffixes that resolve only through a local hosts file or mDNS. foreach (['.local', '.test', '.localhost', '.invalid', '.example'] as $suffix) { if ($host === 'localhost' || str_ends_with($host, $suffix)) { return false; } } // A name with no dot at all cannot be a public domain. if (!str_contains($host, '.') && !str_contains($host, ':')) { return false; } $literal = trim($host, '[]'); if (filter_var($literal, FILTER_VALIDATE_IP)) { return (bool) filter_var($literal, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); } return true; } /** The name a connector carries in the assistant's own list. */ function novasira_auth_connector_name(): string { $site = trim((string) get_bloginfo('name')); return $site !== '' ? 'Novasira — ' . $site : 'Novasira'; } /** * Open the add-connector dialog with the name and address already filled in. * * The user still reviews and confirms; nothing is added on their behalf, and no * credential rides along — there is none to send. */ function novasira_auth_connector_link(string $endpoint, string $name): string { return 'https://claude.ai/customize/connectors?modal=add-custom-connector' . '&connectorName=' . rawurlencode($name) . '&connectorUrl=' . rawurlencode($endpoint); } // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- /** * The connections list. */ function novasira_auth_render_connections(): void { $connections = novasira_auth_user_connections(); $catalogue = novasira_auth_scopes(); if ($connections === []) { echo '

' . esc_html__( 'Nothing is signed in to this site as you yet. Anything that signs in appears here, with what it was allowed to do and when it last called.', 'novasira' ) . '

'; return; } echo '

' . esc_html__( 'Everything currently signed in to this site as you. Revoking takes effect on the very next request the application makes.', 'novasira' ) . '

'; echo '
'; foreach ($connections as $conn) { echo '
'; echo '
'; echo '' . esc_html((string) $conn['name']) . ''; $connected = sprintf( /* translators: %s: a human-readable time difference, such as "5 minutes" */ __('%s ago', 'novasira'), human_time_diff((int) $conn['created']) ); $used = (int) $conn['last_used'] > 0 ? sprintf( /* translators: %s: a human-readable time difference, such as "2 hours" */ __('last used %s ago', 'novasira'), human_time_diff((int) $conn['last_used']) ) : __('never used', 'novasira'); echo '' . esc_html(sprintf( /* translators: 1: how long ago it was connected, 2: when it was last used */ __('Connected %1$s · %2$s', 'novasira'), $connected, $used )) . ''; echo ''; foreach ((array) $conn['scopes'] as $slug) { $meta = $catalogue[$slug] ?? null; $class = ($meta !== null && $meta['danger']) ? ' is-danger' : ''; echo '' . esc_html((string) ($meta['label'] ?? $slug)) . ''; } echo ''; echo '
'; echo '
'; wp_nonce_field('novasira_auth_revoke_' . (string) $conn['id']); echo ''; echo ''; echo ''; echo '
'; echo '
'; } echo '
'; } /** * Open a folded panel: an icon, a title, one line saying what is inside. * * @param string $icon Inner SVG markup for a 24×24 stroke icon (literal, not input). */ function novasira_disclosure_open(string $id, string $title, string $desc, string $icon, bool $open = false): void { echo '
'; echo ''; echo ''; echo '' . esc_html($title) . '' . '' . esc_html($desc) . ''; echo ''; echo '
'; } function novasira_disclosure_close(): void { echo '
'; } /** One titled block inside a panel. */ function novasira_disclosure_section(string $title): void { echo '

' . esc_html($title) . '

'; } /** * Client ids made by hand — collapsed, because most people never need one. */ function novasira_auth_render_manual_clients(): void { $clients = novasira_auth_manual_clients(); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only // notice state, carried back by the nonce-checked redirect that set it. $added = isset($_GET['novasira_auth_client_added']) ? sanitize_text_field(wp_unslash((string) $_GET['novasira_auth_client_added'])) : ''; $error = isset($_GET['novasira_auth_client_error']) ? sanitize_key((string) wp_unslash((string) $_GET['novasira_auth_client_error'])) : ''; // phpcs:enable novasira_disclosure_open( 'nv-auth-manual', __('A client that cannot register itself', 'novasira'), __('Create a client ID by hand for a tool that asks for one, or where registration is blocked.', 'novasira'), '', $added !== '' || $error !== '' ); echo '

' . esc_html__( 'Most clients register on their own and you never see this. Some ask for a client ID up front, and on a few hosts the registration call is blocked before it arrives. Create one here and paste the two values into the client.', 'novasira' ) . '

'; if ($error !== '') { $messages = [ 'name' => __('Give it a name, so the connections list has something to call it.', 'novasira'), 'uris' => __('At least one return address is required — the client\'s own documentation names it.', 'novasira'), 'invalid' => __('A return address must be https, a loopback address such as http://127.0.0.1:5000/callback, or a custom scheme such as myapp://callback.', 'novasira'), ]; novasira_echo_notice('error', (string) ($messages[$error] ?? __('That client could not be created.', 'novasira'))); } if ($added !== '') { novasira_echo_notice('success', __('Client created. Paste the ID below into your client — there is no secret to copy, because this server issues none.', 'novasira')); } echo '
'; wp_nonce_field('novasira_auth_add_client'); echo ''; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; if ($clients !== []) { novasira_disclosure_section(__('Client IDs created here', 'novasira')); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ($clients as $client) { echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html__('Name', 'novasira') . '' . esc_html__('Client ID', 'novasira') . '' . esc_html__('Return addresses', 'novasira') . '' . esc_html__('Actions', 'novasira') . '
' . esc_html((string) $client['name']) . '' . esc_html((string) $client['id']) . ' ' . ''; foreach ((array) $client['redirect_uris'] as $uri) { echo '' . esc_html((string) $uri) . '
'; } echo '
'; wp_nonce_field('novasira_auth_delete_client_' . (string) $client['id']); echo ''; echo ''; echo ''; echo '
'; } novasira_disclosure_close(); } /** * The addresses a client reads, and the one switch for a host that blocks one. * * Printed rather than probed. Asking whether these answer means requesting them, * and this plugin makes no outbound requests — so the addresses are links the * administrator can open in the browser they are already using, which is a better * test anyway: it comes from outside PHP, through whatever WAF or server rule * would block a real client. */ function novasira_auth_render_diagnostics(): void { novasira_disclosure_open( 'nv-auth-trouble', __('If a client cannot find the sign-in service', 'novasira'), __('Check the addresses a client reads, switch the sign-in address, or pass the Authorization header through.', 'novasira'), '' ); novasira_disclosure_section(__('The addresses a client reads', 'novasira')); echo '

' . esc_html__( 'Open each address below in this browser. A client reads them in this order, and each should answer with JSON rather than a 404 or a 403. The first one is the address named in the challenge header, so nothing works without it; the rest are fallbacks for clients that look in the older places.', 'novasira' ) . '

'; echo ''; echo ''; echo ''; echo ''; echo ''; $weights = [ 'required' => __('Required', 'novasira'), 'any' => __('One of these', 'novasira'), 'optional' => __('Optional', 'novasira'), ]; foreach (novasira_auth_discovery_urls() as $probe) { echo ''; echo ''; echo ''; $weight_class = $probe['requirement'] === 'required' ? 'is-brand' : ($probe['requirement'] === 'any' ? 'is-ok' : 'is-muted'); echo ''; echo ''; } echo '
' . esc_html__('Document', 'novasira') . '' . esc_html__('Address', 'novasira') . '' . esc_html__('Matters', 'novasira') . '
' . esc_html((string) $probe['label']) . '' . esc_html((string) $probe['url']) . '' . esc_html((string) ($weights[$probe['requirement']] ?? $probe['requirement'])) . '
'; echo '

' . esc_html__( 'The REST address below is the one the challenge header actually names, and it is the path that works even on a host that intercepts /.well-known/ for certificate checks.', 'novasira' ) . '

'; novasira_echo_code(novasira_auth_metadata_url(), __('Copy', 'novasira')); echo '

' . esc_html__( 'If another plugin on this site also serves an MCP server over OAuth, the two of you publish a sign-in document at the same address and whichever answers first answers for both — a client would then register with the wrong one and arrive here holding a key to a different door. One line in a snippet or in your theme\'s functions.php moves ours somewhere only we own:', 'novasira' ) . '

'; novasira_echo_code("add_filter('novasira_auth_issuer', fn() => home_url('/novasira-auth'));", __('Copy', 'novasira')); /* ── The authorize address, and the switch ─────────────────────────── */ $fallback = (bool) get_option('novasira_auth_authorize_fallback'); novasira_disclosure_section(__('The sign-in address', 'novasira')); echo '

' . esc_html__( 'Where a client sends you to approve a connection. Open it: signed in as an administrator you should see an error about a missing client, which proves the address resolves. A 404 or a 403 from your host means it does not, and the switch below publishes the longer form instead.', 'novasira' ) . '

'; novasira_echo_code(novasira_auth_authorize_url(), __('Copy', 'novasira')); echo '
'; wp_nonce_field('novasira_auth_set_fallback'); echo ''; echo ''; echo '

'; echo '
'; echo '

' . esc_html__( 'Clients re-read the metadata document at every sign-in, so switching this fixes all of them on their next attempt. The short form is preferred where it works: a few clients append their own query string to the address, which breaks the longer one.', 'novasira' ) . '

'; novasira_auth_render_header_fix(); novasira_disclosure_close(); } /** * The notice state the handlers above redirect back with. */ function novasira_auth_render_result_notice(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only // notice state, carried back by the nonce-checked redirect that set it. $result = isset($_GET['novasira_auth_result']) ? sanitize_key((string) wp_unslash((string) $_GET['novasira_auth_result'])) : ''; $deleted = isset($_GET['novasira_auth_client_deleted']) ? sanitize_key((string) wp_unslash((string) $_GET['novasira_auth_client_deleted'])) : ''; $header = isset($_GET['novasira_auth_header_fix']) ? sanitize_key((string) wp_unslash((string) $_GET['novasira_auth_header_fix'])) : ''; // phpcs:enable $messages = [ 'disconnected' => ['success', __('Connection revoked. It stops working on its very next request.', 'novasira')], 'fallback-on' => ['success', __('The longer sign-in address is now published. Reconnect your client to pick it up.', 'novasira')], 'fallback-off' => ['success', __('The short sign-in address is published again.', 'novasira')], ]; if (isset($messages[$result])) { novasira_echo_notice($messages[$result][0], $messages[$result][1]); } if ($deleted !== '') { novasira_echo_notice('success', __('Client ID deleted. Anything already signed in with it keeps working until you revoke it above.', 'novasira')); } if ($header === 'applied') { novasira_echo_notice('success', __('The Authorization header rule was written to .htaccess. Try connecting again.', 'novasira')); } elseif ($header === 'failed') { novasira_echo_notice('error', __('The rule could not be written. Your .htaccess is not writable, or this is not an Apache-compatible server — ask your host to pass the Authorization header through to PHP.', 'novasira')); } }