Files
wz-phone/crates/wzp-relay/src/auth.rs
Siavash Sameni ad16ddb903 feat: WZP-S-2 relay auth + WZP-S-3 featherChat signaling bridge
WZP-S-2: Relay token authentication
- New --auth-url flag: relay calls POST {url} with bearer token
- Clients must send SignalMessage::AuthToken as first signal
- Relay validates against featherChat's /v1/auth/validate endpoint
- Rejects unauthenticated clients before they join rooms
- New auth.rs module with validate_token() + tests

WZP-S-3: featherChat signaling bridge
- New featherchat.rs module for CallSignal interop
- WzpCallPayload: wraps SignalMessage + relay_addr + room name
- encode_call_payload/decode_call_payload for JSON serialization
- CallSignalType enum mirrors featherChat's variant
- signal_to_call_type maps WZP signals to FC types

Protocol: Added SignalMessage::AuthToken { token } variant

129 tests passing across all crates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 09:23:46 +04:00

107 lines
2.9 KiB
Rust

//! featherChat token authentication.
//!
//! When `--auth-url` is configured, the relay validates bearer tokens
//! against featherChat's `POST /v1/auth/validate` endpoint before
//! allowing clients to join rooms.
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
/// Request body for featherChat token validation.
#[derive(Serialize)]
struct ValidateRequest {
token: String,
}
/// Response from featherChat token validation.
#[derive(Deserialize, Debug)]
pub struct ValidateResponse {
pub valid: bool,
pub fingerprint: Option<String>,
pub alias: Option<String>,
}
/// Validated client identity.
#[derive(Clone, Debug)]
pub struct AuthenticatedClient {
pub fingerprint: String,
pub alias: Option<String>,
}
/// Validate a bearer token against featherChat's auth endpoint.
///
/// Calls `POST {auth_url}` with `{ "token": "..." }`.
/// Returns the client identity if valid, or an error string.
pub async fn validate_token(
auth_url: &str,
token: &str,
) -> Result<AuthenticatedClient, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| format!("http client error: {e}"))?;
let resp = client
.post(auth_url)
.json(&ValidateRequest {
token: token.to_string(),
})
.send()
.await
.map_err(|e| format!("auth request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("auth endpoint returned {}", resp.status()));
}
let body: ValidateResponse = resp
.json()
.await
.map_err(|e| format!("invalid auth response: {e}"))?;
if body.valid {
let fingerprint = body
.fingerprint
.ok_or_else(|| "valid response missing fingerprint".to_string())?;
info!(%fingerprint, alias = ?body.alias, "token validated");
Ok(AuthenticatedClient {
fingerprint,
alias: body.alias,
})
} else {
warn!("token validation failed");
Err("invalid token".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_request_serializes() {
let req = ValidateRequest {
token: "abc123".to_string(),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("abc123"));
}
#[test]
fn validate_response_deserializes() {
let json = r#"{"valid": true, "fingerprint": "abcd1234", "alias": "manwe"}"#;
let resp: ValidateResponse = serde_json::from_str(json).unwrap();
assert!(resp.valid);
assert_eq!(resp.fingerprint.unwrap(), "abcd1234");
assert_eq!(resp.alias.unwrap(), "manwe");
}
#[test]
fn invalid_response_deserializes() {
let json = r#"{"valid": false}"#;
let resp: ValidateResponse = serde_json::from_str(json).unwrap();
assert!(!resp.valid);
assert!(resp.fingerprint.is_none());
}
}