chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A connector whose login has ended has its user sign in again
.changeset/mcp-asks-to-sign-in-again.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: patch | ||
--- | ||
| ||
# A connector whose login has ended asks you to sign in again | ||
| ||
When your PDS ended the login a knotless server keeps for your connector, its tools carried on as no one: private repositories read as not there, and writes said to run `knotless login`. The server now answers so that your assistant has you sign in again. | ||
crates/knotless/src/oauth.rs+4−0
| @@ -204,6 +204,10 @@ fn loopback_scope(oauth: &OAuth) -> Option<String> { | ||
.map(|(_, v)| v.into_owned()) | ||
} | ||
| ||
/// The error a token endpoint answers for a grant it no longer honors, as | ||
/// a refresh token past its session's lifetime. | ||
pub const INVALID_GRANT: &str = "invalid_grant"; | ||
| ||
/// Whether `err` is a PDS refusing a call for want of scope, the reference | ||
/// PDS's `InvalidToken` saying so or OAuth's `ScopeMissingError`: from the | ||
/// call itself, or carried in the words of a server it went through. | ||
crates/knotless/src/session.rs+22−0
| @@ -170,6 +170,28 @@ pub async fn ensure(ctx: &Ctx, id: &Identity) -> Result<Session> { | ||
) | ||
} | ||
| ||
/// Whether `did`'s login here is over, so only signing in again brings it | ||
/// back: there is none, or its server turned down refreshing it, which also | ||
/// forgets it. A refresh that failed in any other way may work next time. | ||
pub async fn ended(ctx: &Ctx, did: &str) -> bool { | ||
let Some(stored) = load(ctx, did) else { | ||
return true; | ||
}; | ||
if !stored.expiring() { | ||
return false; | ||
} | ||
let Err(err) = refresh(ctx, &stored).await else { | ||
return false; | ||
}; | ||
let said = format!("{err:#}"); | ||
tracing::warn!(did, error = said, "login refresh failed"); | ||
let over = said.starts_with(oauth::INVALID_GRANT); | ||
if over { | ||
remove(ctx, did); | ||
} | ||
over | ||
} | ||
| ||
/// Marks `session` as the account `knotless login` last logged in. | ||
pub fn remember(ctx: &Ctx, session: &Session) -> Result<()> { | ||
store::write_bytes( | ||
crates/knotless-mcp/src/http.rs+30−9
| @@ -490,16 +490,31 @@ impl Mcp { | ||
.ok_or_else(|| invalid_grant("The redirect_uri or code_verifier does not match")) | ||
} | ||
| ||
fn tokens(&self, form: &HashMap<String, String>) -> Result<Value, Refusal> { | ||
/// The account `form`'s refresh token is `client_id`'s for. One whose | ||
/// login with its PDS is over gets no more tokens, so that its assistant | ||
/// has its user sign in again. | ||
async fn refreshing( | ||
&self, | ||
client_id: &str, | ||
form: &HashMap<String, String>, | ||
) -> Result<String, Refusal> { | ||
let did = 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"))?; | ||
if session::ended(&self.ctx_for(&did), &did).await { | ||
return Err(invalid_grant("The login with the account's PDS is over")); | ||
} | ||
Ok(did) | ||
} | ||
| ||
async 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"))?, | ||
Some("refresh_token") => self.refreshing(&client_id, form).await?, | ||
other => { | ||
return Err(Refusal( | ||
StatusCode::BAD_REQUEST, | ||
| @@ -574,7 +589,13 @@ async fn bearer(State(mcp): State<Arc<Mcp>>, mut req: Request, next: Next) -> Re | ||
.and_then(|v| v.strip_prefix("Bearer ")) | ||
.and_then(|t| mcp.open::<Grant>("access", t)) | ||
.filter(|g| mcp.lets_in(&g.sub)); | ||
let Some(grant) = grant else { | ||
let ended = match &grant { | ||
Some(g) => session::ended(&mcp.ctx_for(&g.sub), &g.sub).await, | ||
None => false, | ||
}; | ||
// With no login left, the tools would read as no one, and a private | ||
// repository would seem not to exist: this has the user sign in again. | ||
let Some(grant) = grant.filter(|_| !ended) else { | ||
let metadata = format!("{}/.well-known/oauth-protected-resource{MCP}", mcp.url); | ||
return ( | ||
StatusCode::UNAUTHORIZED, | ||
| @@ -943,7 +964,7 @@ async fn callback( | ||
} | ||
| ||
async fn token(State(mcp): State<Arc<Mcp>>, Form(form): Form<HashMap<String, String>>) -> Response { | ||
match mcp.tokens(&form) { | ||
match mcp.tokens(&form).await { | ||
Ok(tokens) => ([(header::CACHE_CONTROL, "no-store")], Json(tokens)).into_response(), | ||
Err(refusal) => refusal.into_response(), | ||
} | ||
crates/knotless-mcp/tests/http.rs+48−0
| @@ -514,6 +514,54 @@ fn an_assistant_signs_in_and_works_as_its_user() { | ||
.unwrap() | ||
.contains("no longer") | ||
); | ||
| ||
// The login it keeps with the PDS is refreshed as it runs out, and | ||
// waits out a PDS that cannot be reached. | ||
let dir = allowed.did.replace(':', "_"); | ||
let kept = ctx(&s) | ||
.env | ||
.config_dir | ||
.join("mcp") | ||
.join(&dir) | ||
.join("sessions") | ||
.join(format!("{dir}.json")); | ||
let change = |what: &dyn Fn(&mut Value)| { | ||
let mut session: Value = serde_json::from_slice(&std::fs::read(&kept).unwrap()).unwrap(); | ||
session["oauth"]["expiresAt"] = 0.into(); | ||
what(&mut session); | ||
std::fs::write(&kept, session.to_string()).unwrap(); | ||
}; | ||
change(&|_| {}); | ||
assert_eq!(rpc(&srv, Some(&access), "tools/list", json!({})).0, 200); | ||
let read: Value = serde_json::from_slice(&std::fs::read(&kept).unwrap()).unwrap(); | ||
assert_ne!(read["oauth"]["expiresAt"], 0, "{read}"); | ||
let endpoint = read["oauth"]["tokenEndpoint"].clone(); | ||
change(&|s| s["oauth"]["tokenEndpoint"] = "http://127.0.0.1:1/token".into()); | ||
assert_eq!(rpc(&srv, Some(&access), "tools/list", json!({})).0, 200); | ||
assert!(kept.exists()); | ||
| ||
// Once the PDS turns it down the login is over: the assistant is sent | ||
// to sign in again, rather than working as no one. | ||
change(&|s| { | ||
s["oauth"]["tokenEndpoint"] = endpoint.clone(); | ||
s["refreshJwt"] = format!("ref-{}", "0".repeat(64)).into(); | ||
}); | ||
let (status, _) = rpc(&srv, Some(&access), "tools/list", json!({})); | ||
assert_eq!(status, 401); | ||
assert!(!kept.exists()); | ||
let (status, body) = token( | ||
&srv, | ||
&[ | ||
("grant_type", "refresh_token"), | ||
("client_id", &id), | ||
("client_secret", &secret), | ||
("refresh_token", &refresh), | ||
], | ||
); | ||
assert_eq!( | ||
(status, body["error"].as_str()), | ||
(400, Some("invalid_grant")) | ||
); | ||
} | ||
| ||
#[test] | ||
docs/guide/agents.md+2−0
| @@ -42,6 +42,8 @@ for knotless.dev's. The assistant registers itself and sends you to sign in | ||
with your handle at your own PDS. An account taken down on a server with an | ||
index is turned away, and taking one down stops its tokens at their next | ||
use. The server keeps each account's session apart from every other's. | ||
When your PDS ends the login the server holds for you, your assistant asks | ||
you to sign in again, or shows knotless as disconnected: reconnect it. | ||
| ||
Each account may have 8 tool calls running at once, 2 of them with a | ||
`wait`, and start 120 a minute. A call past any of these answers as a tool | ||
docs/reference/mcp.md+7−0
| @@ -7,6 +7,13 @@ errors come back as tool errors for the agent to read. One a PDS refused | ||
for want of scope, as a login from before knotless asked for more is, adds | ||
that the user signs in again: reconnecting knotless in their assistant, or | ||
running `knotless login` for a knotless on their machine. | ||
| ||
On a server, each account's login with its PDS can end while its assistant's | ||
token still works: a PDS ends a session after a lifetime of its own. Once the | ||
PDS turns down refreshing it, the server forgets the session, answers the | ||
next call `401` and the next token refresh `invalid_grant`, so the assistant | ||
has its user sign in again. A refresh that fails any other way is logged, | ||
and the call goes on. | ||
[Agents](../guide/agents.md) says how to connect a client. | ||
| ||
Over stdio, `knotless-mcp` asks its appview about versions once a day, as | ||