forked from boos/BoosAPI
refactor: purge Node.js, Rust is the one true backend
- Moved boosapi-rs/* to repo root - Deleted all Node.js source (src/server/, src/web/, scripts/) - Deleted node_modules, package.json, tsconfig, vite.config - Updated .gitignore and AGENTS.md for Rust-only stack - Fixed static file path for frontend dist/web/ - Dockerfile stays the same (already Rust-only) - Kept dist/web/ for Rust static serving
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use argon2::Argon2;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::models::downstream_api_key::DownstreamApiKey;
|
||||
|
||||
/// Result of downstream token authorization.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DownstreamAuthResult {
|
||||
/// Found in downstream_api_keys table.
|
||||
Managed { key: DownstreamApiKey },
|
||||
/// Matched the global proxy token from config.
|
||||
Global { token: String },
|
||||
}
|
||||
|
||||
/// Authorize a downstream token (sk-xxx or global proxy token).
|
||||
///
|
||||
/// Looks up the token in:
|
||||
/// 1. downstream_api_keys table (for managed keys)
|
||||
/// 2. Falls back to checking against the global proxy token
|
||||
pub async fn authorize_downstream_token(
|
||||
token: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> Result<DownstreamAuthResult, String> {
|
||||
// Try managed keys first
|
||||
if token.starts_with("sk-") {
|
||||
let key: Option<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
|
||||
"SELECT * FROM downstream_api_keys WHERE key = ? AND enabled = 1",
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| format!("Database error: {e}"))?;
|
||||
|
||||
if let Some(k) = key {
|
||||
return Ok(DownstreamAuthResult::Managed { key: k });
|
||||
}
|
||||
|
||||
// Key not found in table
|
||||
return Err("Invalid API key".to_string());
|
||||
}
|
||||
|
||||
// Not an sk- key, can't be a downstream key
|
||||
Err("Invalid API key format".to_string())
|
||||
}
|
||||
|
||||
/// Generate 16 random bytes for a salt using `uuid::Uuid::new_v4()`.
|
||||
fn random_salt_bytes() -> [u8; 16] {
|
||||
*uuid::Uuid::new_v4().as_bytes()
|
||||
}
|
||||
|
||||
/// Hash an API key using Argon2id with a random salt.
|
||||
pub fn hash_api_key(key: &str) -> anyhow::Result<String> {
|
||||
let salt = SaltString::encode_b64(&random_salt_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to generate salt: {e}"))?;
|
||||
let hash = Argon2::default()
|
||||
.hash_password(key.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to hash API key: {e}"))?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Verify an API key against an Argon2id hash.
|
||||
pub fn verify_api_key(key: &str, hash: &str) -> anyhow::Result<bool> {
|
||||
let parsed_hash =
|
||||
PasswordHash::new(hash).map_err(|e| anyhow::anyhow!("Invalid hash format: {e}"))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(key.as_bytes(), &parsed_hash)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_and_verify_api_key() {
|
||||
let key = "sk-test-api-key-12345";
|
||||
let hash = hash_api_key(key).unwrap();
|
||||
assert!(verify_api_key(key, &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_key_fails() {
|
||||
let key = "sk-test-api-key-12345";
|
||||
let hash = hash_api_key(key).unwrap();
|
||||
assert!(!verify_api_key("wrong-key", &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_hash_fails() {
|
||||
let result = verify_api_key("some-key", "not-a-valid-hash");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_hashes_for_same_key() {
|
||||
let key = "sk-test-key";
|
||||
let hash1 = hash_api_key(key).unwrap();
|
||||
let hash2 = hash_api_key(key).unwrap();
|
||||
assert_ne!(hash1, hash2);
|
||||
assert!(verify_api_key(key, &hash1).unwrap());
|
||||
assert!(verify_api_key(key, &hash2).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String, // user_id
|
||||
pub role: String, // "admin" | "user"
|
||||
pub exp: usize, // expiry timestamp
|
||||
pub iat: usize, // issued at
|
||||
}
|
||||
|
||||
pub fn generate_jwt(
|
||||
secret: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
expiry_hours: i64,
|
||||
) -> anyhow::Result<String> {
|
||||
let now = Utc::now();
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
role: role.to_string(),
|
||||
iat: now.timestamp() as usize,
|
||||
exp: (now + Duration::hours(expiry_hours)).timestamp() as usize,
|
||||
};
|
||||
Ok(encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn validate_jwt(secret: &str, token: &str) -> anyhow::Result<Claims> {
|
||||
let data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)?;
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_and_validate_jwt() {
|
||||
let secret = "test-secret-key";
|
||||
let token = generate_jwt(secret, "user-123", "admin", 24).unwrap();
|
||||
let claims = validate_jwt(secret, &token).unwrap();
|
||||
assert_eq!(claims.sub, "user-123");
|
||||
assert_eq!(claims.role, "admin");
|
||||
assert!(claims.iat > 0);
|
||||
assert!(claims.exp > claims.iat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_token_fails() {
|
||||
let secret = "test-secret-key";
|
||||
let result = validate_jwt(secret, "invalid-token");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_secret_fails() {
|
||||
let token = generate_jwt("secret1", "user-123", "user", 24).unwrap();
|
||||
let result = validate_jwt("secret2", &token);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Auth module — JWT, API key, and password authentication
|
||||
|
||||
pub mod api_key;
|
||||
pub mod jwt;
|
||||
pub mod password;
|
||||
|
||||
/// Represents the authentication context for a request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AuthContext {
|
||||
/// Admin token-based authentication.
|
||||
Admin { token: String },
|
||||
/// Authenticated user session with JWT.
|
||||
User { user_id: String, role: String },
|
||||
/// Downstream API key authentication.
|
||||
DownstreamKey { key_id: i64, name: String },
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use argon2::Argon2;
|
||||
|
||||
/// Generate 16 random bytes for a salt using `uuid::Uuid::new_v4()`.
|
||||
fn random_salt_bytes() -> [u8; 16] {
|
||||
*uuid::Uuid::new_v4().as_bytes()
|
||||
}
|
||||
|
||||
/// Hash a user password using Argon2id with a random salt.
|
||||
pub fn hash_password(password: &str) -> anyhow::Result<String> {
|
||||
let salt = SaltString::encode_b64(&random_salt_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to generate salt: {e}"))?;
|
||||
let hash = Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to hash password: {e}"))?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Verify a password against an Argon2id hash.
|
||||
pub fn verify_password(password: &str, hash: &str) -> anyhow::Result<bool> {
|
||||
let parsed_hash =
|
||||
PasswordHash::new(hash).map_err(|e| anyhow::anyhow!("Invalid hash format: {e}"))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_and_verify_password() {
|
||||
let password = "secure-password-123!";
|
||||
let hash = hash_password(password).unwrap();
|
||||
assert!(verify_password(password, &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_password_fails() {
|
||||
let password = "correct-password";
|
||||
let hash = hash_password(password).unwrap();
|
||||
assert!(!verify_password("wrong-password", &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_hashes_for_same_password() {
|
||||
let password = "my-password";
|
||||
let hash1 = hash_password(password).unwrap();
|
||||
let hash2 = hash_password(password).unwrap();
|
||||
assert_ne!(hash1, hash2);
|
||||
assert!(verify_password(password, &hash1).unwrap());
|
||||
assert!(verify_password(password, &hash2).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_password() {
|
||||
let password = "";
|
||||
let hash = hash_password(password).unwrap();
|
||||
assert!(verify_password(password, &hash).unwrap());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user