//! OAuth2 token exchange and protobuf-level authentication. //! //! Two-phase authentication: //! 1. **HTTP OAuth2** — exchange auth code or refresh token for access token //! 2. **Protobuf auth** — ApplicationAuth + AccountAuth over the TCP connection use prost::Message; use serde::{Deserialize, Serialize}; use tracing::{debug, info}; use crate::config::CTraderConfig; use crate::connection::CTraderConnection; use crate::error::{CTraderError, Result}; use crate::proto::{self, ProtoMessage}; // ── OAuth2 (HTTP) ─────────────────────────────────────────────────── const OAUTH_TOKEN_URL: &str = "https://openapi.ctrader.com/apps/token"; /// OAuth2 token response from the cTrader authorization server. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TokenResponse { /// The access token. pub access_token: String, /// Token type (always "bearer"). pub token_type: String, /// Lifetime in seconds. pub expires_in: i64, /// Refresh token for obtaining new access tokens. pub refresh_token: String, } /// Exchange an authorization code for access + refresh tokens. pub async fn exchange_auth_code( client_id: &str, client_secret: &str, redirect_uri: &str, auth_code: &str, ) -> Result { let params = [ ("grant_type", "authorization_code"), ("code", auth_code), ("redirect_uri", redirect_uri), ("client_id", client_id), ("client_secret", client_secret), ]; let client = reqwest::Client::new(); let resp = client .post(OAUTH_TOKEN_URL) .form(¶ms) .send() .await .map_err(|e| CTraderError::OAuthError(format!("HTTP request failed: {e}")))?; if !resp.status().is_success() { let status = resp.status(); let body = resp .text() .await .unwrap_or_else(|_| "".into()); return Err(CTraderError::OAuthError(format!( "token exchange failed ({status}): {body}" ))); } resp.json::() .await .map_err(|e| CTraderError::OAuthError(format!("failed to parse token response: {e}"))) } /// Refresh an access token using a refresh token. pub async fn refresh_token( client_id: &str, client_secret: &str, refresh_tok: &str, ) -> Result { let params = [ ("grant_type", "refresh_token"), ("refresh_token", refresh_tok), ("client_id", client_id), ("client_secret", client_secret), ]; let client = reqwest::Client::new(); let resp = client .post(OAUTH_TOKEN_URL) .form(¶ms) .send() .await .map_err(|e| CTraderError::OAuthError(format!("HTTP request failed: {e}")))?; if !resp.status().is_success() { let status = resp.status(); let body = resp .text() .await .unwrap_or_else(|_| "".into()); return Err(CTraderError::OAuthError(format!( "token refresh failed ({status}): {body}" ))); } resp.json::() .await .map_err(|e| CTraderError::OAuthError(format!("failed to parse token response: {e}"))) } // ── Protobuf auth (over TCP+TLS) ─────────────────────────────────── /// Run the full protobuf authentication sequence: /// 1. Send `ProtoOAApplicationAuthReq` → wait for `ProtoOAApplicationAuthRes` /// 2. Send `ProtoOAAccountAuthReq` → wait for `ProtoOAAccountAuthRes` /// /// Uses the connection's `recv()` method directly (before the dispatcher /// takes over the receive stream). pub async fn authenticate( conn: &mut CTraderConnection, config: &CTraderConfig, ) -> Result<()> { // Step 1: Application auth info!("authenticating application"); let app_auth = proto::ProtoOaApplicationAuthReq { payload_type: Some(proto::PT_APP_AUTH_REQ as i32), client_id: config.client_id.clone(), client_secret: config.client_secret.clone(), }; let app_auth_bytes = app_auth.encode_to_vec(); let envelope = ProtoMessage { payload_type: proto::PT_APP_AUTH_REQ, payload: Some(app_auth_bytes), client_msg_id: None, }; conn.send(envelope).await?; // Wait for response let resp = conn.recv().await?; match resp.payload_type { proto::PT_APP_AUTH_RES => { debug!("application auth successful"); } proto::PT_OA_ERROR_RES | proto::PT_ERROR_RES => { let desc = extract_error_description(&resp); return Err(CTraderError::AppAuthFailed(desc)); } other => { return Err(CTraderError::ProtocolError(format!( "expected ApplicationAuthRes ({}), got payloadType {other}", proto::PT_APP_AUTH_RES ))); } } // Step 2: Account auth info!(account_id = config.account_id, "authenticating account"); let acct_auth = proto::ProtoOaAccountAuthReq { payload_type: Some(proto::PT_ACCOUNT_AUTH_REQ as i32), ctid_trader_account_id: config.account_id, access_token: config.access_token.clone(), }; let acct_auth_bytes = acct_auth.encode_to_vec(); let acct_envelope = ProtoMessage { payload_type: proto::PT_ACCOUNT_AUTH_REQ, payload: Some(acct_auth_bytes), client_msg_id: None, }; conn.send(acct_envelope).await?; // Wait for response let acct_resp = conn.recv().await?; match acct_resp.payload_type { proto::PT_ACCOUNT_AUTH_RES => { info!("account auth successful"); Ok(()) } proto::PT_OA_ERROR_RES | proto::PT_ERROR_RES => { let desc = extract_error_description(&resp); Err(CTraderError::AccountAuthFailed(desc)) } other => Err(CTraderError::ProtocolError(format!( "expected AccountAuthRes ({}), got payloadType {other}", proto::PT_ACCOUNT_AUTH_RES ))), } } /// Extract an error description from an OA error response or generic error. fn extract_error_description(msg: &ProtoMessage) -> String { if let Some(ref payload) = msg.payload { // Try OA error first if let Ok(oa_err) = proto::ProtoOaErrorRes::decode(payload.as_slice()) { return format!( "{}: {}", oa_err.error_code, oa_err.description.unwrap_or_default() ); } // Try common error if let Ok(err) = proto::ProtoErrorRes::decode(payload.as_slice()) { return format!( "{}: {}", err.error_code, err.description.unwrap_or_default() ); } } format!("unknown error (payloadType={})", msg.payload_type) } #[cfg(test)] mod tests { use super::*; #[test] fn extract_error_from_oa_error_res() { let oa_err = proto::ProtoOaErrorRes { payload_type: Some(proto::PT_OA_ERROR_RES as i32), ctid_trader_account_id: Some(12345), error_code: "CH_CLIENT_AUTH_FAILURE".into(), description: Some("Invalid credentials".into()), maintenance_end_timestamp: None, retry_after: None, }; let msg = ProtoMessage { payload_type: proto::PT_OA_ERROR_RES, payload: Some(oa_err.encode_to_vec()), client_msg_id: None, }; let desc = extract_error_description(&msg); assert!(desc.contains("CH_CLIENT_AUTH_FAILURE")); assert!(desc.contains("Invalid credentials")); } }