chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
MCP over HTTP: show where sign-in goes; revoke and rotate tokens
The consent page now names the host the assistant's redirect goes to. A client's name is whatever it registered with, so "Connect Claude" alone let anyone register as Claude, redirect to their own host, and take a listed user's code. Tokens could not be revoked short of taking a DID off the list. Each client's tokens for an account are now good only from a time kept on disk under the account's directory. A sign-in, a refresh, or a revocation at the new /mcp/revoke (RFC 7009) moves it on, so only the newest pair works and a refresh token works once. Tokens from before carry no time and keep working until the client next refreshes.
Showing 2 changed files with 208 additions and 48 deletions
crates/knotless-mcp/src/http.rs+102−25
| @@ -11,8 +11,11 @@ | ||
//! | ||
//! What it hands out is signed rather than stored: a registered client is | ||
//! its own ID, and a token holds whom it is for. Taking a DID off the list | ||
//! stops its tokens at their next use. Each account's sessions and mirrors | ||
//! live in a directory of their own, so no one reads as anyone else. | ||
//! stops its tokens at their next use. Each client's tokens for an account | ||
//! are good only from a time kept on disk, which every sign-in, refresh and | ||
//! revocation (RFC 7009) moves on: only the newest pair works, and a | ||
//! refresh token works once. Each account's sessions and mirrors live in a | ||
//! directory of their own, so no one reads as anyone else. | ||
| ||
use crate::Server; | ||
use axum::extract::{Form, Query, Request, State}; | ||
| @@ -40,6 +43,7 @@ use serde::de::DeserializeOwned; | ||
use serde::{Deserialize, Serialize}; | ||
use serde_json::{Value, json}; | ||
use std::collections::HashMap; | ||
use std::path::PathBuf; | ||
use std::sync::{Arc, Mutex}; | ||
use std::time::{Duration, Instant}; | ||
| ||
| @@ -90,11 +94,14 @@ struct Client { | ||
secret: bool, | ||
} | ||
| ||
/// Whom a token is for. | ||
#[derive(Serialize, Deserialize)] | ||
/// Whom a token is for, and when it was made. | ||
#[derive(Deserialize)] | ||
struct Grant { | ||
sub: String, | ||
client_id: String, | ||
/// In microseconds. Tokens from before revocation carry none. | ||
#[serde(default)] | ||
issued: u64, | ||
} | ||
| ||
struct Pending { | ||
| @@ -197,6 +204,44 @@ impl Mcp { | ||
self.open("client", id) | ||
} | ||
| ||
/// Where the time `client_id`'s tokens for `did` are good from is kept. | ||
fn revoked(&self, did: &str, client_id: &str) -> PathBuf { | ||
self.ctx_for(did) | ||
.env | ||
.config_dir | ||
.join("revoked") | ||
.join(oauth::sha256(client_id)) | ||
} | ||
| ||
/// When `client_id`'s tokens for `did` are good from, in microseconds. | ||
fn not_before(&self, did: &str, client_id: &str) -> u64 { | ||
std::fs::read_to_string(self.revoked(did, client_id)) | ||
.ok() | ||
.and_then(|at| at.parse().ok()) | ||
.unwrap_or(0) | ||
} | ||
| ||
/// Voids `client_id`'s tokens for `did` made before `at`. | ||
fn revoke_before(&self, did: &str, client_id: &str, at: u64) -> Result<(), Refusal> { | ||
let path = self.revoked(did, client_id); | ||
std::fs::create_dir_all(path.parent().expect("a file is in a directory")) | ||
.and_then(|()| std::fs::write(&path, at.to_string())) | ||
.map_err(|e| { | ||
Refusal( | ||
StatusCode::INTERNAL_SERVER_ERROR, | ||
"server_error", | ||
format!("Keeping a revocation: {e}"), | ||
) | ||
}) | ||
} | ||
| ||
/// Whether a token is still good: for an account on the list, and not | ||
/// revoked or replaced since it was made. | ||
fn live(&self, grant: &Grant) -> bool { | ||
self.dids.contains(&grant.sub) | ||
&& grant.issued >= self.not_before(&grant.sub, &grant.client_id) | ||
} | ||
| ||
/// `redirect`, with `values`, the state and who answers. | ||
fn back(&self, redirect: &str, state: Option<&str>, values: &[(&str, &str)]) -> String { | ||
let mut url = reqwest::Url::parse(redirect).expect("a registered redirect is a URL"); | ||
| @@ -290,14 +335,16 @@ impl Mcp { | ||
| ||
fn tokens(&self, form: &HashMap<String, String>) -> Result<Value, Refusal> { | ||
let client_id = self.authenticate(form)?; | ||
let did = match form.get("grant_type").map(String::as_str) { | ||
Some("authorization_code") => self.redeem(&client_id, form)?, | ||
Some("refresh_token") => form | ||
.get("refresh_token") | ||
.and_then(|t| self.open::<Grant>("refresh", t)) | ||
.filter(|g| g.client_id == client_id) | ||
.map(|g| g.sub) | ||
.ok_or_else(|| invalid_grant("Invalid refresh token"))?, | ||
let (did, issued) = match form.get("grant_type").map(String::as_str) { | ||
Some("authorization_code") => (self.redeem(&client_id, form)?, None), | ||
Some("refresh_token") => { | ||
let grant = form | ||
.get("refresh_token") | ||
.and_then(|t| self.open::<Grant>("refresh", t)) | ||
.filter(|g| g.client_id == client_id) | ||
.ok_or_else(|| invalid_grant("Invalid refresh token"))?; | ||
(grant.sub, Some(grant.issued)) | ||
} | ||
other => { | ||
return Err(Refusal( | ||
StatusCode::BAD_REQUEST, | ||
| @@ -311,11 +358,15 @@ impl Mcp { | ||
"{did} may no longer use this server" | ||
))); | ||
} | ||
let grant = Grant { | ||
sub: did, | ||
client_id, | ||
}; | ||
let mut access = serde_json::to_value(&grant).expect("a grant serializes"); | ||
// A refresh token works once: the tokens it made replaced it. | ||
if issued.is_some_and(|at| at < self.not_before(&did, &client_id)) { | ||
return Err(invalid_grant("This refresh token was used or revoked")); | ||
} | ||
// These replace every earlier token this client has for the account. | ||
let issued = keys::now_micros(); | ||
self.revoke_before(&did, &client_id, issued)?; | ||
let grant = json!({ "sub": did, "client_id": client_id, "issued": issued }); | ||
let mut access = grant.clone(); | ||
access["aud"] = self.resource().into(); | ||
Ok(json!({ | ||
"access_token": self.sign("access", ACCESS_TTL, &access), | ||
| @@ -358,11 +409,12 @@ pub fn router(mcp: Arc<Mcp>) -> Router { | ||
.route("/mcp/authorize", get(authorize).post(sign_in)) | ||
.route(CALLBACK, get(callback)) | ||
.route("/mcp/token", post(token)) | ||
.route("/mcp/revoke", post(revoke)) | ||
.with_state(mcp) | ||
.merge(guarded) | ||
} | ||
| ||
/// Lets in a request with a token for an account still on the list. | ||
/// Lets in a request with a token that is still good. | ||
async fn bearer(State(mcp): State<Arc<Mcp>>, mut req: Request, next: Next) -> Response { | ||
let grant = req | ||
.headers() | ||
| @@ -370,7 +422,7 @@ async fn bearer(State(mcp): State<Arc<Mcp>>, mut req: Request, next: Next) -> Re | ||
.and_then(|v| v.to_str().ok()) | ||
.and_then(|v| v.strip_prefix("Bearer ")) | ||
.and_then(|t| mcp.open::<Grant>("access", t)) | ||
.filter(|g| mcp.dids.contains(&g.sub)); | ||
.filter(|g| mcp.live(g)); | ||
let Some(grant) = grant else { | ||
let metadata = format!("{}/.well-known/oauth-protected-resource{MCP}", mcp.url); | ||
return ( | ||
| @@ -442,10 +494,12 @@ async fn server_metadata(State(mcp): State<Arc<Mcp>>) -> Json<Value> { | ||
"authorization_endpoint": format!("{url}/mcp/authorize"), | ||
"token_endpoint": format!("{url}/mcp/token"), | ||
"registration_endpoint": format!("{url}/mcp/register"), | ||
"revocation_endpoint": format!("{url}/mcp/revoke"), | ||
"response_types_supported": ["code"], | ||
"grant_types_supported": ["authorization_code", "refresh_token"], | ||
"code_challenge_methods_supported": ["S256"], | ||
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"], | ||
"revocation_endpoint_auth_methods_supported": ["none", "client_secret_post"], | ||
"authorization_response_iss_parameter_supported": true, | ||
})) | ||
} | ||
| @@ -577,7 +631,7 @@ async fn authorize( | ||
challenge: challenge.clone(), | ||
}; | ||
let signed = mcp.sign("ask", SIGN_IN.as_secs(), &ask); | ||
page(StatusCode::OK, &form(&ask.name, &signed, None)) | ||
page(StatusCode::OK, &form(&ask, &signed, None)) | ||
} | ||
| ||
#[derive(Deserialize)] | ||
| @@ -600,7 +654,7 @@ async fn sign_in(State(mcp): State<Arc<Mcp>>, Form(f): Form<SignIn>) -> Response | ||
let problem = format!("No account answers to {handle}."); | ||
return page( | ||
StatusCode::BAD_REQUEST, | ||
&form(&ask.name, &f.ask, Some(&problem)), | ||
&form(&ask, &f.ask, Some(&problem)), | ||
); | ||
}; | ||
if !mcp.dids.contains(&id.did) { | ||
| @@ -681,6 +735,24 @@ async fn token(State(mcp): State<Arc<Mcp>>, Form(form): Form<HashMap<String, Str | ||
} | ||
} | ||
| ||
/// Revokes a token (RFC 7009), and with it every other this client has for | ||
/// the account. A token that is not good here is answered the same. | ||
async fn revoke( | ||
State(mcp): State<Arc<Mcp>>, | ||
Form(form): Form<HashMap<String, String>>, | ||
) -> Result<StatusCode, Refusal> { | ||
let client_id = mcp.authenticate(&form)?; | ||
let token = form.get("token").map(String::as_str).unwrap_or_default(); | ||
let grant = mcp | ||
.open::<Grant>("access", token) | ||
.or_else(|| mcp.open::<Grant>("refresh", token)) | ||
.filter(|g| g.client_id == client_id); | ||
if let Some(grant) = grant { | ||
mcp.revoke_before(&grant.sub, &client_id, keys::now_micros())?; | ||
} | ||
Ok(StatusCode::OK) | ||
} | ||
| ||
fn escape(text: &str) -> String { | ||
text.replace('&', "&") | ||
.replace('<', "<") | ||
| @@ -688,18 +760,23 @@ fn escape(text: &str) -> String { | ||
.replace('"', """) | ||
} | ||
| ||
/// Asks who the user is, for the assistant `name`. | ||
fn form(name: &str, ask: &str, problem: Option<&str>) -> String { | ||
let name = escape(name); | ||
/// Asks who the user is, for the assistant `ask` is from. Its name is | ||
/// whatever it registered with, so the page also says where signing in | ||
/// sends the user: that, and not the name, says who the assistant is. | ||
fn form(ask: &Ask, signed: &str, problem: Option<&str>) -> String { | ||
let name = escape(&ask.name); | ||
let to = reqwest::Url::parse(&ask.redirect_uri).expect("a registered redirect is a URL"); | ||
let to = escape(to.host_str().unwrap_or_default()); | ||
let problem = problem | ||
.map(|p| format!("<p class=problem>{}", escape(p))) | ||
.unwrap_or_default(); | ||
format!( | ||
"<h1>Connect {name} to knotless</h1>\ | ||
<p>{name} asks to work on your repositories as you. Sign in with your atproto account to let it.\ | ||
<p>Signing in sends you back to <strong>{to}</strong>. Any assistant can call itself anything, so if that isn't where you meant to connect, stop here.\ | ||
{problem}\ | ||
<form method=post action=/mcp/authorize>\ | ||
<input type=hidden name=ask value=\"{ask}\">\ | ||
<input type=hidden name=ask value=\"{signed}\">\ | ||
<label>Handle <input name=handle autocomplete=username placeholder=you.bsky.social required autofocus></label>\ | ||
<button>Sign in</button></form>" | ||
) | ||
crates/knotless-mcp/tests/http.rs+106−23
| @@ -110,6 +110,16 @@ fn token(s: &Server, form: &[(&str, &str)]) -> (u16, Value) { | ||
(res.status().as_u16(), res.json().unwrap()) | ||
} | ||
| ||
fn revoke(s: &Server, form: &[(&str, &str)]) -> u16 { | ||
client() | ||
.post(format!("{}/mcp/revoke", s.base)) | ||
.form(form) | ||
.send() | ||
.unwrap() | ||
.status() | ||
.as_u16() | ||
} | ||
| ||
/// The sign-in form's signed request, from its page. | ||
fn ask(page: &str) -> String { | ||
let at = page.find("name=ask value=\"").unwrap() + 16; | ||
| @@ -186,6 +196,10 @@ fn an_assistant_signs_in_and_works_as_its_user() { | ||
.json() | ||
.unwrap(); | ||
assert_eq!(meta["token_endpoint"], format!("{}/mcp/token", srv.url)); | ||
assert_eq!( | ||
meta["revocation_endpoint"], | ||
format!("{}/mcp/revoke", srv.url) | ||
); | ||
| ||
// Registration: https, or http on the assistant's own machine. | ||
let (redirect, back) = assistant(); | ||
| @@ -256,20 +270,23 @@ fn an_assistant_signs_in_and_works_as_its_user() { | ||
params.push(("resource", &resource)); | ||
| ||
// The user says who they are; only an account on the list goes on. | ||
// The page names where signing in sends them, as any name can be taken. | ||
let page = authorize(&srv, ¶ms).text().unwrap(); | ||
assert!( | ||
page.contains("Connect Claude <b> to knotless"), | ||
"{page}" | ||
); | ||
assert!( | ||
page.contains("sends you back to <strong>127.0.0.1</strong>"), | ||
"{page}" | ||
); | ||
let signed = ask(&page); | ||
assert_eq!(sign_in(&srv, "forged", &allowed.handle).status(), 400); | ||
let res = sign_in(&srv, &signed, "nobody.invalid"); | ||
assert_eq!(res.status(), 400); | ||
assert!( | ||
res.text() | ||
.unwrap() | ||
.contains("No account answers to nobody.invalid") | ||
); | ||
let text = res.text().unwrap(); | ||
assert!(text.contains("No account answers to nobody.invalid"), "{text}"); | ||
assert!(text.contains("<strong>127.0.0.1</strong>"), "{text}"); | ||
let res = sign_in(&srv, &signed, &other.handle); | ||
assert_eq!(res.status(), 403); | ||
| ||
| @@ -385,26 +402,91 @@ fn an_assistant_signs_in_and_works_as_its_user() { | ||
let listed = res.text().unwrap(); | ||
assert!(listed.contains("over-http"), "{listed}"); | ||
// A refresh token is the client's alone, and makes new tokens. | ||
let (status, _) = token( | ||
&srv, | ||
&[ | ||
("grant_type", "refresh_token"), | ||
("client_id", &id), | ||
("client_secret", &secret), | ||
("refresh_token", &access), | ||
], | ||
); | ||
let refreshed = |t: &str| { | ||
token( | ||
&srv, | ||
&[ | ||
("grant_type", "refresh_token"), | ||
("client_id", &id), | ||
("client_secret", &secret), | ||
("refresh_token", t), | ||
], | ||
) | ||
}; | ||
assert_eq!(refreshed(&access).0, 400); | ||
let (status, fresh) = refreshed(&refresh); | ||
assert_eq!(status, 200, "{fresh}"); | ||
let fresh_access = fresh["access_token"].as_str().unwrap().to_string(); | ||
let fresh_refresh = fresh["refresh_token"].as_str().unwrap().to_string(); | ||
// They replace the old ones: a refresh token works once. | ||
let (status, body) = refreshed(&refresh); | ||
assert_eq!(status, 400); | ||
let (status, fresh) = token( | ||
&srv, | ||
&[ | ||
("grant_type", "refresh_token"), | ||
("client_id", &id), | ||
("client_secret", &secret), | ||
("refresh_token", &refresh), | ||
], | ||
assert!( | ||
body["error_description"] | ||
.as_str() | ||
.unwrap() | ||
.contains("used or revoked"), | ||
"{body}" | ||
); | ||
assert_eq!(status, 200, "{fresh}"); | ||
assert_eq!(rpc(&srv, Some(&access), "tools/list", json!({})).0, 401); | ||
assert_eq!( | ||
rpc(&srv, Some(&fresh_access), "tools/list", json!({})).0, | ||
200 | ||
); | ||
| ||
// A server that cannot keep the time makes no tokens. | ||
let revoked = ctx(&s) | ||
.env | ||
.config_dir | ||
.join("mcp") | ||
.join(allowed.did.replace(':', "_")) | ||
.join("revoked"); | ||
std::fs::remove_dir_all(&revoked).unwrap(); | ||
std::fs::write(&revoked, "").unwrap(); | ||
let (status, body) = refreshed(&fresh_refresh); | ||
assert_eq!( | ||
(status, body["error"].as_str()), | ||
(500, Some("server_error")) | ||
); | ||
std::fs::remove_file(&revoked).unwrap(); | ||
| ||
// Revoking a token (RFC 7009) ends every other the client has for the | ||
// account; one that is not good here is answered the same. | ||
assert_eq!( | ||
revoke(&srv, &[("client_id", "nope"), ("token", &fresh_access)]), | ||
401 | ||
); | ||
assert_eq!( | ||
revoke( | ||
&srv, | ||
&[ | ||
("client_id", &id), | ||
("client_secret", &secret), | ||
("token", "nope") | ||
] | ||
), | ||
200 | ||
); | ||
assert_eq!( | ||
rpc(&srv, Some(&fresh_access), "tools/list", json!({})).0, | ||
200 | ||
); | ||
assert_eq!( | ||
revoke( | ||
&srv, | ||
&[ | ||
("client_id", &id), | ||
("client_secret", &secret), | ||
("token", &fresh_access) | ||
] | ||
), | ||
200 | ||
); | ||
assert_eq!( | ||
rpc(&srv, Some(&fresh_access), "tools/list", json!({})).0, | ||
401 | ||
); | ||
assert_eq!(refreshed(&fresh_refresh).0, 400); | ||
| ||
// A public client proves nothing but its PKCE; a code is for the | ||
// client it was issued to, with the verifier it was asked with. | ||
| @@ -508,6 +590,7 @@ fn a_pds_that_will_not_take_the_sign_in() { | ||
) | ||
.text() | ||
.unwrap(); | ||
assert!(page.contains("<strong>claude.test</strong>"), "{page}"); | ||
let res = sign_in(&srv, &ask(&page), &allowed.handle); | ||
assert_eq!(res.status(), 502); | ||
assert!(res.text().unwrap().contains("didn't take the sign-in")); | ||