feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators feat(tui): create workflow panel for agent status and progress visualization feat(web): introduce web frontend interface with static file serving feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
//! JWT token utilities for HMAC-SHA256 / HS256 signing and verification.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Standard JWT claims with optional session binding.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JwtClaims {
|
||||
pub sub: String,
|
||||
pub exp: u64,
|
||||
pub iat: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl JwtClaims {
|
||||
pub fn new(sub: String, exp: u64, session_id: Option<String>) -> Self {
|
||||
let iat = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
Self {
|
||||
sub,
|
||||
exp,
|
||||
iat,
|
||||
session_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a set of claims into a JWT string using HS256.
|
||||
pub fn create_token(secret: &str, claims: JwtClaims) -> anyhow::Result<String> {
|
||||
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
|
||||
let key = jsonwebtoken::EncodingKey::from_secret(secret.as_bytes());
|
||||
let token = jsonwebtoken::encode(&header, &claims, &key)?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Verify a JWT string and return its claims.
|
||||
pub fn verify_token(secret: &str, token: &str) -> anyhow::Result<JwtClaims> {
|
||||
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
|
||||
validation.validate_exp = true;
|
||||
validation.required_spec_claims = ["sub", "exp", "iat"]
|
||||
.iter()
|
||||
.map(|&s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let key = jsonwebtoken::DecodingKey::from_secret(secret.as_bytes());
|
||||
let token_data = jsonwebtoken::decode::<JwtClaims>(token, &key, &validation)?;
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Auth service implementations: JWT signing/verification, Argon2 password
|
||||
//! hashing, and OAuth loopback server.
|
||||
|
||||
pub mod jwt;
|
||||
pub mod oauth_loopback;
|
||||
pub mod password;
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
|
||||
/// A single-use HTTP listener on `127.0.0.1` that receives the OAuth
|
||||
/// `?code=...` redirect and serves back a static confirmation page.
|
||||
pub struct LoopbackServer {
|
||||
listener: TcpListener,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl LoopbackServer {
|
||||
pub fn bind() -> std::io::Result<Self> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
Ok(LoopbackServer { listener, port })
|
||||
}
|
||||
|
||||
pub fn redirect_uri(&self) -> String {
|
||||
format!("http://127.0.0.1:{}/callback", self.port)
|
||||
}
|
||||
|
||||
pub fn wait_for_code(
|
||||
&self,
|
||||
timeout_ms: u64,
|
||||
expected_state: &str,
|
||||
) -> std::io::Result<String> {
|
||||
let (mut stream, _) = self.listener.accept()?;
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
|
||||
Self::read_callback(&mut stream, expected_state)
|
||||
}
|
||||
|
||||
fn read_callback(
|
||||
stream: &mut TcpStream,
|
||||
expected_state: &str,
|
||||
) -> std::io::Result<String> {
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = stream.read(&mut buf)?;
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
let code = Self::extract_code(&request);
|
||||
let state = Self::extract_state(&request);
|
||||
let state_ok = state.as_deref() == Some(expected_state);
|
||||
let response = match (code.as_ref(), state_ok) {
|
||||
(Some(_), true) => {
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n\
|
||||
Authorization complete. You may close this tab."
|
||||
}
|
||||
(Some(_), false) => {
|
||||
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
|
||||
State mismatch — possible CSRF attack."
|
||||
}
|
||||
(None, _) => {
|
||||
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
|
||||
Missing authorization code."
|
||||
}
|
||||
};
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.flush();
|
||||
if !state_ok {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"state mismatch",
|
||||
));
|
||||
}
|
||||
code.ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, "code not found in callback")
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_code(request: &str) -> Option<String> {
|
||||
let line = request.lines().next()?;
|
||||
let path = line.split(' ').nth(1)?;
|
||||
let query = path.split('?').nth(1)?;
|
||||
for pair in query.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
if parts.next()? == "code" {
|
||||
return parts.next().map(urlencoding);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_state(request: &str) -> Option<String> {
|
||||
let line = request.lines().next()?;
|
||||
let path = line.split(' ').nth(1)?;
|
||||
let query = path.split('?').nth(1)?;
|
||||
for pair in query.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
if parts.next()? == "state" {
|
||||
return parts.next().map(urlencoding);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-decode a string (e.g. `%20` -> space).
|
||||
fn urlencoding(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%' {
|
||||
match (
|
||||
chars.next().and_then(|c| c.to_digit(16)),
|
||||
chars.next().and_then(|c| c.to_digit(16)),
|
||||
) {
|
||||
(Some(hi), Some(lo)) => {
|
||||
let byte: u8 = (hi as u8) * 16 + lo as u8;
|
||||
result.push(char::from(byte));
|
||||
}
|
||||
_ => {
|
||||
result.push('%');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Argon2 password hashing and verification utilities.
|
||||
|
||||
use argon2::{
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use rand_core::OsRng;
|
||||
|
||||
/// Hash a plaintext password using Argon2id with a random salt.
|
||||
pub async fn hash_password(password: &str) -> anyhow::Result<String> {
|
||||
let password = password.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?;
|
||||
Ok(hash.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))?
|
||||
}
|
||||
|
||||
/// Verify a plaintext password against a previously-hashed PHC string.
|
||||
pub async fn verify_password(password: &str, hash: &str) -> anyhow::Result<bool> {
|
||||
let password = password.to_string();
|
||||
let hash = hash.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let parsed_hash = PasswordHash::new(&hash)
|
||||
.map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?;
|
||||
let argon2 = Argon2::default();
|
||||
let valid = argon2
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok();
|
||||
Ok(valid)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))?
|
||||
}
|
||||
Reference in New Issue
Block a user