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: "owner@acme.test".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("owner@acme.test", "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("owner@acme.test", "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("ghost@acme.test", "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("owner@acme.test", "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("owner@acme.test", "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("owner@acme.test", "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("owner@acme.test", "pw").await.unwrap(); let b = auth.login_local("owner@acme.test", "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("owner@acme.test", "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()); }