`/mcp` — `email_send`, `slack_post`, `delegate` — authenticated with `authenticate`, which accepts only `full`. Nothing hands it a token today, so this cost nothing yet; the moment something did, the only credential that worked would have been an owner's session, held by an agent runtime. `SCOPE_AGENT_DOOR` is that credential's narrow form. `full` still works, so the UI and every human caller are unaffected, and the route now names what it accepts rather than accepting everything by default. The test that matters is not that each scope opens its own route: it is that holding one grants nothing the other has. Both tokens live where an agent can read them. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
254 lines
8.5 KiB
Rust
254 lines
8.5 KiB
Rust
use cm_auth::{AuthError, AuthService};
|
|
use cm_domain::{Role, User, UserId, Workspace, WorkspaceId};
|
|
|
|
async fn seeded(pool: &sqlx::PgPool) -> (Workspace, User) {
|
|
let ws = Workspace {
|
|
id: WorkspaceId::new(),
|
|
name: "Acme".into(),
|
|
plan: "team".into(),
|
|
};
|
|
cm_db::repo::workspaces::insert(pool, &ws).await.unwrap();
|
|
let user = User {
|
|
id: UserId::new(),
|
|
workspace_id: ws.id,
|
|
email: "[email protected]".into(),
|
|
role: Role::Owner,
|
|
display_name: "Owner".into(),
|
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
|
};
|
|
cm_db::repo::users::insert(pool, &user).await.unwrap();
|
|
(ws, user)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn register_login_authenticate_round_trip() {
|
|
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();
|
|
|
|
let authed = auth.authenticate(token.secret()).await.unwrap();
|
|
assert_eq!(authed.user_id, user.id);
|
|
assert_eq!(authed.workspace_id, ws.id);
|
|
assert_eq!(authed.role, Role::Owner);
|
|
assert!(authed.role.is_owner());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn wrong_password_is_rejected_without_detail() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let (_, user) = seeded(&pool).await;
|
|
let auth = AuthService::new(pool);
|
|
auth.set_password(user.id, "right").await.unwrap();
|
|
|
|
let err = auth
|
|
.login_local("[email protected]", "wrong")
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, AuthError::InvalidCredentials));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_email_is_the_same_error_as_wrong_password() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let auth = AuthService::new(pool);
|
|
let err = auth.login_local("[email protected]", "pw").await.unwrap_err();
|
|
// Indistinguishable from a wrong password: no account enumeration.
|
|
assert!(matches!(err, AuthError::InvalidCredentials));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn user_without_password_cannot_login_locally() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let (_, _user) = seeded(&pool).await;
|
|
let auth = AuthService::new(pool);
|
|
let err = auth.login_local("[email protected]", "pw").await.unwrap_err();
|
|
assert!(matches!(err, AuthError::InvalidCredentials));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_token_is_unauthenticated() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let auth = AuthService::new(pool);
|
|
let err = auth.authenticate("not-a-real-token").await.unwrap_err();
|
|
assert!(matches!(err, AuthError::Unauthenticated));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn expired_session_is_unauthenticated() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let (_, user) = seeded(&pool).await;
|
|
let auth = AuthService::new(pool.clone());
|
|
auth.set_password(user.id, "pw").await.unwrap();
|
|
let token = auth.login_local("[email protected]", "pw").await.unwrap();
|
|
|
|
sqlx::query("UPDATE auth_sessions SET expires_at = now() - interval '1 minute'")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let err = auth.authenticate(token.secret()).await.unwrap_err();
|
|
assert!(matches!(err, AuthError::Unauthenticated));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn logout_invalidates_the_token() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let (_, user) = seeded(&pool).await;
|
|
let auth = AuthService::new(pool);
|
|
auth.set_password(user.id, "pw").await.unwrap();
|
|
let token = auth.login_local("[email protected]", "pw").await.unwrap();
|
|
|
|
auth.authenticate(token.secret()).await.unwrap();
|
|
auth.logout(token.secret()).await.unwrap();
|
|
let err = auth.authenticate(token.secret()).await.unwrap_err();
|
|
assert!(matches!(err, AuthError::Unauthenticated));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tokens_are_unique_per_login() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let (_, user) = seeded(&pool).await;
|
|
let auth = AuthService::new(pool);
|
|
auth.set_password(user.id, "pw").await.unwrap();
|
|
let a = auth.login_local("[email protected]", "pw").await.unwrap();
|
|
let b = auth.login_local("[email protected]", "pw").await.unwrap();
|
|
assert_ne!(a.secret(), b.secret());
|
|
// Both remain valid concurrently (multiple devices).
|
|
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);
|
|
}
|
|
|
|
/// The two narrow scopes must not substitute for each other.
|
|
///
|
|
/// They protect different things — one reads the skills catalogue, the other
|
|
/// operates the §15 door that can `delegate`. Both tokens live where an agent
|
|
/// can read them, so the whole value of having two constants is that holding
|
|
/// one grants nothing the other has.
|
|
#[tokio::test]
|
|
async fn one_narrow_scope_does_not_open_the_other() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let (_ws, user) = seeded(&pool).await;
|
|
let auth = AuthService::new(pool);
|
|
|
|
let skills = auth
|
|
.mint_scoped(user.id, cm_auth::SCOPE_SKILLS_READ, time::Duration::hours(1))
|
|
.await
|
|
.unwrap();
|
|
let door = auth
|
|
.mint_scoped(user.id, cm_auth::SCOPE_AGENT_DOOR, time::Duration::hours(1))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(
|
|
matches!(
|
|
auth.authenticate_scoped(&skills, cm_auth::SCOPE_AGENT_DOOR).await,
|
|
Err(AuthError::Unauthenticated)
|
|
),
|
|
"a skills token must not reach the door — the door can `delegate`"
|
|
);
|
|
assert!(
|
|
matches!(
|
|
auth.authenticate_scoped(&door, cm_auth::SCOPE_SKILLS_READ).await,
|
|
Err(AuthError::Unauthenticated)
|
|
),
|
|
"and a door token must not read the catalogue"
|
|
);
|
|
assert!(
|
|
matches!(
|
|
auth.authenticate(&door).await,
|
|
Err(AuthError::Unauthenticated)
|
|
),
|
|
"nor authenticate an ordinary API call"
|
|
);
|
|
assert!(auth
|
|
.authenticate_scoped(&door, cm_auth::SCOPE_AGENT_DOOR)
|
|
.await
|
|
.is_ok());
|
|
}
|
|
|
|
/// 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());
|
|
}
|