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
+75 -1
View File
@@ -10,6 +10,17 @@ use crate::token::{hash_token, SessionToken};
/// How long a login session stays valid.
pub const SESSION_TTL: Duration = Duration::days(7);
/// A person's session. Accepted by every route.
pub const SCOPE_FULL: &str = "full";
/// Read the skills catalogue over MCP, and nothing else.
///
/// The credential a mission container is given so its agent can retrieve skill
/// bodies on demand. Deliberately its own constant rather than a string
/// literal at the two call sites: a typo in one of them would produce a token
/// that authenticates nowhere, which fails safely but silently.
pub const SCOPE_SKILLS_READ: &str = "skills:read";
/// The authenticated caller attached to every API request: everything RBAC
/// decisions need, nothing more.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -263,13 +274,44 @@ impl AuthService {
/// JWTs (three dot-separated segments) take the hosted-identity path;
/// everything else is a local opaque session token.
pub async fn authenticate(&self, token_secret: &str) -> Result<AuthedUser, AuthError> {
self.authenticate_scoped(token_secret, SCOPE_FULL).await
}
/// Resolve a bearer token that is allowed to be narrow.
///
/// `required` is the scope this call site accepts *in addition to*
/// [`SCOPE_FULL`], which is a person's session and is accepted everywhere.
///
/// # Fail closed
///
/// [`authenticate`](Self::authenticate) delegates here with `SCOPE_FULL`,
/// so a narrow token is **rejected by every existing caller** and a route
/// has to opt in by naming the scope it accepts. That direction matters:
/// the likely mistake is adding a scope and forgetting to wire a check, and
/// this way that mistake grants nothing instead of granting everything.
///
/// A narrow credential exists because the alternative is worse. Reaching
/// `/mcp/skills` from a mission container means putting a bearer token in a
/// file inside it, and mission agents run arbitrary `Bash` with egress and
/// no read gate — so a full session there is an owner-privileged API key
/// handed to something explicitly untrusted.
pub async fn authenticate_scoped(
&self,
token_secret: &str,
required: &str,
) -> Result<AuthedUser, AuthError> {
if let Some(verifier) = self.verifier.clone() {
if token_secret.matches('.').count() == 2 {
// An external issuer JWT is always a person. There is no
// narrow form of it, so it satisfies only `full`.
if required != SCOPE_FULL {
return Err(AuthError::Unauthenticated);
}
return self.authenticate_external(token_secret, &verifier).await;
}
}
let row = sqlx::query!(
"SELECT u.id, u.workspace_id, u.role
"SELECT u.id, u.workspace_id, u.role, s.scope
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.expires_at > now()",
@@ -278,6 +320,9 @@ impl AuthService {
.fetch_optional(&self.pool)
.await?
.ok_or(AuthError::Unauthenticated)?;
if row.scope != SCOPE_FULL && row.scope != required {
return Err(AuthError::Unauthenticated);
}
Ok(AuthedUser {
user_id: UserId::from(row.id),
workspace_id: WorkspaceId::from(row.workspace_id),
@@ -289,6 +334,35 @@ impl AuthService {
})
}
/// Mint a narrow, short-lived credential for something that is not a person.
///
/// Returns the secret, which is the only time it exists in plaintext here.
pub async fn mint_scoped(
&self,
user_id: UserId,
scope: &str,
ttl: Duration,
) -> Result<String, AuthError> {
if scope == SCOPE_FULL {
// A caller reaching for this wants a narrow token; handing back a
// full one because the argument was wrong is the failure this
// whole change exists to prevent.
return Err(AuthError::Unauthenticated);
}
let token = SessionToken::generate();
sqlx::query!(
"INSERT INTO auth_sessions (token_hash, user_id, expires_at, scope)
VALUES ($1, $2, $3, $4)",
hash_token(token.secret()),
user_id.as_uuid(),
OffsetDateTime::now_utc() + ttl,
scope,
)
.execute(&self.pool)
.await?;
Ok(token.secret().to_string())
}
/// Mint a long-lived opaque session for an internal service caller
/// (e.g. the per-team ZeroClaw runtime calling back into the MCP door).
/// Returns the plaintext token — the caller is responsible for handing