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

2
Cargo.lock generated
View File

@@ -1444,10 +1444,12 @@ dependencies = [
"dotenvy", "dotenvy",
"futures", "futures",
"headers", "headers",
"hex",
"image", "image",
"insta", "insta",
"mime", "mime",
"mockito", "mockito",
"rand 0.9.2",
"reqwest", "reqwest",
"reqwest-eventsource", "reqwest-eventsource",
"serde", "serde",

View File

@@ -52,6 +52,8 @@ futures = "0.3"
async-trait = "0.1" async-trait = "0.1"
async-stream = "0.3" async-stream = "0.3"
reqwest-eventsource = "0.6" reqwest-eventsource = "0.6"
rand = "0.9"
hex = "0.4"
[dev-dependencies] [dev-dependencies]
tokio-test = "0.4" tokio-test = "0.4"

View File

@@ -3,6 +3,7 @@ use axum::{
response::Json, response::Json,
}; };
use chrono; use chrono;
use rand::Rng;
use serde::Deserialize; use serde::Deserialize;
use serde_json; use serde_json;
use sqlx::Row; use sqlx::Row;
@@ -11,6 +12,13 @@ use uuid;
use super::{ApiResponse, DashboardState}; use super::{ApiResponse, DashboardState};
/// Generate a random API token: sk-{48 hex chars}
fn generate_token() -> String {
let mut rng = rand::rng();
let bytes: Vec<u8> = (0..24).map(|_| rng.random::<u8>()).collect();
format!("sk-{}", hex::encode(bytes))
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub(super) struct CreateClientRequest { pub(super) struct CreateClientRequest {
pub(super) name: String, pub(super) name: String,
@@ -98,12 +106,29 @@ pub(super) async fn handle_create_client(
.await; .await;
match result { match result {
Ok(row) => Json(ApiResponse::success(serde_json::json!({ Ok(row) => {
"id": row.get::<String, _>("client_id"), // Auto-generate a token for the new client
"name": row.get::<Option<String>, _>("name"), let token = generate_token();
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"), let token_result = sqlx::query(
"status": "active", "INSERT INTO client_tokens (client_id, token, name) VALUES (?, ?, 'default')",
}))), )
.bind(&client_id)
.bind(&token)
.execute(pool)
.await;
if let Err(e) = token_result {
warn!("Client created but failed to generate token: {}", e);
}
Json(ApiResponse::success(serde_json::json!({
"id": row.get::<String, _>("client_id"),
"name": row.get::<Option<String>, _>("name"),
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
"status": "active",
"token": token,
})))
}
Err(e) => { Err(e) => {
warn!("Failed to create client: {}", e); warn!("Failed to create client: {}", e);
Json(ApiResponse::error(format!("Failed to create client: {}", e))) Json(ApiResponse::error(format!("Failed to create client: {}", e)))
@@ -333,3 +358,131 @@ pub(super) async fn handle_client_usage(
} }
} }
} }
// ── Token management endpoints ──────────────────────────────────────
pub(super) async fn handle_get_client_tokens(
State(state): State<DashboardState>,
Path(id): Path<String>,
) -> Json<ApiResponse<serde_json::Value>> {
let pool = &state.app_state.db_pool;
let result = sqlx::query(
r#"
SELECT id, token, name, is_active, created_at, last_used_at
FROM client_tokens
WHERE client_id = ?
ORDER BY created_at DESC
"#,
)
.bind(&id)
.fetch_all(pool)
.await;
match result {
Ok(rows) => {
let tokens: Vec<serde_json::Value> = rows
.into_iter()
.map(|row| {
let token: String = row.get("token");
// Mask all but last 8 chars: sk-••••abcd1234
let masked = if token.len() > 8 {
format!("{}••••{}", &token[..3], &token[token.len() - 8..])
} else {
"••••".to_string()
};
serde_json::json!({
"id": row.get::<i64, _>("id"),
"token_masked": masked,
"name": row.get::<Option<String>, _>("name").unwrap_or_else(|| "default".to_string()),
"is_active": row.get::<bool, _>("is_active"),
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
"last_used_at": row.get::<Option<chrono::DateTime<chrono::Utc>>, _>("last_used_at"),
})
})
.collect();
Json(ApiResponse::success(serde_json::json!(tokens)))
}
Err(e) => {
warn!("Failed to fetch client tokens: {}", e);
Json(ApiResponse::error(format!("Failed to fetch client tokens: {}", e)))
}
}
}
#[derive(Deserialize)]
pub(super) struct CreateTokenRequest {
pub(super) name: Option<String>,
}
pub(super) async fn handle_create_client_token(
State(state): State<DashboardState>,
Path(id): Path<String>,
Json(payload): Json<CreateTokenRequest>,
) -> Json<ApiResponse<serde_json::Value>> {
let pool = &state.app_state.db_pool;
// Verify client exists
let exists: Option<(i64,)> = sqlx::query_as("SELECT 1 as x FROM clients WHERE client_id = ?")
.bind(&id)
.fetch_optional(pool)
.await
.unwrap_or(None);
if exists.is_none() {
return Json(ApiResponse::error(format!("Client '{}' not found", id)));
}
let token = generate_token();
let token_name = payload.name.unwrap_or_else(|| "default".to_string());
let result = sqlx::query(
"INSERT INTO client_tokens (client_id, token, name) VALUES (?, ?, ?) RETURNING id, created_at",
)
.bind(&id)
.bind(&token)
.bind(&token_name)
.fetch_one(pool)
.await;
match result {
Ok(row) => Json(ApiResponse::success(serde_json::json!({
"id": row.get::<i64, _>("id"),
"token": token,
"name": token_name,
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
}))),
Err(e) => {
warn!("Failed to create client token: {}", e);
Json(ApiResponse::error(format!("Failed to create token: {}", e)))
}
}
}
pub(super) async fn handle_delete_client_token(
State(state): State<DashboardState>,
Path((client_id, token_id)): Path<(String, i64)>,
) -> Json<ApiResponse<serde_json::Value>> {
let pool = &state.app_state.db_pool;
let result = sqlx::query("DELETE FROM client_tokens WHERE id = ? AND client_id = ?")
.bind(token_id)
.bind(&client_id)
.execute(pool)
.await;
match result {
Ok(r) => {
if r.rows_affected() == 0 {
Json(ApiResponse::error("Token not found".to_string()))
} else {
Json(ApiResponse::success(serde_json::json!({ "message": "Token revoked" })))
}
}
Err(e) => {
warn!("Failed to delete client token: {}", e);
Json(ApiResponse::error(format!("Failed to revoke token: {}", e)))
}
}
}

View File

@@ -11,7 +11,7 @@ mod websocket;
use axum::{ use axum::{
Router, Router,
routing::{get, post, put}, routing::{delete, get, post, put},
}; };
use serde::Serialize; use serde::Serialize;
@@ -87,6 +87,14 @@ pub fn router(state: AppState) -> Router {
.delete(clients::handle_delete_client), .delete(clients::handle_delete_client),
) )
.route("/api/clients/{id}/usage", get(clients::handle_client_usage)) .route("/api/clients/{id}/usage", get(clients::handle_client_usage))
.route(
"/api/clients/{id}/tokens",
get(clients::handle_get_client_tokens).post(clients::handle_create_client_token),
)
.route(
"/api/clients/{id}/tokens/{token_id}",
delete(clients::handle_delete_client_token),
)
.route("/api/providers", get(providers::handle_get_providers)) .route("/api/providers", get(providers::handle_get_providers))
.route( .route(
"/api/providers/{name}", "/api/providers/{name}",

View File

@@ -130,6 +130,24 @@ async fn run_migrations(pool: &DbPool) -> Result<()> {
.execute(pool) .execute(pool)
.await?; .await?;
// Create client_tokens table for DB-based token auth
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS client_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
name TEXT DEFAULT 'default',
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME,
FOREIGN KEY (client_id) REFERENCES clients(client_id) ON DELETE CASCADE
)
"#,
)
.execute(pool)
.await?;
// Add must_change_password column if it doesn't exist (migration for existing DBs) // Add must_change_password column if it doesn't exist (migration for existing DBs)
let _ = sqlx::query("ALTER TABLE users ADD COLUMN must_change_password BOOLEAN DEFAULT FALSE") let _ = sqlx::query("ALTER TABLE users ADD COLUMN must_change_password BOOLEAN DEFAULT FALSE")
.execute(pool) .execute(pool)
@@ -184,6 +202,14 @@ async fn run_migrations(pool: &DbPool) -> Result<()> {
.execute(pool) .execute(pool)
.await?; .await?;
sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_client_tokens_token ON client_tokens(token)")
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_client_tokens_client_id ON client_tokens(client_id)")
.execute(pool)
.await?;
// Insert default client if none exists // Insert default client if none exists
sqlx::query( sqlx::query(
r#" r#"

View File

@@ -304,6 +304,7 @@ pub mod middleware {
middleware::Next, middleware::Next,
response::Response, response::Response,
}; };
use sqlx;
/// Rate limiting middleware /// Rate limiting middleware
pub async fn rate_limit_middleware( pub async fn rate_limit_middleware(
@@ -311,8 +312,11 @@ pub mod middleware {
request: Request, request: Request,
next: Next, next: Next,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// Extract client ID from authentication header // Extract token synchronously from headers (avoids holding &Request across await)
let client_id = extract_client_id_from_request(&request); 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 // Check rate limits
if !state.rate_limit_manager.check_client_request(&client_id).await? { if !state.rate_limit_manager.check_client_request(&client_id).await? {
@@ -322,18 +326,33 @@ pub mod middleware {
Ok(next.run(request).await) Ok(next.run(request).await)
} }
/// Extract client ID from request (helper function) /// Synchronously extract bearer token from request headers
fn extract_client_id_from_request(request: &Request) -> String { fn extract_bearer_token(request: &Request) -> Option<String> {
// Try to extract from Authorization header request.headers().get("Authorization")
if let Some(auth_header) = request.headers().get("Authorization") .and_then(|v| v.to_str().ok())
&& let Ok(auth_str) = auth_header.to_str() .and_then(|s| s.strip_prefix("Bearer "))
&& let Some(token) = auth_str.strip_prefix("Bearer ") .map(|t| t.to_string())
{ }
// Use token hash as client ID (same logic as auth module)
/// 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())]); return format!("client_{}", &token[..8.min(token.len())]);
} }
// Fallback to anonymous // No token — anonymous
"anonymous".to_string() "anonymous".to_string()
} }

View File

@@ -6,6 +6,7 @@ use axum::{
routing::{get, post}, routing::{get, post},
}; };
use futures::stream::StreamExt; use futures::stream::StreamExt;
use sqlx;
use std::sync::Arc; use std::sync::Arc;
use tracing::{info, warn}; use tracing::{info, warn};
use uuid::Uuid; use uuid::Uuid;
@@ -119,13 +120,34 @@ async fn chat_completions(
auth: AuthenticatedClient, auth: AuthenticatedClient,
Json(mut request): Json<ChatCompletionRequest>, Json(mut request): Json<ChatCompletionRequest>,
) -> Result<axum::response::Response, AppError> { ) -> Result<axum::response::Response, AppError> {
// Validate token against configured auth tokens // Resolve client_id: try DB token first, then env tokens, then permissive fallback
if !state.auth_tokens.is_empty() && !state.auth_tokens.contains(&auth.token) { let db_client_id: Option<String> = sqlx::query_scalar::<_, String>(
"SELECT client_id FROM client_tokens WHERE token = ? AND is_active = TRUE",
)
.bind(&auth.token)
.fetch_optional(&state.db_pool)
.await
.unwrap_or(None);
let client_id = if let Some(cid) = db_client_id {
// Update last_used_at in background (fire-and-forget)
let pool = state.db_pool.clone();
let token = auth.token.clone();
tokio::spawn(async move {
let _ = sqlx::query("UPDATE client_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token = ?")
.bind(&token)
.execute(&pool)
.await;
});
cid
} else if state.auth_tokens.is_empty() || state.auth_tokens.contains(&auth.token) {
// Env token match or permissive mode (no env tokens configured)
auth.client_id.clone()
} else {
return Err(AppError::AuthError("Invalid authentication token".to_string())); return Err(AppError::AuthError("Invalid authentication token".to_string()));
} };
let start_time = std::time::Instant::now(); let start_time = std::time::Instant::now();
let client_id = auth.client_id.clone();
let model = request.model.clone(); let model = request.model.clone();
info!("Chat completion request from client {} for model {}", client_id, model); info!("Chat completion request from client {} for model {}", client_id, model);

View File

@@ -167,16 +167,61 @@ class ClientsPage {
} }
try { try {
await window.api.post('/clients', { name, client_id: id || null }); const result = await window.api.post('/clients', { name, client_id: id || null });
window.authManager.showToast(`Client "${name}" created`, 'success');
modal.remove(); modal.remove();
this.loadClients(); this.loadClients();
// Show the generated token (copy-once dialog)
if (result.token) {
this.showTokenRevealModal(name, result.token);
} else {
window.authManager.showToast(`Client "${name}" created`, 'success');
}
} catch (error) { } catch (error) {
window.authManager.showToast(error.message, 'error'); window.authManager.showToast(error.message, 'error');
} }
}; };
} }
showTokenRevealModal(clientName, token) {
const modal = document.createElement('div');
modal.className = 'modal active';
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Client Created: ${clientName}</h3>
</div>
<div class="modal-body">
<p style="margin-bottom: 0.75rem; color: var(--yellow);">
<i class="fas fa-exclamation-triangle"></i>
Copy this token now. It will not be shown again.
</p>
<div class="form-control">
<label>API Token</label>
<div style="display: flex; gap: 0.5rem;">
<input type="text" id="revealed-token" value="${token}" readonly
style="font-family: monospace; font-size: 0.85rem;">
<button class="btn btn-secondary" id="copy-token-btn" title="Copy">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="this.closest('.modal').remove()">Done</button>
</div>
</div>
`;
document.body.appendChild(modal);
modal.querySelector('#copy-token-btn').onclick = () => {
navigator.clipboard.writeText(token).then(() => {
window.authManager.showToast('Token copied to clipboard', 'success');
});
};
}
async deleteClient(id) { async deleteClient(id) {
if (!confirm(`Are you sure you want to delete client ${id}? This cannot be undone.`)) return; if (!confirm(`Are you sure you want to delete client ${id}? This cannot be undone.`)) return;
@@ -228,6 +273,18 @@ class ClientsPage {
<span>Active</span> <span>Active</span>
</label> </label>
</div> </div>
<hr style="border-color: var(--bg3); margin: 1rem 0;">
<div class="form-control">
<label style="display: flex; justify-content: space-between; align-items: center;">
<span>API Tokens</span>
<button class="btn btn-secondary" id="generate-token-btn" style="padding: 0.25rem 0.5rem; font-size: 0.8rem;">
<i class="fas fa-plus"></i> Generate
</button>
</label>
<div id="tokens-list" style="margin-top: 0.5rem;">
<div style="color: var(--fg4); font-size: 0.85rem;">Loading tokens...</div>
</div>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" onclick="this.closest('.modal').remove()">Cancel</button> <button class="btn btn-secondary" onclick="this.closest('.modal').remove()">Cancel</button>
@@ -238,6 +295,21 @@ class ClientsPage {
document.body.appendChild(modal); document.body.appendChild(modal);
// Load tokens
this.loadTokensList(client.id, modal);
modal.querySelector('#generate-token-btn').onclick = async () => {
try {
const result = await window.api.post(`/clients/${client.id}/tokens`, { name: null });
if (result.token) {
this.showTokenRevealModal(`${client.name} - New Token`, result.token);
}
this.loadTokensList(client.id, modal);
} catch (error) {
window.authManager.showToast(error.message, 'error');
}
};
modal.querySelector('#confirm-edit-client').onclick = async () => { modal.querySelector('#confirm-edit-client').onclick = async () => {
const name = modal.querySelector('#edit-client-name').value.trim(); const name = modal.querySelector('#edit-client-name').value.trim();
const description = modal.querySelector('#edit-client-description').value.trim(); const description = modal.querySelector('#edit-client-description').value.trim();
@@ -266,6 +338,51 @@ class ClientsPage {
} }
}; };
} }
async loadTokensList(clientId, modal) {
const container = modal.querySelector('#tokens-list');
if (!container) return;
try {
const tokens = await window.api.get(`/clients/${clientId}/tokens`);
if (!tokens || tokens.length === 0) {
container.innerHTML = '<div style="color: var(--fg4); font-size: 0.85rem;">No tokens. Generate one to enable API access.</div>';
return;
}
container.innerHTML = tokens.map(t => {
const lastUsed = t.last_used_at
? luxon.DateTime.fromISO(t.last_used_at).toRelative()
: 'Never';
return `
<div style="display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid var(--bg3);">
<code style="flex: 1; font-size: 0.8rem; color: var(--fg2);">${t.token_masked}</code>
<span style="font-size: 0.75rem; color: var(--fg4);" title="Last used">${lastUsed}</span>
<button class="btn-action danger" title="Revoke" style="padding: 0.2rem 0.4rem;"
onclick="window.clientsPage.revokeToken('${clientId}', ${t.id}, this)">
<i class="fas fa-trash" style="font-size: 0.75rem;"></i>
</button>
</div>
`;
}).join('');
} catch (error) {
container.innerHTML = '<div style="color: var(--red); font-size: 0.85rem;">Failed to load tokens</div>';
}
}
async revokeToken(clientId, tokenId, btn) {
if (!confirm('Revoke this token? Any services using it will lose access.')) return;
try {
await window.api.delete(`/clients/${clientId}/tokens/${tokenId}`);
window.authManager.showToast('Token revoked', 'success');
// Reload tokens list in the open modal
const modal = btn.closest('.modal');
if (modal) this.loadTokensList(clientId, modal);
} catch (error) {
window.authManager.showToast(error.message, 'error');
}
}
} }
window.initClients = async () => { window.initClients = async () => {