feat: add auth gRPC service + migrate dashboard from REST/WS to grpc-web
Backend (Rust): - Create proto/auth.proto with Login and RefreshToken RPCs - Implement AuthGrpcService in services/api with JWT issuance - Register as unauthenticated service in main.rs (pre-auth) - Update JWT issuer from foxhunt-api-gateway to foxhunt-api Dashboard (TypeScript): - Install @connectrpc/connect-web + @bufbuild/protobuf - Generate TypeScript proto clients via buf (src/gen/) - Create grpc.ts transport with JWT interceptor - Rewrite all useApi hooks to typed gRPC calls - Replace WebSocket with useGrpcStream server-streaming hooks - Migrate all 6 pages + 6 components to grpc-web - Delete api.ts, websocket.ts, useWebSocket.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -179,6 +179,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
&["../../proto"],
|
||||
)?;
|
||||
|
||||
// Compile Auth Service proto (package: auth) — server only (no client needed)
|
||||
config
|
||||
.clone()
|
||||
.build_server(true)
|
||||
.build_client(false)
|
||||
.file_descriptor_set_path(out_dir.join("auth_descriptor.bin"))
|
||||
.compile_well_known_types(true)
|
||||
.extern_path(".google.protobuf", "::prost_types")
|
||||
.type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]")
|
||||
.server_mod_attribute(".", "#[allow(unused_qualifications)]")
|
||||
.client_mod_attribute(".", "#[allow(unused_qualifications)]")
|
||||
.compile_protos(
|
||||
&["../../proto/auth.proto"],
|
||||
&["../../proto"],
|
||||
)?;
|
||||
|
||||
println!("cargo:rerun-if-changed=../../proto/fxt_trading.proto");
|
||||
println!("cargo:rerun-if-changed=../../proto/trading.proto");
|
||||
println!("cargo:rerun-if-changed=../../proto/risk.proto");
|
||||
@@ -189,6 +205,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("cargo:rerun-if-changed=../../proto/broker_gateway.proto");
|
||||
println!("cargo:rerun-if-changed=../../proto/data_acquisition.proto");
|
||||
println!("cargo:rerun-if-changed=../../proto/ml.proto");
|
||||
println!("cargo:rerun-if-changed=../../proto/auth.proto");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
201
services/api/src/auth/grpc_login.rs
Normal file
201
services/api/src/auth/grpc_login.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
//! gRPC AuthService implementation — Login and RefreshToken RPCs.
|
||||
//!
|
||||
//! This service is exposed WITHOUT the auth interceptor since it handles
|
||||
//! pre-authentication token issuance.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use tonic::{Request, Response, Status};
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::jwt::service::{JwtClaims, JwtConfig};
|
||||
use crate::auth_proto::auth_service_server::AuthService;
|
||||
use crate::auth_proto::{
|
||||
LoginRequest, LoginResponse, RefreshTokenRequest, RefreshTokenResponse,
|
||||
};
|
||||
|
||||
/// Access token lifetime: 1 hour (seconds).
|
||||
const ACCESS_TOKEN_EXPIRY_SECS: u64 = 3600;
|
||||
|
||||
/// Refresh token lifetime: 24 hours (seconds).
|
||||
const REFRESH_TOKEN_EXPIRY_SECS: u64 = 86400;
|
||||
|
||||
/// gRPC implementation of `auth.AuthService`.
|
||||
pub struct AuthGrpcService {
|
||||
jwt_config: Arc<JwtConfig>,
|
||||
}
|
||||
|
||||
impl AuthGrpcService {
|
||||
/// Create a new `AuthGrpcService` backed by the given JWT configuration.
|
||||
pub fn new(jwt_config: Arc<JwtConfig>) -> Self {
|
||||
Self { jwt_config }
|
||||
}
|
||||
|
||||
/// Validate user credentials.
|
||||
///
|
||||
/// Checks the `USER_CREDENTIALS` environment variable (format: `user:pass`)
|
||||
/// first, then falls back to a hard-coded development user (`admin` / `admin`).
|
||||
fn validate_credentials(username: &str, password: &str) -> Result<(), Status> {
|
||||
// Check USER_CREDENTIALS env var (format: "user:pass")
|
||||
if let Ok(creds) = std::env::var("USER_CREDENTIALS") {
|
||||
if let Some((env_user, env_pass)) = creds.split_once(':') {
|
||||
if username == env_user && password == env_pass {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Env var set but credentials don't match
|
||||
return Err(Status::unauthenticated("invalid credentials"));
|
||||
}
|
||||
|
||||
// Development fallback: admin/admin
|
||||
if username == "admin" && password == "admin" {
|
||||
warn!("Login accepted using hard-coded dev credentials (set USER_CREDENTIALS for production)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(Status::unauthenticated("invalid credentials"))
|
||||
}
|
||||
|
||||
/// Build and sign a JWT token (access or refresh).
|
||||
fn issue_token(
|
||||
&self,
|
||||
subject: &str,
|
||||
token_type: &str,
|
||||
session_id: &str,
|
||||
expiry_secs: u64,
|
||||
) -> Result<(String, u64), Status> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| {
|
||||
error!("System time error: {}", e);
|
||||
Status::internal("system time error")
|
||||
})?
|
||||
.as_secs();
|
||||
|
||||
let exp = now.checked_add(expiry_secs).ok_or_else(|| {
|
||||
error!("Overflow computing token expiry");
|
||||
Status::internal("token expiry overflow")
|
||||
})?;
|
||||
|
||||
let claims = JwtClaims {
|
||||
jti: Uuid::new_v4().to_string(),
|
||||
sub: subject.to_string(),
|
||||
iat: now,
|
||||
exp,
|
||||
iss: self.jwt_config.jwt_issuer.clone(),
|
||||
aud: self.jwt_config.jwt_audience.clone(),
|
||||
roles: vec!["user".to_string()],
|
||||
permissions: vec![],
|
||||
token_type: token_type.to_string(),
|
||||
session_id: Some(session_id.to_string()),
|
||||
};
|
||||
|
||||
let header = Header::new(Algorithm::HS256);
|
||||
let key = EncodingKey::from_secret(self.jwt_config.jwt_secret.as_bytes());
|
||||
let token = encode(&header, &claims, &key).map_err(|e| {
|
||||
error!("Failed to encode JWT: {}", e);
|
||||
Status::internal("failed to issue token")
|
||||
})?;
|
||||
|
||||
Ok((token, exp))
|
||||
}
|
||||
|
||||
/// Decode and validate a refresh token, returning its claims.
|
||||
fn decode_refresh_token(&self, token: &str) -> Result<JwtClaims, Status> {
|
||||
if token.is_empty() {
|
||||
return Err(Status::invalid_argument("refresh token is empty"));
|
||||
}
|
||||
|
||||
if token.len() > 8192 {
|
||||
return Err(Status::invalid_argument("refresh token too long"));
|
||||
}
|
||||
|
||||
let key = DecodingKey::from_secret(self.jwt_config.jwt_secret.as_bytes());
|
||||
|
||||
let mut validation = Validation::new(Algorithm::HS256);
|
||||
validation.set_issuer(&[&self.jwt_config.jwt_issuer]);
|
||||
validation.set_audience(&[&self.jwt_config.jwt_audience]);
|
||||
validation.validate_exp = true;
|
||||
validation.validate_nbf = false;
|
||||
validation.leeway = 10;
|
||||
validation.validate_aud = true;
|
||||
|
||||
let token_data = decode::<JwtClaims>(token, &key, &validation).map_err(|e| {
|
||||
warn!("Refresh token decode failed: {}", e);
|
||||
Status::unauthenticated("invalid refresh token")
|
||||
})?;
|
||||
|
||||
if token_data.claims.token_type != "refresh" {
|
||||
return Err(Status::unauthenticated(
|
||||
"token is not a refresh token",
|
||||
));
|
||||
}
|
||||
|
||||
if token_data.claims.sub.is_empty() {
|
||||
return Err(Status::unauthenticated("refresh token has empty subject"));
|
||||
}
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl AuthService for AuthGrpcService {
|
||||
async fn login(
|
||||
&self,
|
||||
request: Request<LoginRequest>,
|
||||
) -> Result<Response<LoginResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
|
||||
if req.username.is_empty() || req.password.is_empty() {
|
||||
return Err(Status::invalid_argument(
|
||||
"username and password are required",
|
||||
));
|
||||
}
|
||||
|
||||
Self::validate_credentials(&req.username, &req.password)?;
|
||||
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
|
||||
let (access_token, expires_at) =
|
||||
self.issue_token(&req.username, "access", &session_id, ACCESS_TOKEN_EXPIRY_SECS)?;
|
||||
let (refresh_token, _) =
|
||||
self.issue_token(&req.username, "refresh", &session_id, REFRESH_TOKEN_EXPIRY_SECS)?;
|
||||
|
||||
info!(user = %req.username, "Login successful, tokens issued");
|
||||
|
||||
Ok(Response::new(LoginResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_at: i64::try_from(expires_at).unwrap_or(i64::MAX),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn refresh_token(
|
||||
&self,
|
||||
request: Request<RefreshTokenRequest>,
|
||||
) -> Result<Response<RefreshTokenResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
let claims = self.decode_refresh_token(&req.refresh_token)?;
|
||||
|
||||
let session_id = claims
|
||||
.session_id
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
|
||||
let (access_token, expires_at) =
|
||||
self.issue_token(&claims.sub, "access", &session_id, ACCESS_TOKEN_EXPIRY_SECS)?;
|
||||
let (refresh_token, _) =
|
||||
self.issue_token(&claims.sub, "refresh", &session_id, REFRESH_TOKEN_EXPIRY_SECS)?;
|
||||
|
||||
info!(user = %claims.sub, "Token refreshed successfully");
|
||||
|
||||
Ok(Response::new(RefreshTokenResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_at: i64::try_from(expires_at).unwrap_or(i64::MAX),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ impl JwtConfig {
|
||||
|
||||
Ok(Self {
|
||||
jwt_secret,
|
||||
jwt_issuer: "foxhunt-api-gateway".to_string(),
|
||||
jwt_issuer: "foxhunt-api".to_string(),
|
||||
jwt_audience: "foxhunt-services".to_string(),
|
||||
})
|
||||
}
|
||||
@@ -454,7 +454,7 @@ mod tests {
|
||||
.await
|
||||
.expect("Should create config with valid JWT_SECRET");
|
||||
|
||||
assert_eq!(config.jwt_issuer, "foxhunt-api-gateway");
|
||||
assert_eq!(config.jwt_issuer, "foxhunt-api");
|
||||
assert_eq!(config.jwt_audience, "foxhunt-services");
|
||||
assert!(config.jwt_secret.len() >= 64);
|
||||
|
||||
@@ -484,7 +484,7 @@ mod tests {
|
||||
match result {
|
||||
Ok(config) => {
|
||||
// Verify config was loaded successfully
|
||||
assert_eq!(config.jwt_issuer, "foxhunt-api-gateway");
|
||||
assert_eq!(config.jwt_issuer, "foxhunt-api");
|
||||
assert_eq!(config.jwt_audience, "foxhunt-services");
|
||||
assert!(config.jwt_secret.len() >= 64);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
//! - Authorization: <100ns (in-memory cache)
|
||||
//! - Rate limiting: <50ns (atomic counters)
|
||||
|
||||
pub mod grpc_login;
|
||||
pub mod interceptor;
|
||||
pub mod jwt;
|
||||
#[cfg(feature = "mfa")]
|
||||
|
||||
@@ -39,6 +39,8 @@ pub const DATA_ACQUISITION_FILE_DESCRIPTOR_SET: &[u8] =
|
||||
tonic::include_file_descriptor_set!("data_acquisition_descriptor");
|
||||
pub const ML_FILE_DESCRIPTOR_SET: &[u8] =
|
||||
tonic::include_file_descriptor_set!("ml_descriptor");
|
||||
pub const AUTH_FILE_DESCRIPTOR_SET: &[u8] =
|
||||
tonic::include_file_descriptor_set!("auth_descriptor");
|
||||
|
||||
// Include generated protobuf code FIRST (needed by other modules)
|
||||
pub mod foxhunt {
|
||||
@@ -91,6 +93,11 @@ pub mod ml_inference {
|
||||
tonic::include_proto!("ml");
|
||||
}
|
||||
|
||||
// Auth Service proto (server-only, unauthenticated)
|
||||
pub mod auth_proto {
|
||||
tonic::include_proto!("auth");
|
||||
}
|
||||
|
||||
// Error module MUST come before modules that use it
|
||||
pub mod error;
|
||||
|
||||
|
||||
@@ -470,6 +470,7 @@ async fn main() -> Result<()> {
|
||||
};
|
||||
|
||||
// Build gRPC server with all services
|
||||
use api::auth_proto::auth_service_server::AuthServiceServer;
|
||||
use api::foxhunt::tli::{
|
||||
backtesting_service_server::BacktestingServiceServer,
|
||||
trading_service_server::TradingServiceServer,
|
||||
@@ -633,13 +634,18 @@ async fn main() -> Result<()> {
|
||||
.register_encoded_file_descriptor_set(api::BROKER_GATEWAY_FILE_DESCRIPTOR_SET)
|
||||
.register_encoded_file_descriptor_set(api::DATA_ACQUISITION_FILE_DESCRIPTOR_SET)
|
||||
.register_encoded_file_descriptor_set(api::ML_FILE_DESCRIPTOR_SET)
|
||||
.register_encoded_file_descriptor_set(api::AUTH_FILE_DESCRIPTOR_SET)
|
||||
.build_v1()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to build reflection service: {}", e))?;
|
||||
|
||||
// Add health + reflection services
|
||||
// Create unauthenticated auth service (Login / RefreshToken -- pre-authentication)
|
||||
let auth_grpc_service = api::auth::grpc_login::AuthGrpcService::new(Arc::new(jwt_config));
|
||||
|
||||
// Add health + reflection services, then unauthenticated auth service
|
||||
let mut router = server_builder
|
||||
.add_service(health_service)
|
||||
.add_service(reflection_service);
|
||||
.add_service(reflection_service)
|
||||
.add_service(AuthServiceServer::new(auth_grpc_service));
|
||||
|
||||
// Always add trading service (required) with authentication
|
||||
router = router.add_service(TradingServiceServer::with_interceptor(
|
||||
|
||||
Reference in New Issue
Block a user