Files
clawmates/crates/cm-auth/src/service.rs
T
Omar SobhandClaude Opus 4.7 49f94a5360
ci / gates (push) Successful in 5s
ci / rust (push) Failing after 10s
ci / frontend (push) Successful in 25s
ci / e2e (push) Skipped
ci / publish (push) Skipped
mcp door: mint workspace-owner service session for team runtime bearer
The runtime template's static clawmates_door bearer is rejected by
cm_auth::authenticate() (needs an auth_sessions row). Every per-team
agent was getting `unauthorized: missing or invalid bearer token` and
`0 tool(s) registered from 0 server(s)`.

Add AuthService::mint_service_session + users::owner_of_workspace and
mint a 30d service session in try_team_gateway_url; inject it into the
freshly-spawned team container's config.toml [[mcp.servers]] clawmates
Authorization header via prewrite_daemon_config_with_risk (bearer arg).

Follow-up: apply the same pattern to research::spawn (per-topic) and
per-loop spawn paths.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-07-18 13:37:27 -07:00

325 lines
11 KiB
Rust

use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use cm_domain::{Role, UserId, WorkspaceId};
use sqlx::{PgPool, Row};
use time::{Duration, OffsetDateTime};
use crate::token::{hash_token, SessionToken};
/// How long a login session stays valid.
pub const SESSION_TTL: Duration = Duration::days(7);
/// The authenticated caller attached to every API request: everything RBAC
/// decisions need, nothing more.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthedUser {
pub user_id: UserId,
pub workspace_id: WorkspaceId,
pub role: Role,
}
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
/// Wrong password and unknown email are deliberately the same error so
/// login cannot be used to enumerate accounts.
#[error("invalid credentials")]
InvalidCredentials,
#[error("unauthenticated")]
Unauthenticated,
#[error("password hashing failed: {0}")]
Hashing(String),
#[error(transparent)]
Db(#[from] sqlx::Error),
}
#[derive(Clone)]
pub struct AuthService {
pool: PgPool,
verifier: Option<std::sync::Arc<crate::JwtVerifier>>,
/// SaaS mode: a brand-new external identity provisions its own workspace
/// (owner) rather than joining the instance's single workspace.
per_signup_workspace: bool,
}
impl AuthService {
pub fn new(pool: PgPool) -> AuthService {
AuthService {
pool,
verifier: None,
per_signup_workspace: false,
}
}
/// Enables hosted-identity session JWTs (Clerk / OIDC) alongside
/// local sessions.
pub fn with_verifier(mut self, verifier: std::sync::Arc<crate::JwtVerifier>) -> AuthService {
self.verifier = Some(verifier);
self
}
/// In SaaS deployments, give each new hosted-identity sign-in its own
/// workspace (they become its owner) instead of joining the first one.
pub fn with_per_signup_workspace(mut self, enabled: bool) -> AuthService {
self.per_signup_workspace = enabled;
self
}
/// Verifies an issuer session token and JIT-provisions the user on
/// first sight. Role tracks the issuer claim on every login (Clerk's
/// org role is the source of truth for SSO users).
async fn authenticate_external(
&self,
token: &str,
verifier: &crate::JwtVerifier,
) -> Result<AuthedUser, AuthError> {
let claims = verifier
.verify(token)
.await
.map_err(|_| AuthError::Unauthenticated)?;
let role = match claims.role.as_deref() {
Some(role) if role.contains("admin") => Role::Owner,
_ => Role::Member,
};
let role_str = if role == Role::Owner {
"owner"
} else {
"member"
};
if let Some(row) = sqlx::query!(
"UPDATE users SET role = $2 WHERE auth_subject = $1
RETURNING id, workspace_id",
claims.sub,
role_str,
)
.fetch_optional(&self.pool)
.await?
{
return Ok(AuthedUser {
user_id: UserId::from(row.id),
workspace_id: WorkspaceId::from(row.workspace_id),
role,
});
}
// Link to an existing local account by email, if one exists.
if let Some(email) = claims.email.as_deref() {
if let Some(row) = sqlx::query!(
"UPDATE users SET auth_subject = $2, role = $3 WHERE email = $1
RETURNING id, workspace_id",
email,
claims.sub,
role_str,
)
.fetch_optional(&self.pool)
.await?
{
return Ok(AuthedUser {
user_id: UserId::from(row.id),
workspace_id: WorkspaceId::from(row.workspace_id),
role,
});
}
}
// First sight. Serialize concurrent first-logins for the same subject
// (the authed shell fires several API calls at once) with a per-subject
// advisory lock, so SaaS mode can't create duplicate workspaces and the
// appliance path can't double-insert the user.
let email = claims
.email
.clone()
.unwrap_or_else(|| format!("{}@sso.local", claims.sub));
let display_name = email.split('@').next().unwrap_or("teammate").to_owned();
let mut tx = self.pool.begin().await?;
sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1))")
.bind(&claims.sub)
.execute(&mut *tx)
.await?;
// A concurrent winner may have already provisioned this identity.
if let Some(row) =
sqlx::query("SELECT id, workspace_id, role FROM users WHERE auth_subject = $1")
.bind(&claims.sub)
.fetch_optional(&mut *tx)
.await?
{
tx.commit().await?;
let role_db: String = row.get("role");
return Ok(AuthedUser {
user_id: UserId::from(row.get::<uuid::Uuid, _>("id")),
workspace_id: WorkspaceId::from(row.get::<uuid::Uuid, _>("workspace_id")),
role: if role_db == "owner" {
Role::Owner
} else {
Role::Member
},
});
}
// Pick the workspace: SaaS provisions a fresh one this user owns;
// appliance joins the instance's single workspace.
let (workspace, effective_role): (uuid::Uuid, &str) = if self.per_signup_workspace {
let ws = WorkspaceId::new();
sqlx::query("INSERT INTO workspaces (id, name, plan) VALUES ($1, $2, 'free')")
.bind(ws.as_uuid())
.bind(format!("{display_name}'s workspace"))
.execute(&mut *tx)
.await?;
(ws.as_uuid(), "owner")
} else {
let ws: uuid::Uuid =
sqlx::query_scalar("SELECT id FROM workspaces ORDER BY created_at, id LIMIT 1")
.fetch_optional(&mut *tx)
.await?
.ok_or(AuthError::Unauthenticated)?;
(ws, role_str)
};
let user_id = UserId::new();
let row = sqlx::query(
"INSERT INTO users (id, workspace_id, email, role, display_name, auth_subject)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, workspace_id",
)
.bind(user_id.as_uuid())
.bind(workspace)
.bind(&email)
.bind(effective_role)
.bind(&display_name)
.bind(&claims.sub)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(AuthedUser {
user_id: UserId::from(row.get::<uuid::Uuid, _>("id")),
workspace_id: WorkspaceId::from(row.get::<uuid::Uuid, _>("workspace_id")),
role: if effective_role == "owner" {
Role::Owner
} else {
Role::Member
},
})
}
/// Sets (or resets) a user's local password.
pub async fn set_password(&self, user_id: UserId, password: &str) -> Result<(), AuthError> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|e| AuthError::Hashing(e.to_string()))?
.to_string();
sqlx::query!(
"UPDATE users SET password_hash = $2 WHERE id = $1",
user_id.as_uuid(),
hash,
)
.execute(&self.pool)
.await?;
Ok(())
}
/// Verifies email + password and opens a new session.
pub async fn login_local(
&self,
email: &str,
password: &str,
) -> Result<SessionToken, AuthError> {
let row = sqlx::query!(
"SELECT id, password_hash FROM users WHERE email = $1",
email,
)
.fetch_optional(&self.pool)
.await?;
let (user_id, stored_hash) = match row {
Some(row) => match row.password_hash {
Some(hash) => (row.id, hash),
None => return Err(AuthError::InvalidCredentials),
},
None => return Err(AuthError::InvalidCredentials),
};
let parsed =
PasswordHash::new(&stored_hash).map_err(|e| AuthError::Hashing(e.to_string()))?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.map_err(|_| AuthError::InvalidCredentials)?;
let token = SessionToken::generate();
sqlx::query!(
"INSERT INTO auth_sessions (token_hash, user_id, expires_at)
VALUES ($1, $2, $3)",
hash_token(token.secret()),
user_id,
OffsetDateTime::now_utc() + SESSION_TTL,
)
.execute(&self.pool)
.await?;
Ok(token)
}
/// Resolves a bearer token to the authenticated user. Issuer session
/// 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> {
if let Some(verifier) = self.verifier.clone() {
if token_secret.matches('.').count() == 2 {
return self.authenticate_external(token_secret, &verifier).await;
}
}
let row = sqlx::query!(
"SELECT u.id, u.workspace_id, u.role
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.expires_at > now()",
hash_token(token_secret),
)
.fetch_optional(&self.pool)
.await?
.ok_or(AuthError::Unauthenticated)?;
Ok(AuthedUser {
user_id: UserId::from(row.id),
workspace_id: WorkspaceId::from(row.workspace_id),
role: if row.role == "owner" {
Role::Owner
} else {
Role::Member
},
})
}
/// 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
/// it to the exact process that needs it and not persisting it broadly.
pub async fn mint_service_session(
&self,
user_id: UserId,
ttl: Duration,
) -> Result<SessionToken, AuthError> {
let token = SessionToken::generate();
sqlx::query!(
"INSERT INTO auth_sessions (token_hash, user_id, expires_at)
VALUES ($1, $2, $3)",
hash_token(token.secret()),
user_id.as_uuid(),
OffsetDateTime::now_utc() + ttl,
)
.execute(&self.pool)
.await?;
Ok(token)
}
/// Ends the session for this token.
pub async fn logout(&self, token_secret: &str) -> Result<(), AuthError> {
sqlx::query!(
"DELETE FROM auth_sessions WHERE token_hash = $1",
hash_token(token_secret),
)
.execute(&self.pool)
.await?;
Ok(())
}
}