feat(auth): a credential narrow enough to hand to an agent

`docs/TOOL-CALL-ARCHITECTURE.md` §3 calls deploying the MCP door "config,
not code". It is not, and the reason is authentication.

`/mcp/skills` authenticates with `AuthService::authenticate`, which
returns a full `AuthedUser` carrying the user's role. There is no
narrower credential in the system. So pointing a mission container at the
door means writing a bearer token into a file inside that container — and
mission agents run arbitrary `Bash` with egress and no read gate, which
is this platform's own documented security posture. An owner-scoped token
there turns "the agent runs commands in a sandbox" into "the agent drives
the whole ClawMates API as the owner".

Checked before building this rather than assumed: no such credential is
in a mission container today. The runtime's config.toml has no
`[mcp.servers]` block and no bearer, so the door would have been a NEW
exposure, not an existing one.

So: `auth_sessions.scope`, defaulting to `full`. `authenticate` now
delegates to `authenticate_scoped(token, SCOPE_FULL)`, which means **every
existing caller rejects a narrow token** and a route must opt in by naming
the scope it accepts. `/mcp/skills` is the only opt-in.

Fail closed on purpose. The likely mistake here is adding a scope and
forgetting to wire its check; this way that mistake grants nothing rather
than granting everything.

`mint_scoped` refuses to mint a `full` token — a caller reaching for it
wants a narrow credential, and handing back a full one because an
argument was wrong is exactly the failure the column exists to prevent,
and it would be invisible because the token would work.

The test that matters is not that the door accepts the token, it is that
nothing else does. Negative-controlled: removing the scope comparison
fails `a_scoped_token_is_refused_by_every_unscoped_caller`.

`.sqlx` regenerated — `authenticate` is a compile-checked query and CI
builds with SQLX_OFFLINE=true.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
This commit is contained in:
Omar Sobh
2026-08-21 11:43:25 -07:00
co-authored by Claude Opus 5
parent 8591585e60
commit 2668191e30
7 changed files with 233 additions and 5 deletions
+78
View File
@@ -125,3 +125,81 @@ async fn tokens_are_unique_per_login() {
auth.authenticate(a.secret()).await.unwrap();
auth.authenticate(b.secret()).await.unwrap();
}
/// A narrow credential must be refused everywhere it was not explicitly
/// allowed.
///
/// This is the whole security property. The skills token lives in a file
/// inside a mission container, where an agent running arbitrary `Bash` can
/// read it — so what matters is not that `/mcp/skills` accepts it but that
/// **nothing else does**.
#[tokio::test]
async fn a_scoped_token_is_refused_by_every_unscoped_caller() {
let pool = cm_testkit::test_pool().await;
let (_ws, user) = seeded(&pool).await;
let auth = AuthService::new(pool);
let narrow = auth
.mint_scoped(user.id, cm_auth::SCOPE_SKILLS_READ, time::Duration::hours(1))
.await
.unwrap();
// `authenticate` is what every ordinary route calls.
assert!(
matches!(
auth.authenticate(&narrow).await,
Err(AuthError::Unauthenticated)
),
"a skills token must not authenticate a normal API call — the token is \
readable by the agent it is given to"
);
// And it is refused for a DIFFERENT narrow scope, not just for `full`.
assert!(matches!(
auth.authenticate_scoped(&narrow, "some:other").await,
Err(AuthError::Unauthenticated)
));
// It does work for the one thing it is for.
let ok = auth
.authenticate_scoped(&narrow, cm_auth::SCOPE_SKILLS_READ)
.await
.unwrap();
assert_eq!(ok.user_id, user.id);
}
/// A person's session keeps working everywhere, including the scoped route.
#[tokio::test]
async fn a_full_session_still_satisfies_a_scoped_route() {
let pool = cm_testkit::test_pool().await;
let (_ws, user) = seeded(&pool).await;
let auth = AuthService::new(pool);
auth.set_password(user.id, "correct horse battery staple")
.await
.unwrap();
let token = auth
.login_local("[email protected]", "correct horse battery staple")
.await
.unwrap();
assert!(auth
.authenticate_scoped(token.secret(), cm_auth::SCOPE_SKILLS_READ)
.await
.is_ok());
}
/// `mint_scoped` must refuse to mint a full token.
///
/// A caller reaching for this wants a narrow credential; handing back a full
/// one because an argument was wrong is precisely the failure the scope column
/// exists to prevent, and it would be invisible — the token would work.
#[tokio::test]
async fn mint_scoped_refuses_to_mint_a_full_token() {
let pool = cm_testkit::test_pool().await;
let (_ws, user) = seeded(&pool).await;
let auth = AuthService::new(pool);
assert!(auth
.mint_scoped(user.id, cm_auth::SCOPE_FULL, time::Duration::hours(1))
.await
.is_err());
}