feat(auth): add DB-based token authentication for dashboard-created clients
Some checks failed
CI / Check (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Release Build (push) Has been cancelled

Add client_tokens table with auto-generated sk-{hex} tokens so clients
created in the dashboard get working API keys. Auth flow: DB token lookup
first, then env token fallback, then permissive mode. Includes token
management CRUD endpoints and copy-once reveal modal in the frontend.
This commit is contained in:
2026-03-02 15:14:12 -05:00
parent 4e53b05126
commit 54c45cbfca
8 changed files with 373 additions and 24 deletions

View File

@@ -304,6 +304,7 @@ pub mod middleware {
middleware::Next,
response::Response,
};
use sqlx;
/// Rate limiting middleware
pub async fn rate_limit_middleware(
@@ -311,8 +312,11 @@ pub mod middleware {
request: Request,
next: Next,
) -> Result<Response, AppError> {
// Extract client ID from authentication header
let client_id = extract_client_id_from_request(&request);
// Extract token synchronously from headers (avoids holding &Request across await)
let token = extract_bearer_token(&request);
// Resolve client_id: DB token lookup, then prefix fallback
let client_id = resolve_client_id(token, &state).await;
// Check rate limits
if !state.rate_limit_manager.check_client_request(&client_id).await? {
@@ -322,18 +326,33 @@ pub mod middleware {
Ok(next.run(request).await)
}
/// Extract client ID from request (helper function)
fn extract_client_id_from_request(request: &Request) -> String {
// Try to extract from Authorization header
if let Some(auth_header) = request.headers().get("Authorization")
&& let Ok(auth_str) = auth_header.to_str()
&& let Some(token) = auth_str.strip_prefix("Bearer ")
{
// Use token hash as client ID (same logic as auth module)
/// Synchronously extract bearer token from request headers
fn extract_bearer_token(request: &Request) -> Option<String> {
request.headers().get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.map(|t| t.to_string())
}
/// Resolve client ID: try DB token first, then fall back to token-prefix derivation
async fn resolve_client_id(token: Option<String>, state: &AppState) -> String {
if let Some(token) = token {
// Try DB token lookup first
if let Ok(Some(cid)) = sqlx::query_scalar::<_, String>(
"SELECT client_id FROM client_tokens WHERE token = ? AND is_active = TRUE",
)
.bind(&token)
.fetch_optional(&state.db_pool)
.await
{
return cid;
}
// Fallback to token-prefix derivation (env tokens / permissive mode)
return format!("client_{}", &token[..8.min(token.len())]);
}
// Fallback to anonymous
// No token — anonymous
"anonymous".to_string()
}