#[cfg(not(target_arch = "wasm32"))]
use crate::{
errors::{Error, Result},
http::HttpClient,
};
#[cfg(not(target_arch = "wasm32"))]
use serde::{Deserialize, Serialize};
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum LoginStatus {
Pending,
Scanned,
Authorized,
Success,
Failed,
Expired,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginState {
pub status: LoginStatus,
pub code: Option<String>,
pub error: Option<String>,
pub created_at: i64,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone)]
pub enum LoginEvent {
StatusUpdate(LoginState),
Heartbeat,
Close,
Error(String),
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn start_login_session(
issuer: &str,
client_id: &str,
redirect_uri: &str,
http: &dyn HttpClient,
) -> Result<(String, String)> {
if issuer.is_empty() {
return Err(Error::InvalidParam("issuer cannot be empty"));
}
if client_id.is_empty() {
return Err(Error::InvalidParam("client_id cannot be empty"));
}
if redirect_uri.is_empty() {
return Err(Error::InvalidParam("redirect_uri cannot be empty"));
}
let session_endpoint = format!("{}/auth/wechat/qr", issuer.trim_end_matches('/'));
let body = serde_json::json!({
"client_id": client_id,
"redirect_uri": redirect_uri,
});
let response = http
.post_json_value(&session_endpoint, &body, None)
.await
.map_err(|e| Error::Network(format!("Failed to start login session: {}", e)))?;
let login_id = response["login_id"]
.as_str()
.ok_or_else(|| Error::InvalidState("Missing login_id in response".to_string()))?
.to_string();
let qr_url = response["wechat_qr_url"]
.as_str()
.ok_or_else(|| Error::InvalidState("Missing wechat_qr_url in response".to_string()))?
.to_string();
Ok((login_id, qr_url))
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone)]
pub struct LoginMonitorConfig {
pub issuer: String,
pub login_id: String,
pub timeout_secs: Option<u64>,
pub max_reconnects: Option<u32>,
}
#[cfg(all(not(target_arch = "wasm32"), feature = "sse"))]
pub async fn subscribe_login_events(
config: LoginMonitorConfig,
) -> Result<impl futures_util::Stream<Item = Result<LoginEvent>>> {
use eventsource_client::{Client, ClientBuilder, ReconnectOptions, SSE};
use futures_util::StreamExt;
let sse_url = format!(
"{}/auth/login-stream?login_id={}",
config.issuer.trim_end_matches('/'),
urlencoding::encode(&config.login_id)
);
let reconnect = ReconnectOptions::reconnect(true)
.retry_initial(false)
.delay(std::time::Duration::from_secs(1))
.delay_max(std::time::Duration::from_secs(5))
.build();
let client = ClientBuilder::for_url(&sse_url)
.map_err(|e| Error::Network(format!("Failed to create SSE client: {}", e)))?
.reconnect(reconnect)
.build();
let event_stream = client.stream().map(|result| {
match result {
Ok(SSE::Event(e)) => {
match e.event_type.as_str() {
"pending" | "scanned" | "authorized" | "success" | "failed" | "expired" => {
let parse_result: Result<LoginState> = {
if let Ok(state) = serde_json::from_str::<LoginState>(&e.data) {
Ok(state)
} else {
if let Ok(response) = serde_json::from_str::<serde_json::Value>(&e.data) {
if let Some(nested_state) = response.get("state") {
serde_json::from_value(nested_state.clone())
.map_err(|e| Error::Verification(format!("Failed to parse login state from nested SSE format: {}", e)))
} else {
Err(Error::Verification("SSE data is not valid LoginState and has no 'state' field".to_string()))
}
} else {
Err(Error::Verification("Failed to parse SSE data as JSON".to_string()))
}
}
};
match parse_result {
Ok(state) => Ok(LoginEvent::StatusUpdate(state)),
Err(e) => Err(e),
}
}
"close" => Ok(LoginEvent::Close),
"heartbeat" | "" => Ok(LoginEvent::Heartbeat),
_ => Ok(LoginEvent::Heartbeat), }
}
Ok(SSE::Comment(_)) => Ok(LoginEvent::Heartbeat),
Err(e) => Err(Error::Network(format!("SSE error: {}", e))),
}
});
if let Some(timeout_secs) = config.timeout_secs {
let timeout_stream = tokio_stream::StreamExt::timeout(
event_stream,
std::time::Duration::from_secs(timeout_secs),
)
.map(move |result| {
result
.map_err(|_| Error::Network("SSE stream timeout".to_string()))
.and_then(|inner| inner)
});
Ok(Box::pin(timeout_stream) as std::pin::Pin<Box<dyn futures_util::Stream<Item = Result<LoginEvent>> + Send>>)
} else {
Ok(Box::pin(event_stream) as std::pin::Pin<Box<dyn futures_util::Stream<Item = Result<LoginEvent>> + Send>>)
}
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn check_login_status(
issuer: &str,
login_id: &str,
http: &dyn HttpClient,
) -> Result<LoginState> {
if issuer.is_empty() {
return Err(Error::InvalidParam("issuer cannot be empty"));
}
if login_id.is_empty() {
return Err(Error::InvalidParam("login_id cannot be empty"));
}
let status_endpoint = format!(
"{}/auth/login-status/{}",
issuer.trim_end_matches('/'),
urlencoding::encode(login_id)
);
let response = http
.get_value(&status_endpoint)
.await
.map_err(|e| Error::Network(format!("Failed to check login status: {}", e)))?;
let state: LoginState = if let Some(nested_state) = response.get("state") {
serde_json::from_value(nested_state.clone())
.map_err(|e| Error::Verification(format!("Failed to parse login state from nested format: {}", e)))?
} else {
serde_json::from_value(response)
.map_err(|e| Error::Verification(format!("Failed to parse login state from direct format: {}", e)))?
};
Ok(state)
}
#[cfg(target_arch = "wasm32")]
use crate::errors::{Error, Result};
#[cfg(target_arch = "wasm32")]
pub async fn start_login_session(
_issuer: &str,
_client_id: &str,
_redirect_uri: &str,
_http: &dyn crate::http::HttpClient,
) -> Result<(String, String)> {
Err(Error::ServerOnly("SSE login sessions"))
}
#[cfg(target_arch = "wasm32")]
pub async fn check_login_status(
_issuer: &str,
_login_id: &str,
_http: &dyn crate::http::HttpClient,
) -> Result<()> {
Err(Error::ServerOnly("SSE login status"))
}
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod tests {
use super::*;
#[test]
fn test_login_state_parsing() {
let json = serde_json::json!({
"status": "SUCCESS",
"code": "auth_code_123",
"error": null,
"created_at": 1234567890
});
let state: LoginState = serde_json::from_value(json).unwrap();
assert_eq!(state.status, LoginStatus::Success);
assert_eq!(state.code, Some("auth_code_123".to_string()));
assert!(state.error.is_none());
assert_eq!(state.created_at, 1234567890);
}
#[test]
fn test_login_status_serialization() {
let status = LoginStatus::Pending;
let json = serde_json::to_string(&status).unwrap();
assert_eq!(json, r#""PENDING""#);
let status = LoginStatus::Success;
let json = serde_json::to_string(&status).unwrap();
assert_eq!(json, r#""SUCCESS""#);
}
#[test]
fn test_nested_login_state_parsing() {
let nested_json = serde_json::json!({
"loginId": "login_123",
"state": {
"status": "PENDING",
"code": null,
"error": null,
"created_at": 1234567890
}
});
let state: LoginState = if let Some(nested_state) = nested_json.get("state") {
serde_json::from_value(nested_state.clone()).unwrap()
} else {
serde_json::from_value(nested_json).unwrap()
};
assert_eq!(state.status, LoginStatus::Pending);
assert!(state.code.is_none());
assert!(state.error.is_none());
assert_eq!(state.created_at, 1234567890);
}
#[test]
fn test_sse_event_data_parsing() {
let direct_data = r#"{"status":"SUCCESS","code":"auth_123","error":null,"created_at":1234567890}"#;
let state: LoginState = serde_json::from_str(direct_data).unwrap();
assert_eq!(state.status, LoginStatus::Success);
assert_eq!(state.code, Some("auth_123".to_string()));
let nested_data = r#"{"loginId":"login_123","state":{"status":"FAILED","code":null,"error":"Auth failed","created_at":1234567890}}"#;
let parse_result: Result<LoginState> = {
if let Ok(state) = serde_json::from_str::<LoginState>(nested_data) {
Ok(state)
} else {
if let Ok(response) = serde_json::from_str::<serde_json::Value>(nested_data) {
if let Some(nested_state) = response.get("state") {
serde_json::from_value(nested_state.clone())
.map_err(|e| Error::Verification(format!("Failed to parse: {}", e)))
} else {
Err(Error::Verification("No state field".to_string()))
}
} else {
Err(Error::Verification("Invalid JSON".to_string()))
}
}
};
let state = parse_result.unwrap();
assert_eq!(state.status, LoginStatus::Failed);
assert!(state.code.is_none());
assert_eq!(state.error, Some("Auth failed".to_string()));
}
}