Move 17 library crates into crates/, CLI binary into bin/fxt, consolidate 10 test crates into testing/, split config crate from deployment config files. Root directory reduced from 38+ to ~17 directories. All Cargo.toml paths and build.rs proto refs updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
239 lines
7.7 KiB
Rust
239 lines
7.7 KiB
Rust
//! 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<TokenResponse> {
|
|
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(|_| "<unreadable>".into());
|
|
return Err(CTraderError::OAuthError(format!(
|
|
"token exchange failed ({status}): {body}"
|
|
)));
|
|
}
|
|
|
|
resp.json::<TokenResponse>()
|
|
.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<TokenResponse> {
|
|
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(|_| "<unreadable>".into());
|
|
return Err(CTraderError::OAuthError(format!(
|
|
"token refresh failed ({status}): {body}"
|
|
)));
|
|
}
|
|
|
|
resp.json::<TokenResponse>()
|
|
.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"));
|
|
}
|
|
}
|