Files
foxhunt/crates/ctrader-openapi/src/auth.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
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>
2026-02-25 11:56:00 +01:00

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(&params)
.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(&params)
.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"));
}
}