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:
jgrusewski
2026-03-05 00:05:18 +01:00
parent 57e22c01a8
commit 84877e5146
44 changed files with 22151 additions and 728 deletions

View File

@@ -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(())
}

View 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),
}))
}
}

View File

@@ -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);

View File

@@ -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")]

View File

@@ -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;

View File

@@ -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(