mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat: multi-tenant auth with per-user scoping Multi-user authentication and authorization for IronClaw gateway: - Token-based auth mapping tokens to user IDs via GATEWAY_USER_TOKENS - Per-user SSE broadcast scoping - Per-user rate limiting with poisoned lock recovery - Handler auth and ownership checks for jobs, settings, routines - Extension secrets scoped per-user - Chat handlers use authenticated identity - Reverse proxy deployment documentation - Comprehensive integration tests for auth, SSE, rate limiting, and job isolation * fix: scope memory tools per-user in multi-tenant mode Memory tools (search, write, read, tree) held a single workspace created at startup with GATEWAY_USER_ID. In multi-tenant mode, all users' tool calls searched the default user's scope. Add WorkspaceResolver trait that resolves workspaces per-request using JobContext.user_id. In single-user mode, returns the startup workspace. In multi-tenant mode (GATEWAY_USER_TOKENS configured), creates and caches per-user workspaces on demand. Includes regression tests for workspace resolution and user isolation. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: comprehensive multi-tenant isolation audit Address all review findings from @serrrfirat plus 7 additional gaps found via full security audit: Reviewer findings (5): - WorkspacePool now applies search config, memory layers, embedding cache, identity read scopes, and global config scopes (was bare) - jobs_summary_handler uses per-user queries instead of global counters - jobs_prompt_handler restructured to not 404 agent jobs + ownership check - jobs_restart_handler agent branch now verifies user ownership - agent_job_summary_for_user added to Database trait + both backends Audit findings (7): - Delete dead handlers/memory.rs (stale copies with no auth) - Add AuthenticatedUser to logs_events, logs_level_get, logs_level_set - Add AuthenticatedUser to extensions_tools_handler, gateway_status_handler - Add auth + ownership checks to all 6 routines handlers - Add auth to all 4 skills handlers with audit logging on mutations - Scope extension setup SSE broadcast to user (broadcast_for_user) - Fix pre-existing test compilation errors in extensions/manager.rs 17 new multi-tenant isolation tests covering: - WorkspacePool config propagation and scope merging - Jobs handler per-user isolation (summary, restart, prompt, cancel) - Routines handler auth enforcement and cross-user rejection - Auth middleware enforcement on logs, skills, status endpoints Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: second-pass multi-tenant audit — scope SSE broadcasts, DB queries, dead handlers Second audit pass applying learned patterns across the codebase: - OAuth callback SSE broadcasts now use broadcast_for_user (lines 773, 912) - jobs_list_handler uses list_agent_jobs_for_user instead of fetching all users' jobs and filtering in Rust - list_agent_jobs_for_user added to Database trait + postgres + libsql - Dead handler files (extensions.rs, static_files.rs) hardened with AuthenticatedUser to prevent auth regression if migrated Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review findings — token hashing, broadcast scoping, error handling Security fixes: - Hash tokens with SHA-256 at construction time so authentication compares fixed-size 32-byte digests, eliminating length-oracle timing leaks - Scope auth SSE broadcasts per-user in chat_auth_token_handler — AuthRequired/AuthCompleted events were leaking across tenants - Propagate DB errors in restart handlers instead of silently swallowing via `if let Ok(Some(...))` pattern Code quality: - Log SSE serialization failures instead of silently producing empty strings via unwrap_or_default() - Remove dead `pub type AuthState = MultiAuthState` alias - Replace `.unwrap()` with `Arc::clone(db)` in app.rs multi-tenant workspace setup (db is guaranteed Some in context, but unwrap violates project convention) - Fix telegram setup test to inject UserIdentity into request extensions (handler now requires AuthenticatedUser) - Add safety comments on test-only expect/unwrap calls for CI - Apply cargo fmt to fix pre-existing formatting Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review findings — unify workspace pool, fix SSE regression, cache job owners - Unify WorkspacePool and PerUserWorkspaceResolver: WorkspacePool now implements WorkspaceResolver, eliminating duplicate per-user workspace construction logic. app.rs uses WorkspacePool directly. - Fix sse_tx: None scheduler regression: change scheduler/worker SSE broadcasting from broadcast::Sender<SseEvent> to Arc<SseManager>, restoring SSE event delivery for scheduled agent jobs. - Cache job owner in orchestrator: add job_owner_cache to OrchestratorState so job_event_handler avoids a DB round-trip on every event after the first per job. - Deduplicate ext_user_id computation in main.rs. - Remove unused _gateway_state variable. - Fix pre-existing test: first_token() returns None in multi-user mode by design; align test assertion. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: fix formatting in app.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor: extract memory handlers back into handlers/memory.rs Move memory API handlers out of server.rs into their own module, consistent with how jobs, routines, and skills handlers are organized. The resolve_workspace() helper moves with them since it is only used by memory handlers. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]> Co-authored-by: [email protected] <[email protected]>
671 lines
24 KiB
Rust
671 lines
24 KiB
Rust
//! Bearer token authentication middleware for the web gateway.
|
|
//!
|
|
//! Supports multi-user mode: each token maps to a `UserIdentity` that carries
|
|
//! the user_id. The identity is inserted into request extensions so downstream
|
|
//! handlers can extract it via `AuthenticatedUser`.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use axum::{
|
|
extract::{FromRequestParts, Request, State},
|
|
http::{HeaderMap, Method, StatusCode, request::Parts},
|
|
middleware::Next,
|
|
response::{IntoResponse, Response},
|
|
};
|
|
use sha2::{Digest, Sha256};
|
|
use subtle::ConstantTimeEq;
|
|
|
|
/// Identity resolved from a bearer token.
|
|
#[derive(Debug, Clone)]
|
|
pub struct UserIdentity {
|
|
pub user_id: String,
|
|
/// Additional user scopes this identity can read from.
|
|
pub workspace_read_scopes: Vec<String>,
|
|
}
|
|
|
|
/// Hash a token with SHA-256 for constant-size, timing-safe storage.
|
|
fn hash_token(token: &str) -> [u8; 32] {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(token.as_bytes());
|
|
hasher.finalize().into()
|
|
}
|
|
|
|
/// Multi-user auth state: maps token hashes to user identities.
|
|
///
|
|
/// Tokens are SHA-256 hashed on construction so they are never stored in
|
|
/// plaintext. Authentication compares fixed-size (32-byte) digests using
|
|
/// constant-time comparison, eliminating both length-oracle timing leaks
|
|
/// and accidental token exposure in memory dumps.
|
|
///
|
|
/// In single-user mode (the default), contains exactly one entry.
|
|
#[derive(Clone)]
|
|
pub struct MultiAuthState {
|
|
/// Maps SHA-256(token) → identity. Tokens are never stored in cleartext.
|
|
hashed_tokens: Vec<([u8; 32], UserIdentity)>,
|
|
/// Original first token kept only for single-user startup printing.
|
|
/// Not used for authentication.
|
|
display_token: Option<String>,
|
|
}
|
|
|
|
impl MultiAuthState {
|
|
/// Create a single-user auth state (backwards compatible).
|
|
pub fn single(token: String, user_id: String) -> Self {
|
|
let hash = hash_token(&token);
|
|
Self {
|
|
hashed_tokens: vec![(
|
|
hash,
|
|
UserIdentity {
|
|
user_id,
|
|
workspace_read_scopes: Vec::new(),
|
|
},
|
|
)],
|
|
display_token: Some(token),
|
|
}
|
|
}
|
|
|
|
/// Create a multi-user auth state from a map of tokens to identities.
|
|
pub fn multi(tokens: HashMap<String, UserIdentity>) -> Self {
|
|
let hashed_tokens: Vec<([u8; 32], UserIdentity)> = tokens
|
|
.into_iter()
|
|
.map(|(tok, identity)| (hash_token(&tok), identity))
|
|
.collect();
|
|
Self {
|
|
hashed_tokens,
|
|
display_token: None,
|
|
}
|
|
}
|
|
|
|
/// Authenticate a token, returning the associated identity if valid.
|
|
///
|
|
/// Uses SHA-256 hashing + constant-time comparison (`subtle::ConstantTimeEq`)
|
|
/// to prevent timing side-channels. Both the candidate and stored tokens are
|
|
/// hashed to 32-byte digests, eliminating length-oracle leaks. Iterates all
|
|
/// entries regardless of match to avoid early-exit timing differences.
|
|
/// O(n) in the number of configured users — negligible for typical
|
|
/// deployments (< 10 users).
|
|
pub fn authenticate(&self, candidate: &str) -> Option<&UserIdentity> {
|
|
let candidate_hash = hash_token(candidate);
|
|
let mut matched: Option<&UserIdentity> = None;
|
|
for (stored_hash, identity) in &self.hashed_tokens {
|
|
if bool::from(candidate_hash.ct_eq(stored_hash)) {
|
|
matched = Some(identity);
|
|
}
|
|
}
|
|
matched
|
|
}
|
|
|
|
/// Get the first token for backwards-compatible printing at startup.
|
|
///
|
|
/// Only available in single-user mode; returns `None` in multi-user mode
|
|
/// to avoid exposing tokens.
|
|
pub fn first_token(&self) -> Option<&str> {
|
|
self.display_token.as_deref()
|
|
}
|
|
|
|
/// Get the first user identity (for single-user fallback).
|
|
pub fn first_identity(&self) -> Option<&UserIdentity> {
|
|
self.hashed_tokens.first().map(|(_, id)| id)
|
|
}
|
|
}
|
|
|
|
/// Axum extractor that provides the authenticated user identity.
|
|
///
|
|
/// Only available on routes behind `auth_middleware`. Extracts the
|
|
/// `UserIdentity` that the middleware inserted into request extensions.
|
|
pub struct AuthenticatedUser(pub UserIdentity);
|
|
|
|
impl<S> FromRequestParts<S> for AuthenticatedUser
|
|
where
|
|
S: Send + Sync,
|
|
{
|
|
type Rejection = (StatusCode, &'static str);
|
|
|
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
|
parts
|
|
.extensions
|
|
.get::<UserIdentity>()
|
|
.cloned()
|
|
.map(AuthenticatedUser)
|
|
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated"))
|
|
}
|
|
}
|
|
|
|
/// Whether query-string token auth is allowed for this request.
|
|
///
|
|
/// Only GET requests to streaming endpoints may use `?token=xxx`. This
|
|
/// minimizes token-in-URL exposure on state-changing routes, where the token
|
|
/// would leak via server logs, Referer headers, and browser history.
|
|
///
|
|
/// Allowed endpoints:
|
|
/// - SSE: `/api/chat/events`, `/api/logs/events` (EventSource can't set headers)
|
|
/// - WebSocket: `/api/chat/ws` (WS upgrade can't set custom headers)
|
|
///
|
|
/// If you add a new SSE or WebSocket endpoint, add its path here.
|
|
fn allows_query_token_auth(request: &Request) -> bool {
|
|
if request.method() != Method::GET {
|
|
return false;
|
|
}
|
|
|
|
matches!(
|
|
request.uri().path(),
|
|
"/api/chat/events" | "/api/logs/events" | "/api/chat/ws"
|
|
)
|
|
}
|
|
|
|
/// Extract the `token` query parameter value, URL-decoded.
|
|
fn query_token(request: &Request) -> Option<String> {
|
|
let query = request.uri().query()?;
|
|
url::form_urlencoded::parse(query.as_bytes()).find_map(|(k, v)| {
|
|
if k == "token" {
|
|
Some(v.into_owned())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Auth middleware that validates bearer token from header or query param.
|
|
///
|
|
/// SSE connections can't set headers from `EventSource`, so we also accept
|
|
/// `?token=xxx` as a query parameter, but only on SSE/WS endpoints.
|
|
///
|
|
/// On successful authentication, inserts the matching `UserIdentity` into
|
|
/// request extensions for downstream extraction via `AuthenticatedUser`.
|
|
pub async fn auth_middleware(
|
|
State(auth): State<MultiAuthState>,
|
|
headers: HeaderMap,
|
|
mut request: Request,
|
|
next: Next,
|
|
) -> Response {
|
|
// Try Authorization header first.
|
|
// RFC 6750 Section 2.1: auth-scheme comparison is case-insensitive.
|
|
if let Some(auth_header) = headers.get("authorization")
|
|
&& let Ok(value) = auth_header.to_str()
|
|
&& value.len() > 7
|
|
&& value[..7].eq_ignore_ascii_case("Bearer ")
|
|
&& let Some(identity) = auth.authenticate(&value[7..])
|
|
{
|
|
request.extensions_mut().insert(identity.clone());
|
|
return next.run(request).await;
|
|
}
|
|
|
|
// Fall back to query parameter, but only for SSE/WS endpoints.
|
|
if allows_query_token_auth(&request)
|
|
&& let Some(token) = query_token(&request)
|
|
&& let Some(identity) = auth.authenticate(&token)
|
|
{
|
|
request.extensions_mut().insert(identity.clone());
|
|
return next.run(request).await;
|
|
}
|
|
|
|
(StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::testing::credentials::TEST_AUTH_SECRET_TOKEN;
|
|
|
|
#[test]
|
|
fn test_multi_auth_state_single() {
|
|
let state = MultiAuthState::single("tok-123".to_string(), "alice".to_string());
|
|
let identity = state.authenticate("tok-123");
|
|
assert!(identity.is_some());
|
|
assert_eq!(identity.unwrap().user_id, "alice");
|
|
}
|
|
|
|
#[test]
|
|
fn test_multi_auth_state_reject_wrong_token() {
|
|
let state = MultiAuthState::single("tok-123".to_string(), "alice".to_string());
|
|
assert!(state.authenticate("wrong-token").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_multi_auth_state_multi_users() {
|
|
let mut tokens = HashMap::new();
|
|
tokens.insert(
|
|
"tok-alice".to_string(),
|
|
UserIdentity {
|
|
user_id: "alice".to_string(),
|
|
workspace_read_scopes: Vec::new(),
|
|
},
|
|
);
|
|
tokens.insert(
|
|
"tok-bob".to_string(),
|
|
UserIdentity {
|
|
user_id: "bob".to_string(),
|
|
workspace_read_scopes: Vec::new(),
|
|
},
|
|
);
|
|
let state = MultiAuthState::multi(tokens);
|
|
|
|
let alice = state.authenticate("tok-alice").unwrap();
|
|
assert_eq!(alice.user_id, "alice");
|
|
|
|
let bob = state.authenticate("tok-bob").unwrap();
|
|
assert_eq!(bob.user_id, "bob");
|
|
|
|
assert!(state.authenticate("tok-charlie").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_multi_auth_state_first_token() {
|
|
let state = MultiAuthState::single("my-token".to_string(), "user1".to_string());
|
|
assert_eq!(state.first_token(), Some("my-token"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_multi_auth_state_first_identity() {
|
|
let state = MultiAuthState::single("my-token".to_string(), "user1".to_string());
|
|
let identity = state.first_identity().unwrap();
|
|
assert_eq!(identity.user_id, "user1");
|
|
}
|
|
|
|
use axum::Router;
|
|
use axum::body::Body;
|
|
use axum::middleware;
|
|
use axum::routing::{get, post};
|
|
use tower::ServiceExt;
|
|
|
|
async fn dummy_handler() -> &'static str {
|
|
"ok"
|
|
}
|
|
|
|
/// Router with streaming endpoints (query auth allowed) and regular
|
|
/// endpoints (query auth rejected).
|
|
fn test_app(token: &str) -> Router {
|
|
let state = MultiAuthState::single(token.to_string(), "test-user".to_string());
|
|
Router::new()
|
|
.route("/api/chat/events", get(dummy_handler))
|
|
.route("/api/logs/events", get(dummy_handler))
|
|
.route("/api/chat/ws", get(dummy_handler))
|
|
.route("/api/chat/history", get(dummy_handler))
|
|
.route("/api/chat/send", post(dummy_handler))
|
|
.layer(middleware::from_fn_with_state(state, auth_middleware))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_valid_bearer_token_passes() {
|
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
|
let req = Request::builder()
|
|
.uri("/api/chat/events")
|
|
.header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_invalid_bearer_token_rejected() {
|
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
|
let req = Request::builder()
|
|
.uri("/api/chat/events")
|
|
.header("Authorization", "Bearer wrong-token")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_query_token_allowed_for_chat_events() {
|
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
|
let req = Request::builder()
|
|
.uri(format!("/api/chat/events?token={TEST_AUTH_SECRET_TOKEN}"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_query_token_allowed_for_logs_events() {
|
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
|
let req = Request::builder()
|
|
.uri(format!("/api/logs/events?token={TEST_AUTH_SECRET_TOKEN}"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_query_token_allowed_for_ws_upgrade() {
|
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
|
let req = Request::builder()
|
|
.uri(format!("/api/chat/ws?token={TEST_AUTH_SECRET_TOKEN}"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_query_token_url_encoded() {
|
|
// Token with characters that get percent-encoded in URLs.
|
|
let raw_token = "tok+en/with spaces";
|
|
let app = test_app(raw_token);
|
|
let req = Request::builder()
|
|
.uri("/api/chat/events?token=tok%2Ben%2Fwith%20spaces")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_query_token_url_encoded_mismatch() {
|
|
let app = test_app("real-token");
|
|
// Encoded value decodes to "wrong-token", not "real-token".
|
|
let req = Request::builder()
|
|
.uri("/api/chat/events?token=wrong%2Dtoken")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_query_token_rejected_for_non_sse_get() {
|
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
|
let req = Request::builder()
|
|
.uri(format!("/api/chat/history?token={TEST_AUTH_SECRET_TOKEN}"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_query_token_rejected_for_post() {
|
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
|
let req = Request::builder()
|
|
.method(Method::POST)
|
|
.uri(format!("/api/chat/send?token={TEST_AUTH_SECRET_TOKEN}"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_query_token_invalid_rejected() {
|
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
|
let req = Request::builder()
|
|
.uri("/api/chat/events?token=wrong-token")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_no_auth_at_all_rejected() {
|
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
|
let req = Request::builder()
|
|
.uri("/api/chat/events")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_bearer_header_works_for_post() {
|
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
|
let req = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/api/chat/send")
|
|
.header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_bearer_prefix_case_insensitive() {
|
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
|
let req = Request::builder()
|
|
.uri("/api/chat/events")
|
|
.header("Authorization", format!("bearer {TEST_AUTH_SECRET_TOKEN}"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_bearer_prefix_mixed_case() {
|
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
|
let req = Request::builder()
|
|
.uri("/api/chat/events")
|
|
.header("Authorization", format!("BEARER {TEST_AUTH_SECRET_TOKEN}"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_empty_bearer_token_rejected() {
|
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
|
let req = Request::builder()
|
|
.uri("/api/chat/events")
|
|
.header("Authorization", "Bearer ")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_token_with_whitespace_rejected() {
|
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
|
let req = Request::builder()
|
|
.uri("/api/chat/events")
|
|
.header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
// --- Multi-tenant auth integration tests ---
|
|
|
|
/// Handler that extracts `AuthenticatedUser` and returns the resolved user_id.
|
|
async fn identity_handler(AuthenticatedUser(identity): AuthenticatedUser) -> String {
|
|
identity.user_id
|
|
}
|
|
|
|
/// Handler that extracts `AuthenticatedUser` and returns workspace_read_scopes as JSON.
|
|
async fn scopes_handler(AuthenticatedUser(identity): AuthenticatedUser) -> String {
|
|
serde_json::to_string(&identity.workspace_read_scopes).unwrap()
|
|
}
|
|
|
|
/// Build a multi-user router where each token maps to a distinct identity.
|
|
fn multi_user_app(tokens: HashMap<String, UserIdentity>) -> Router {
|
|
let state = MultiAuthState::multi(tokens);
|
|
Router::new()
|
|
.route("/api/chat/events", get(identity_handler))
|
|
.route("/api/chat/send", post(identity_handler))
|
|
.route("/api/scopes", get(scopes_handler))
|
|
.layer(middleware::from_fn_with_state(state, auth_middleware))
|
|
}
|
|
|
|
fn two_user_tokens() -> HashMap<String, UserIdentity> {
|
|
let mut tokens = HashMap::new();
|
|
tokens.insert(
|
|
"tok-alice".to_string(),
|
|
UserIdentity {
|
|
user_id: "alice".to_string(),
|
|
workspace_read_scopes: vec!["shared".to_string()],
|
|
},
|
|
);
|
|
tokens.insert(
|
|
"tok-bob".to_string(),
|
|
UserIdentity {
|
|
user_id: "bob".to_string(),
|
|
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
|
|
},
|
|
);
|
|
tokens
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_multi_user_alice_token_resolves_to_alice() {
|
|
let app = multi_user_app(two_user_tokens());
|
|
let req = Request::builder()
|
|
.uri("/api/chat/events")
|
|
.header("Authorization", "Bearer tok-alice")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
|
assert_eq!(body, "alice");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_multi_user_bob_token_resolves_to_bob() {
|
|
let app = multi_user_app(two_user_tokens());
|
|
let req = Request::builder()
|
|
.uri("/api/chat/events")
|
|
.header("Authorization", "Bearer tok-bob")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
|
assert_eq!(body, "bob");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_multi_user_sequential_tokens_resolve_independently() {
|
|
// Send both alice and bob tokens sequentially and verify each gets
|
|
// the correct identity — guards against token map corruption.
|
|
let tokens = two_user_tokens();
|
|
|
|
let app1 = multi_user_app(tokens.clone());
|
|
let req = Request::builder()
|
|
.uri("/api/chat/events")
|
|
.header("Authorization", "Bearer tok-alice")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app1.oneshot(req).await.unwrap();
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
|
assert_eq!(body, "alice");
|
|
|
|
let app2 = multi_user_app(tokens);
|
|
let req = Request::builder()
|
|
.uri("/api/chat/events")
|
|
.header("Authorization", "Bearer tok-bob")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app2.oneshot(req).await.unwrap();
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
|
assert_eq!(body, "bob");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_multi_user_unknown_token_rejected() {
|
|
let app = multi_user_app(two_user_tokens());
|
|
let req = Request::builder()
|
|
.uri("/api/chat/events")
|
|
.header("Authorization", "Bearer tok-charlie")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_multi_user_workspace_read_scopes_propagated() {
|
|
let app = multi_user_app(two_user_tokens());
|
|
|
|
// Alice has ["shared"]
|
|
let req = Request::builder()
|
|
.uri("/api/scopes")
|
|
.header("Authorization", "Bearer tok-alice")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
|
let scopes: Vec<String> = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(scopes, vec!["shared"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_multi_user_bob_has_two_scopes() {
|
|
let app = multi_user_app(two_user_tokens());
|
|
|
|
// Bob has ["shared", "alice"]
|
|
let req = Request::builder()
|
|
.uri("/api/scopes")
|
|
.header("Authorization", "Bearer tok-bob")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
|
let scopes: Vec<String> = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(scopes, vec!["shared", "alice"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_multi_user_query_param_resolves_correct_identity() {
|
|
let app = multi_user_app(two_user_tokens());
|
|
let req = Request::builder()
|
|
.uri("/api/chat/events?token=tok-bob")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
|
assert_eq!(body, "bob");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_multi_user_post_with_bearer_resolves_identity() {
|
|
let app = multi_user_app(two_user_tokens());
|
|
let req = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/api/chat/send")
|
|
.header("Authorization", "Bearer tok-alice")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
|
assert_eq!(body, "alice");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_multi_user_empty_scopes_for_single_user() {
|
|
// Single-user mode creates identity with empty workspace_read_scopes.
|
|
let state = MultiAuthState::single("tok-only".to_string(), "solo".to_string());
|
|
let app = Router::new()
|
|
.route("/api/scopes", get(scopes_handler))
|
|
.layer(middleware::from_fn_with_state(state, auth_middleware));
|
|
let req = Request::builder()
|
|
.uri("/api/scopes")
|
|
.header("Authorization", "Bearer tok-only")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
|
let scopes: Vec<String> = serde_json::from_slice(&body).unwrap();
|
|
assert!(scopes.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_prefix_and_extension_tokens_rejected() {
|
|
// Verifies that prefix/suffix variants of valid tokens are rejected.
|
|
// Note: the constant-time property is enforced structurally by use of
|
|
// subtle::ConstantTimeEq and cannot be verified via outcome testing.
|
|
let state = MultiAuthState::single("long-secret-token".to_string(), "user".to_string());
|
|
assert!(state.authenticate("long-secret").is_none());
|
|
assert!(state.authenticate("long-secret-token-extra").is_none());
|
|
}
|
|
}
|