Scaffold dd0c/route core proxy engine (handler, router, auth, config)
This commit is contained in:
105
products/01-llm-cost-router/src/auth/mod.rs
Normal file
105
products/01-llm-cost-router/src/auth/mod.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use axum::http::HeaderMap;
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthContext {
|
||||
pub org_id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub role: Role,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Role {
|
||||
Owner,
|
||||
Member,
|
||||
Viewer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthError {
|
||||
#[error("Invalid API key")]
|
||||
InvalidKey,
|
||||
#[error("Expired token")]
|
||||
ExpiredToken,
|
||||
#[error("Missing authorization header")]
|
||||
MissingAuth,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AuthProvider: Send + Sync {
|
||||
async fn authenticate(&self, headers: &HeaderMap) -> Result<AuthContext, AuthError>;
|
||||
}
|
||||
|
||||
/// Local auth — bcrypt passwords + HS256 JWT (self-hosted mode)
|
||||
pub struct LocalAuthProvider {
|
||||
pool: sqlx::PgPool,
|
||||
jwt_secret: String,
|
||||
redis: deadpool_redis::Pool,
|
||||
}
|
||||
|
||||
impl LocalAuthProvider {
|
||||
pub fn new(pool: sqlx::PgPool, jwt_secret: String, redis: deadpool_redis::Pool) -> Self {
|
||||
Self { pool, jwt_secret, redis }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthProvider for LocalAuthProvider {
|
||||
async fn authenticate(&self, headers: &HeaderMap) -> Result<AuthContext, AuthError> {
|
||||
let key = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or(AuthError::MissingAuth)?;
|
||||
|
||||
// 1. Check Redis cache first
|
||||
if let Ok(mut conn) = self.redis.get().await {
|
||||
let cached: Option<String> = redis::cmd("GET")
|
||||
.arg(format!("apikey:{}", &key[..8])) // prefix lookup
|
||||
.query_async(&mut *conn)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
if let Some(org_id) = cached {
|
||||
return Ok(AuthContext {
|
||||
org_id,
|
||||
user_id: None,
|
||||
role: Role::Member,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fall back to PostgreSQL
|
||||
let row = sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT org_id, key_hash FROM api_keys WHERE key_prefix = $1 AND revoked_at IS NULL"
|
||||
)
|
||||
.bind(&key[..8])
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|_| AuthError::InvalidKey)?
|
||||
.ok_or(AuthError::InvalidKey)?;
|
||||
|
||||
// 3. Verify bcrypt hash
|
||||
let valid = bcrypt::verify(key, &row.1).unwrap_or(false);
|
||||
if !valid {
|
||||
return Err(AuthError::InvalidKey);
|
||||
}
|
||||
|
||||
// 4. Cache in Redis for next time (5 min TTL)
|
||||
if let Ok(mut conn) = self.redis.get().await {
|
||||
let _: Result<(), _> = redis::cmd("SETEX")
|
||||
.arg(format!("apikey:{}", &key[..8]))
|
||||
.arg(300)
|
||||
.arg(&row.0)
|
||||
.query_async(&mut *conn)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(AuthContext {
|
||||
org_id: row.0,
|
||||
user_id: None,
|
||||
role: Role::Member,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user