~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

A connector whose login has ended has its user sign in again

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:mcp/dead-login
.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