Skip to main content

comdirect_rest_api/
session.rs

1use std::future::Future;
2use std::sync::Arc;
3use std::time::Duration;
4
5use crate::application::session_runtime::{
6    RefreshTokenCallback, SessionRuntime, SessionRuntimeConfig, StateChangeCallback,
7};
8use crate::auth::{LoginChallenge, TanAction};
9use crate::error::Result;
10use crate::types::SessionStateType;
11
12const DEFAULT_BASE_URL: &str = "https://api.comdirect.de";
13
14/// Configuration used to create a [`Session`].
15///
16/// The configuration separates static credentials from runtime concerns such as
17/// timeouts and token refresh behavior. This keeps all I/O settings in one
18/// location and avoids hidden defaults spread across modules.
19///
20/// # Example
21///
22/// ```no_run
23/// use comdirect_rest_api::{Session, SessionConfig};
24///
25/// # async fn demo() -> Result<(), comdirect_rest_api::ComdirectError> {
26/// let config = SessionConfig::new("client-id", "client-secret", "username", "pin")
27///     .with_base_url("https://api.comdirect.de")
28///     .with_request_timeout(std::time::Duration::from_secs(20))
29///     .with_refresh_buffer(std::time::Duration::from_secs(90));
30///
31/// let session = Session::from_config(config)?;
32/// session.try_restore("persisted-refresh-token").await?;
33/// # Ok(())
34/// # }
35/// ```
36#[derive(Debug, Clone)]
37pub struct SessionConfig {
38    client_id: String,
39    client_secret: String,
40    username: String,
41    password: String,
42    base_url: String,
43    request_timeout: Duration,
44    refresh_buffer: Duration,
45}
46
47impl SessionConfig {
48    /// Creates configuration with production defaults.
49    pub fn new(
50        client_id: impl Into<String>,
51        client_secret: impl Into<String>,
52        username: impl Into<String>,
53        password: impl Into<String>,
54    ) -> Self {
55        Self {
56            client_id: client_id.into(),
57            client_secret: client_secret.into(),
58            username: username.into(),
59            password: password.into(),
60            base_url: DEFAULT_BASE_URL.to_string(),
61            request_timeout: Duration::from_secs(30),
62            refresh_buffer: Duration::from_secs(60),
63        }
64    }
65
66    /// Overrides the API base URL.
67    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
68        self.base_url = base_url.into();
69        self
70    }
71
72    /// Overrides the request timeout used for all HTTP calls.
73    pub fn with_request_timeout(mut self, request_timeout: Duration) -> Self {
74        self.request_timeout = request_timeout;
75        self
76    }
77
78    /// Configures how early token refresh should happen before expiry.
79    pub fn with_refresh_buffer(mut self, refresh_buffer: Duration) -> Self {
80        self.refresh_buffer = refresh_buffer;
81        self
82    }
83}
84
85/// Stateful comdirect API session with automatic token refresh.
86///
87/// The session encapsulates auth orchestration, transient-error retries,
88/// background refresh supervision, and endpoint access.
89///
90/// # Example
91///
92/// ```no_run
93/// use comdirect_rest_api::{Session, TanAction};
94/// use std::io::{self, Write};
95///
96/// # async fn demo() -> Result<(), comdirect_rest_api::ComdirectError> {
97/// let session = Session::new("client-id", "client-secret", "username", "pin")?;
98///
99/// session
100///     .login(|challenge| async move {
101///         println!("challenge {}", challenge.challenge_id);
102///         println!("Approve the Push-TAN in your Comdirect app first.");
103///         print!("Press Enter after approval... ");
104///         let _ = io::stdout().flush();
105///         let mut line = String::new();
106///         let _ = io::stdin().read_line(&mut line);
107///         TanAction::ConfirmPushTan
108///     })
109///     .await?;
110///
111/// let balances = session.accounts().get_balances().await?;
112/// println!("{} accounts", balances.values.len());
113/// # Ok(())
114/// # }
115/// ```
116#[derive(Clone)]
117pub struct Session {
118    #[cfg(feature = "web")]
119    username: String,
120    runtime: Arc<SessionRuntime>,
121}
122
123impl Session {
124    /// Creates a session using default runtime settings.
125    pub fn new(
126        client_id: impl Into<String>,
127        client_secret: impl Into<String>,
128        username: impl Into<String>,
129        password: impl Into<String>,
130    ) -> Result<Self> {
131        let config = SessionConfig::new(client_id, client_secret, username, password);
132        Self::from_config(config)
133    }
134
135    /// Creates a session from explicit [`SessionConfig`].
136    pub fn from_config(config: SessionConfig) -> Result<Self> {
137        #[cfg(feature = "web")]
138        let username = config.username.clone();
139        #[cfg(feature = "log")]
140        let log_username = config.username.clone();
141
142        let runtime = Arc::new(SessionRuntime::new(SessionRuntimeConfig {
143            base_url: config.base_url,
144            client_id: config.client_id,
145            client_secret: config.client_secret,
146            username: config.username,
147            password: config.password,
148            request_timeout: config.request_timeout,
149            refresh_buffer_secs: config.refresh_buffer.as_secs(),
150        })?);
151
152        #[cfg(feature = "log")]
153        crate::log_info!(username = %log_username, "session created");
154
155        Ok(Self {
156            #[cfg(feature = "web")]
157            username,
158            runtime,
159        })
160    }
161
162    /// Returns a copyable endpoint client for account operations.
163    pub fn accounts(&self) -> crate::accounts::AccountsApi {
164        crate::accounts::AccountsApi::new(self.clone())
165    }
166
167    /// Returns a copyable endpoint client for brokerage operations.
168    pub fn brokerage(&self) -> crate::brokerage::BrokerageApi {
169        crate::brokerage::BrokerageApi::new(self.clone())
170    }
171
172    /// Registers a callback invoked on session state transitions.
173    pub async fn set_state_change_callback<F>(&self, callback: F)
174    where
175        F: Fn(SessionStateType, SessionStateType) + Send + Sync + 'static,
176    {
177        self.runtime
178            .set_state_change_callback(Some(Arc::new(callback) as Arc<StateChangeCallback>))
179            .await;
180    }
181
182    /// Registers a callback invoked whenever a new refresh token is issued.
183    pub async fn set_refresh_token_callback<F>(&self, callback: F)
184    where
185        F: Fn(String) + Send + Sync + 'static,
186    {
187        self.runtime
188            .set_refresh_token_callback(Some(Arc::new(callback) as Arc<RefreshTokenCallback>))
189            .await;
190    }
191
192    /// Reads the current authorization state.
193    pub async fn state(&self) -> SessionStateType {
194        self.runtime.state().await
195    }
196
197    /// Performs non-interactive login using a persisted refresh token.
198    ///
199    /// This is the preferred startup path for long-running services because it
200    /// avoids interactive TAN prompts during cold start.
201    pub async fn try_restore(&self, refresh_token: &str) -> Result<()> {
202        self.runtime.login_try(refresh_token).await
203    }
204
205    /// Performs interactive login and asks the callback how to answer TAN challenges.
206    ///
207    /// The callback is awaited before TAN completion is submitted. For `ConfirmPushTan`,
208    /// return only after the user has actually confirmed the challenge externally
209    /// (for example via a frontend "I've allowed it" action).
210    ///
211    /// Use this for initial bootstrap of refresh tokens. Persist the refresh
212    /// token from `set_refresh_token_callback` and switch to [`Self::try_restore`]
213    /// for subsequent restarts.
214    pub async fn login<F, Fut>(&self, callback: F) -> Result<()>
215    where
216        F: Fn(LoginChallenge) -> Fut + Send + Sync,
217        Fut: Future<Output = TanAction> + Send,
218    {
219        self.runtime.login(callback).await
220    }
221
222    /// Stops background refresh tasks and marks the session unauthorized.
223    ///
224    /// Call this before process shutdown to ensure refresh workers are cancelled
225    /// deterministically.
226    pub async fn shutdown(&self) {
227        self.runtime.shutdown().await;
228    }
229
230    #[cfg(feature = "web")]
231    /// Mounts HTTP login routes for browser-driven TAN flows.
232    pub async fn login_web(self: Arc<Self>, router: axum::Router) -> axum::Router {
233        crate::web::mount_session_login_route(router, "/comdirect", self.username.clone(), self)
234            .await
235    }
236
237    pub(crate) async fn get_authorized_resource(
238        &self,
239        operation: &'static str,
240        api_path: &str,
241    ) -> Result<String> {
242        self.runtime
243            .get_authorized_resource(operation, api_path)
244            .await
245    }
246}