P0: tc-auth local sessions, tc-api P0 endpoints, teamclaw-server binary

- tc-auth: argon2id passwords, hashed opaque bearer tokens in auth_sessions
  (migration 0002), anti-enumeration login errors, redacted token Debug
- tc-api: axum router with /healthz, /api/auth/login|logout, /api/user/me,
  /api/team/{claws,credits,permissions}; Authed bearer extractor + RBAC
  permission derivation; integration-tested over real TCP vs real Postgres
- teamclaw-server: config -> pool -> self-migrate -> serve

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-09 22:30:33 -05:00
co-authored by Claude Fable 5
parent 0afb359183
commit c4349bf292
25 changed files with 1257 additions and 0 deletions
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM auth_sessions WHERE token_hash = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "47831a76c636349a39ae29d4feefd1e70d83dbc08a424d0d5f49fa666ec070e1"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, password_hash FROM users WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "password_hash",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "4cd14ef0a86a8e666d0c7482f5b7448633610dc688c1f7d3ade9efb403201c41"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET password_hash = $2 WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "4d4d46a946f0083e2dd5037ffba55c3ea33db13d224b3cf1f8bc8cefb26cc283"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT u.id, u.workspace_id, u.role\n FROM auth_sessions s\n JOIN users u ON u.id = s.user_id\n WHERE s.token_hash = $1 AND s.expires_at > now()",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "role",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "900827c5c8c24f4861120e98e3cc8a5b70f22e9f4b4168c9e8eb51c53d68bdae"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO auth_sessions (token_hash, user_id, expires_at)\n VALUES ($1, $2, $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid",
"Timestamptz"
]
},
"nullable": []
},
"hash": "e04949b5ecf5e8085624656df4dc378163cb6a9354402dcfbf7add44e060acc8"
}
Generated
+259
View File
@@ -32,6 +32,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]] [[package]]
name = "astral-tokio-tar" name = "astral-tokio-tar"
version = "0.5.6" version = "0.5.6"
@@ -119,10 +131,13 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [ dependencies = [
"axum-core", "axum-core",
"bytes", "bytes",
"form_urlencoded",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper",
"hyper-util",
"itoa", "itoa",
"matchit", "matchit",
"memchr", "memchr",
@@ -130,10 +145,15 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"serde_core", "serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -152,6 +172,7 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -203,6 +224,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -338,6 +368,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.45" version = "0.4.45"
@@ -775,8 +811,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -786,9 +824,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 5.3.0", "r-efi 5.3.0",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -989,6 +1029,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots 1.0.7",
] ]
[[package]] [[package]]
@@ -1010,13 +1051,16 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [ dependencies = [
"base64",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
"hyper", "hyper",
"ipnet",
"libc", "libc",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"tokio", "tokio",
@@ -1207,6 +1251,12 @@ version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
[[package]]
name = "ipnet"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.14.0" version = "0.14.0"
@@ -1309,6 +1359,12 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "matchit" name = "matchit"
version = "0.8.4" version = "0.8.4"
@@ -1510,6 +1566,17 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]] [[package]]
name = "pear" name = "pear"
version = "0.2.9" version = "0.2.9"
@@ -1726,6 +1793,61 @@ version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -1882,6 +2004,44 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.7",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -1977,6 +2137,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@@ -2126,6 +2287,17 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde_repr" name = "serde_repr"
version = "0.1.20" version = "0.1.20"
@@ -2542,6 +2714,9 @@ name = "sync_wrapper"
version = "1.0.2" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]] [[package]]
name = "synstructure" name = "synstructure"
@@ -2554,6 +2729,41 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tc-api"
version = "0.1.0"
dependencies = [
"axum",
"reqwest",
"serde",
"serde_json",
"sqlx",
"tc-auth",
"tc-db",
"tc-domain",
"tc-testkit",
"thiserror",
"time",
"tokio",
]
[[package]]
name = "tc-auth"
version = "0.1.0"
dependencies = [
"argon2",
"base64",
"rand_core 0.6.4",
"sha2",
"sqlx",
"tc-db",
"tc-domain",
"tc-testkit",
"thiserror",
"time",
"tokio",
]
[[package]] [[package]]
name = "tc-config" name = "tc-config"
version = "0.1.0" version = "0.1.0"
@@ -2600,6 +2810,17 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "teamclaw-server"
version = "0.1.0"
dependencies = [
"axum",
"tc-api",
"tc-config",
"tc-db",
"tokio",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
@@ -2887,6 +3108,24 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-util",
"http",
"http-body",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
"url",
]
[[package]] [[package]]
name = "tower-layer" name = "tower-layer"
version = "0.3.3" version = "0.3.3"
@@ -3144,6 +3383,16 @@ dependencies = [
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.123" version = "0.2.123"
@@ -3210,6 +3459,16 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "web-sys"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "web-time" name = "web-time"
version = "1.1.0" version = "1.1.0"
+3
View File
@@ -5,6 +5,9 @@ members = [
"crates/tc-config", "crates/tc-config",
"crates/tc-db", "crates/tc-db",
"crates/tc-testkit", "crates/tc-testkit",
"crates/tc-auth",
"crates/tc-api",
"crates/bins/teamclaw-server",
] ]
[workspace.package] [workspace.package]
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "teamclaw-server"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
axum = "0.8"
tc-api = { path = "../../tc-api" }
tc-config = { path = "../../tc-config" }
tc-db = { path = "../../tc-db" }
tokio = { workspace = true }
[lints]
workspace = true
+41
View File
@@ -0,0 +1,41 @@
//! TeamClaw server: REST API, and (in later phases) the streaming gateway,
//! scheduler, and safety worker, composed into one binary.
use std::path::PathBuf;
use std::process::ExitCode;
use tc_config::AppConfig;
#[tokio::main]
async fn main() -> ExitCode {
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(message) => {
eprintln!("teamclaw-server: {message}");
ExitCode::FAILURE
}
}
}
async fn run() -> Result<(), String> {
let config_path =
PathBuf::from(std::env::var("TEAMCLAW_CONFIG").unwrap_or_else(|_| "teamclaw.toml".into()));
let config = AppConfig::load_from(&config_path).map_err(|e| e.to_string())?;
let pool = tc_db::connect(&config.database.url, config.database.max_connections)
.await
.map_err(|e| format!("database connection failed: {e}"))?;
tc_db::MIGRATOR
.run(&pool)
.await
.map_err(|e| format!("migrations failed: {e}"))?;
let app = tc_api::router(tc_api::AppState::new(pool));
let listener = tokio::net::TcpListener::bind(config.listen_addr)
.await
.map_err(|e| format!("bind {} failed: {e}", config.listen_addr))?;
println!("teamclaw-server listening on {}", config.listen_addr);
axum::serve(listener, app)
.await
.map_err(|e| format!("server error: {e}"))
}
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "tc-api"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
axum = "0.8"
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
tc-auth = { path = "../tc-auth" }
tc-db = { path = "../tc-db" }
tc-domain = { path = "../tc-domain" }
thiserror = { workspace = true }
time = { workspace = true }
tokio = { workspace = true }
[dev-dependencies]
reqwest = { version = "0.12", default-features = false, features = [
"json",
"rustls-tls",
] }
tc-testkit = { path = "../tc-testkit" }
[lints]
workspace = true
+50
View File
@@ -0,0 +1,50 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
/// API-surface errors with their HTTP mapping. Internal causes are logged
/// server-side, never echoed to clients.
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("unauthorized")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("not found")]
NotFound,
#[error("internal error")]
Internal,
}
impl From<tc_db::DbError> for ApiError {
fn from(err: tc_db::DbError) -> Self {
match err {
tc_db::DbError::NotFound => ApiError::NotFound,
_ => ApiError::Internal,
}
}
}
impl From<tc_auth::AuthError> for ApiError {
fn from(err: tc_auth::AuthError) -> Self {
match err {
tc_auth::AuthError::InvalidCredentials | tc_auth::AuthError::Unauthenticated => {
ApiError::Unauthorized
}
_ => ApiError::Internal,
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match self {
ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
ApiError::Forbidden => StatusCode::FORBIDDEN,
ApiError::NotFound => StatusCode::NOT_FOUND,
ApiError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(json!({ "error": self.to_string() }))).into_response()
}
}
+32
View File
@@ -0,0 +1,32 @@
use axum::extract::FromRequestParts;
use axum::http::header::AUTHORIZATION;
use axum::http::request::Parts;
use tc_auth::AuthedUser;
use crate::{ApiError, AppState};
/// Extracts and verifies the bearer token on protected routes.
pub struct Authed(pub AuthedUser);
/// Pulls the raw bearer secret out of the Authorization header.
pub(crate) fn bearer_secret(parts: &Parts) -> Result<&str, ApiError> {
parts
.headers
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(ApiError::Unauthorized)
}
impl FromRequestParts<AppState> for Authed {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let secret = bearer_secret(parts)?;
let user = state.auth.authenticate(secret).await?;
Ok(Authed(user))
}
}
+38
View File
@@ -0,0 +1,38 @@
//! REST API for TeamClaw (spec §13). One route resource per module.
mod error;
mod extract;
mod routes;
use axum::routing::{get, post};
use axum::Router;
use sqlx::PgPool;
use tc_auth::AuthService;
pub use error::ApiError;
pub use extract::Authed;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub auth: AuthService,
}
impl AppState {
pub fn new(pool: PgPool) -> AppState {
let auth = AuthService::new(pool.clone());
AppState { pool, auth }
}
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/healthz", get(routes::health::healthz))
.route("/api/auth/login", post(routes::auth::login))
.route("/api/auth/logout", post(routes::auth::logout))
.route("/api/user/me", get(routes::identity::me))
.route("/api/team/claws", get(routes::team::claws))
.route("/api/team/credits", get(routes::team::credits))
.route("/api/team/permissions", get(routes::team::permissions))
.with_state(state)
}
+32
View File
@@ -0,0 +1,32 @@
use axum::extract::{Request, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::extract::bearer_secret;
use crate::{ApiError, AppState};
#[derive(Deserialize)]
pub struct LoginRequest {
email: String,
password: String,
}
pub async fn login(
State(state): State<AppState>,
Json(body): Json<LoginRequest>,
) -> Result<Json<Value>, ApiError> {
let token = state.auth.login_local(&body.email, &body.password).await?;
Ok(Json(json!({ "token": token.secret() })))
}
/// Revokes the presented token. No prior authentication step: deleting by
/// token hash can only ever revoke the session of the token the caller
/// already holds, and revoking an expired session must still succeed.
pub async fn logout(State(state): State<AppState>, req: Request) -> Result<StatusCode, ApiError> {
let (parts, _) = req.into_parts();
let secret = bearer_secret(&parts)?;
state.auth.logout(secret).await?;
Ok(StatusCode::NO_CONTENT)
}
+6
View File
@@ -0,0 +1,6 @@
use axum::http::StatusCode;
/// Liveness probe; deliberately unauthenticated so orchestrators can use it.
pub async fn healthz() -> StatusCode {
StatusCode::OK
}
+13
View File
@@ -0,0 +1,13 @@
use axum::extract::State;
use axum::Json;
use tc_domain::User;
use crate::{ApiError, AppState, Authed};
pub async fn me(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<User>, ApiError> {
let me = tc_db::repo::users::get(&state.pool, user.user_id).await?;
Ok(Json(me))
}
+4
View File
@@ -0,0 +1,4 @@
pub mod auth;
pub mod health;
pub mod identity;
pub mod team;
+34
View File
@@ -0,0 +1,34 @@
use axum::extract::State;
use axum::Json;
use serde_json::{json, Value};
use tc_domain::Agent;
use crate::{ApiError, AppState, Authed};
/// The left-rail agent roster (§4).
pub async fn claws(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<Agent>>, ApiError> {
let roster = tc_db::repo::agents::roster(&state.pool, user.workspace_id).await?;
Ok(Json(roster))
}
/// Available credit balance for the Credits page (§8.4).
pub async fn credits(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, ApiError> {
let available = tc_db::repo::credits::balance(&state.pool, user.workspace_id).await?;
Ok(Json(json!({ "available": available })))
}
/// What the caller may do, derived from their role (§16 RBAC).
pub async fn permissions(Authed(user): Authed) -> Json<Value> {
let owner = user.role.is_owner();
Json(json!({
"role": user.role,
"can_manage_team": owner,
"can_manage_billing": owner,
}))
}
+248
View File
@@ -0,0 +1,248 @@
use serde_json::{json, Value};
use tc_api::AppState;
use tc_auth::AuthService;
use tc_domain::{
AccessPolicy, Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId,
};
struct TestServer {
base: String,
client: reqwest::Client,
}
/// Boots the real axum server on an ephemeral port over real TCP.
async fn serve(pool: sqlx::PgPool) -> TestServer {
let state = AppState::new(pool);
let app = tc_api::router(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
TestServer {
base: format!("http://{addr}"),
client: reqwest::Client::new(),
}
}
async fn seed(pool: &sqlx::PgPool) -> (Workspace, User, Agent) {
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
tc_db::repo::workspaces::insert(pool, &ws).await.unwrap();
let owner = 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,
};
tc_db::repo::users::insert(pool, &owner).await.unwrap();
let agent = Agent {
id: AgentId::new(),
workspace_id: ws.id,
name: "Scout".into(),
job_title: "Research Analyst".into(),
system_prompt: String::new(),
avatar: "scout-1".into(),
accent: "#f96565".into(),
wallpaper: "dunes".into(),
managed_by: owner.id,
status: AgentStatus::Online,
};
tc_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
.await
.unwrap();
AuthService::new(pool.clone())
.set_password(owner.id, "pw")
.await
.unwrap();
(ws, owner, agent)
}
async fn login(server: &TestServer) -> String {
let res = server
.client
.post(format!("{}/api/auth/login", server.base))
.json(&json!({"email": "[email protected]", "password": "pw"}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
res.json::<Value>().await.unwrap()["token"]
.as_str()
.unwrap()
.to_owned()
}
#[tokio::test]
async fn healthz_needs_no_auth() {
let pool = tc_testkit::test_pool().await;
let server = serve(pool).await;
let res = server
.client
.get(format!("{}/healthz", server.base))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
}
#[tokio::test]
async fn protected_routes_reject_missing_or_bad_tokens() {
let pool = tc_testkit::test_pool().await;
let server = serve(pool).await;
for path in ["/api/user/me", "/api/team/claws", "/api/team/credits"] {
let bare = server
.client
.get(format!("{}{path}", server.base))
.send()
.await
.unwrap();
assert_eq!(bare.status(), 401, "{path} without token");
let bad = server
.client
.get(format!("{}{path}", server.base))
.bearer_auth("forged-token")
.send()
.await
.unwrap();
assert_eq!(bad.status(), 401, "{path} with forged token");
}
}
#[tokio::test]
async fn login_rejects_wrong_password() {
let pool = tc_testkit::test_pool().await;
seed(&pool).await;
let server = serve(pool).await;
let res = server
.client
.post(format!("{}/api/auth/login", server.base))
.json(&json!({"email": "[email protected]", "password": "nope"}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 401);
}
#[tokio::test]
async fn user_me_returns_the_authenticated_user() {
let pool = tc_testkit::test_pool().await;
let (ws, owner, _) = seed(&pool).await;
let server = serve(pool).await;
let token = login(&server).await;
let me: Value = server
.client
.get(format!("{}/api/user/me", server.base))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(me["id"], owner.id.to_string());
assert_eq!(me["workspace_id"], ws.id.to_string());
assert_eq!(me["email"], "[email protected]");
assert_eq!(me["role"], "owner");
}
#[tokio::test]
async fn team_claws_lists_the_roster() {
let pool = tc_testkit::test_pool().await;
let (_, _, agent) = seed(&pool).await;
let server = serve(pool).await;
let token = login(&server).await;
let claws: Value = server
.client
.get(format!("{}/api/team/claws", server.base))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let list = claws.as_array().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0]["id"], agent.id.to_string());
assert_eq!(list[0]["name"], "Scout");
assert_eq!(list[0]["status"], "online");
}
#[tokio::test]
async fn team_credits_returns_available_balance() {
let pool = tc_testkit::test_pool().await;
let (ws, _, _) = seed(&pool).await;
tc_db::repo::credits::add_lot(&pool, ws.id, 500, "purchase")
.await
.unwrap();
let server = serve(pool).await;
let token = login(&server).await;
let credits: Value = server
.client
.get(format!("{}/api/team/credits", server.base))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(credits["available"], 500);
}
#[tokio::test]
async fn team_permissions_reflect_role() {
let pool = tc_testkit::test_pool().await;
seed(&pool).await;
let server = serve(pool).await;
let token = login(&server).await;
let perms: Value = server
.client
.get(format!("{}/api/team/permissions", server.base))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(perms["role"], "owner");
assert_eq!(perms["can_manage_team"], true);
assert_eq!(perms["can_manage_billing"], true);
}
#[tokio::test]
async fn logout_revokes_the_session() {
let pool = tc_testkit::test_pool().await;
seed(&pool).await;
let server = serve(pool).await;
let token = login(&server).await;
let res = server
.client
.post(format!("{}/api/auth/logout", server.base))
.bearer_auth(&token)
.send()
.await
.unwrap();
assert_eq!(res.status(), 204);
let me = server
.client
.get(format!("{}/api/user/me", server.base))
.bearer_auth(&token)
.send()
.await
.unwrap();
assert_eq!(me.status(), 401);
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "tc-auth"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
argon2 = "0.5"
base64 = "0.22"
rand_core = { version = "0.6", features = ["getrandom"] }
sha2 = "0.10"
sqlx = { workspace = true }
tc-db = { path = "../tc-db" }
tc-domain = { path = "../tc-domain" }
thiserror = { workspace = true }
time = { workspace = true }
[dev-dependencies]
tc-testkit = { path = "../tc-testkit" }
tokio = { workspace = true }
[lints]
workspace = true
+12
View File
@@ -0,0 +1,12 @@
//! Authentication for TeamClaw (spec §16: SSO + RBAC).
//!
//! `local` mode: argon2id password hashes and opaque bearer tokens whose
//! SHA-256 hashes are stored in `auth_sessions`. This is the zero-dependency
//! appliance mode for air-gapped installs; OIDC SSO shares the same session
//! storage and `AuthedUser` output.
mod service;
mod token;
pub use service::{AuthError, AuthService, AuthedUser, SESSION_TTL};
pub use token::SessionToken;
+135
View File
@@ -0,0 +1,135 @@
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use sqlx::PgPool;
use tc_domain::{Role, UserId, WorkspaceId};
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,
}
impl AuthService {
pub fn new(pool: PgPool) -> AuthService {
AuthService { pool }
}
/// 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.
pub async fn authenticate(&self, token_secret: &str) -> Result<AuthedUser, AuthError> {
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
},
})
}
/// 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(())
}
}
+35
View File
@@ -0,0 +1,35 @@
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use rand_core::{OsRng, RngCore};
use sha2::{Digest, Sha256};
/// An opaque bearer token handed to the client exactly once at login.
/// Only its hash is persisted.
pub struct SessionToken(String);
impl std::fmt::Debug for SessionToken {
/// Redacted so tokens cannot leak through logs or panic messages.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SessionToken(<redacted>)")
}
}
impl SessionToken {
pub(crate) fn generate() -> SessionToken {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
SessionToken(URL_SAFE_NO_PAD.encode(bytes))
}
/// The secret value to return to the client. Deliberately not `Display`
/// so tokens cannot leak into logs by accident.
pub fn secret(&self) -> &str {
&self.0
}
}
/// Hash used as the storage key for a token.
pub(crate) fn hash_token(secret: &str) -> String {
let digest = Sha256::digest(secret.as_bytes());
URL_SAFE_NO_PAD.encode(digest)
}
+127
View File
@@ -0,0 +1,127 @@
use tc_auth::{AuthError, AuthService};
use tc_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(),
};
tc_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,
};
tc_db::repo::users::insert(pool, &user).await.unwrap();
(ws, user)
}
#[tokio::test]
async fn register_login_authenticate_round_trip() {
let pool = tc_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 = tc_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 = tc_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 = tc_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 = tc_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 = tc_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 = tc_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 = tc_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();
}
+10
View File
@@ -0,0 +1,10 @@
-- Login sessions for both auth modes. Only a SHA-256 hash of the bearer
-- token is stored; possession of the database never yields valid tokens.
CREATE TABLE auth_sessions (
token_hash TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users (id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX auth_sessions_user_idx ON auth_sessions (user_id);