crates/gunnar-mcp/src/page.rs
//! **The consent page**: who is asking, where the code goes, the separate
//! write grant (unticked), and one passkey touch. Its script is a file of its
//! own (`/oauth/authorize.js`) so the console's CSP (`script-src 'self'`)
//! needs no new hash.
use axum::response::{IntoResponse, Response};
/// Escape text for HTML.
pub(crate) fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
out
}
const STYLE: &str = "body{font-family:system-ui,sans-serif;max-width:34rem;margin:3rem auto;padding:0 1rem;color:#222;background:#fafafa}\
main{background:#fff;border:1px solid #ddd;border-radius:8px;padding:1.5rem}\
h1{font-size:1.3rem}code{background:#f0f0f0;padding:0 .2rem}\
button{font-size:1rem;padding:.5rem 1rem;margin-right:.5rem}\
.err{color:#a00}.ok{color:#070}label{display:block;margin:1rem 0}";
/// The consent page for one waiting request.
pub(crate) fn consent(request: &str, client: &str, to: &str, offers_write: bool) -> String {
let write = if offers_write {
"<label><input type=\"checkbox\" id=\"write\"> Also allow it to <strong>write</strong> (comment on issues as you). Leave this off unless you need it.</label>"
} else {
"<input type=\"hidden\" id=\"write\" value=\"\">"
};
format!(
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
<title>Gunnar: connect an app</title><style>{STYLE}</style></head><body>\
<main id=\"consent\" data-request=\"{request}\">\
<h1>Connect {client} to Gunnar</h1>\
<p><strong>{client}</strong> asks to read the repositories you can see on this Gunnar, as you: repositories, files, commits, diffs, code search, issues and merge requests.</p>\
<p>It gets no shell, no network and no other address. It can never see more than you can.</p>\
{write}\
<p>After your passkey, you go back to <code>{to}</code>. The connection lasts at most a day, and you can end it at any time.</p>\
<p><button id=\"approve\" type=\"button\">Allow with my passkey</button><button id=\"deny\" type=\"button\">Deny</button></p>\
<p id=\"msg\" role=\"status\"></p></main>\
<script src=\"/oauth/authorize.js\"></script></body></html>",
request = escape(request),
client = escape(client),
to = escape(to),
)
}
/// A page that sends nobody anywhere: the request was not good enough to
/// redirect.
pub(crate) fn refusal(why: &str) -> String {
format!(
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Gunnar: cannot connect</title><style>{STYLE}</style></head>\
<body><main><h1>This app cannot connect</h1><p class=\"err\">{}</p></main></body></html>",
escape(why)
)
}
const SCRIPT: &str = r#"'use strict';
const main = document.getElementById('consent');
const request = main.dataset.request;
function show(text, kind) { const m = document.getElementById('msg'); m.textContent = text; m.className = kind || ''; }
function b64urlToBuf(s) { s = s.replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; const bin = atob(s), bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes.buffer; }
function bufToB64url(buf) { const bytes = new Uint8Array(buf); let s = ''; for (const b of bytes) s += String.fromCharCode(b); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
function said(e) {
if (e instanceof TypeError) return 'Gunnar did not answer. Check your connection and try again.';
if (typeof DOMException !== 'undefined' && e instanceof DOMException) return e.name === 'NotAllowedError' ? 'The passkey request was cancelled or timed out. Try again.' : 'Something went wrong with the passkey. Try again.';
return (e && e.message) || 'Something went wrong. Try again.';
}
async function post(path, body) {
const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.text()) || 'refused');
return res.json();
}
async function approve() {
const btn = document.getElementById('approve'); btn.disabled = true;
try {
show('Waiting for your passkey …');
const start = await post('/oauth/authorize/passkey', { request });
const pk = start.publicKey;
pk.challenge = b64urlToBuf(pk.challenge);
pk.allowCredentials = (pk.allowCredentials || []).map(c => ({ ...c, id: b64urlToBuf(c.id) }));
const cred = await navigator.credentials.get({ publicKey: pk });
const w = document.getElementById('write');
const done = await post('/oauth/authorize/approve', { request, write: !!(w && w.checked), ceremony_id: start.ceremony_id, id: cred.id, response: {
clientDataJSON: bufToB64url(cred.response.clientDataJSON),
authenticatorData: bufToB64url(cred.response.authenticatorData),
signature: bufToB64url(cred.response.signature) } });
show('Connected. Returning to the app …', 'ok');
location.assign(done.redirect);
} catch (e) { show(said(e), 'err'); btn.disabled = false; }
}
async function deny() {
try { const done = await post('/oauth/authorize/deny', { request }); show('Denied. The app gets nothing.', 'ok'); location.assign(done.redirect); }
catch (e) { show(said(e), 'err'); }
}
document.getElementById('approve').addEventListener('click', approve);
document.getElementById('deny').addEventListener('click', deny);
"#;
/// `GET /oauth/authorize.js`.
pub(crate) async fn script() -> Response {
(
[
(http::header::CONTENT_TYPE, "text/javascript; charset=utf-8"),
(http::header::CACHE_CONTROL, "no-cache"),
],
SCRIPT,
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_page_escapes_what_a_client_registered() {
let page = consent(
"r1",
"<img src=x onerror=alert(1)>",
"https://claude.ai/api/mcp/auth_callback",
false,
);
assert!(!page.contains("<img"), "{page}");
assert!(page.contains("<img"));
assert!(
!page.contains("type=\"checkbox\""),
"no write box unless write was asked for"
);
assert!(
consent("r1", "Claude", "x", true).contains("type=\"checkbox\" id=\"write\">"),
"the write box is offered unticked"
);
assert!(
!page.contains("<script>"),
"no inline script: the CSP allows only 'self'"
);
}
}