Skip to main content

concur_api/
auth.rs

1use crate::error::{ConcurError, Result};
2use async_trait::async_trait;
3use serde::{Deserialize, Serialize};
4use std::sync::Mutex;
5use std::time::{Duration, Instant};
6
7/// OAuth2 token response from SAP Concur.
8#[derive(Debug, Clone, Deserialize, Serialize)]
9pub struct Token {
10    pub access_token: String,
11    pub token_type: String,
12    pub expires_in: u64,
13    pub refresh_token: Option<String>,
14    pub scope: Option<String>,
15    pub geolocation: Option<String>,
16    pub id_token: Option<String>,
17    #[serde(skip)]
18    pub obtained_at: Option<Instant>,
19}
20
21impl Token {
22    /// Returns true if the access token is expired or about to expire within the buffer.
23    pub fn is_expired(&self, buffer: Duration) -> bool {
24        match self.obtained_at {
25            Some(t) => {
26                let elapsed = t.elapsed();
27                let lifetime = Duration::from_secs(self.expires_in);
28                elapsed + buffer >= lifetime
29            }
30            None => true,
31        }
32    }
33}
34
35/// Trait for token management strategies.
36#[async_trait]
37pub trait TokenManager {
38    /// Return a valid access token, refreshing if necessary.
39    async fn access_token(&self) -> Result<String>;
40}
41
42/// Static token manager that always returns the same token.
43pub struct StaticToken {
44    token: String,
45}
46
47impl StaticToken {
48    pub fn new(token: impl Into<String>) -> Self {
49        Self {
50            token: token.into(),
51        }
52    }
53}
54
55#[async_trait]
56impl TokenManager for StaticToken {
57    async fn access_token(&self) -> Result<String> {
58        Ok(self.token.clone())
59    }
60}
61
62/// OAuth2 password / client-credentials grant token manager with refresh support.
63pub struct OAuth2TokenManager {
64    http: reqwest::Client,
65    token_url: String,
66    client_id: String,
67    client_secret: String,
68    grant: Grant,
69    state: Mutex<TokenState>,
70}
71
72struct TokenState {
73    token: Option<Token>,
74}
75
76#[derive(Debug, Clone)]
77pub enum Grant {
78    /// Client credentials grant (company / application token).
79    ClientCredentials { scope: Option<String> },
80    /// Password grant.
81    Password {
82        username: String,
83        password: String,
84        credtype: Option<String>,
85        scope: Option<String>,
86    },
87    /// Refresh token grant.
88    RefreshToken {
89        refresh_token: String,
90        scope: Option<String>,
91    },
92}
93
94impl OAuth2TokenManager {
95    pub fn new(
96        token_url: impl Into<String>,
97        client_id: impl Into<String>,
98        client_secret: impl Into<String>,
99        grant: Grant,
100    ) -> Self {
101        Self {
102            http: reqwest::Client::new(),
103            token_url: token_url.into(),
104            client_id: client_id.into(),
105            client_secret: client_secret.into(),
106            grant,
107            state: Mutex::new(TokenState { token: None }),
108        }
109    }
110
111    async fn fetch_token(&self) -> Result<Token> {
112        let mut params = vec![
113            ("client_id", self.client_id.clone()),
114            ("client_secret", self.client_secret.clone()),
115        ];
116
117        match &self.grant {
118            Grant::ClientCredentials { scope } => {
119                params.push(("grant_type", "client_credentials".to_string()));
120                if let Some(s) = scope {
121                    params.push(("scope", s.clone()));
122                }
123            }
124            Grant::Password {
125                username,
126                password,
127                credtype,
128                scope,
129            } => {
130                params.push(("grant_type", "password".to_string()));
131                params.push(("username", username.clone()));
132                params.push(("password", password.clone()));
133                if let Some(c) = credtype {
134                    params.push(("credtype", c.clone()));
135                }
136                if let Some(s) = scope {
137                    params.push(("scope", s.clone()));
138                }
139            }
140            Grant::RefreshToken {
141                refresh_token,
142                scope,
143            } => {
144                params.push(("grant_type", "refresh_token".to_string()));
145                params.push(("refresh_token", refresh_token.clone()));
146                if let Some(s) = scope {
147                    params.push(("scope", s.clone()));
148                }
149            }
150        }
151
152        let response = self
153            .http
154            .post(&self.token_url)
155            .header("Content-Type", "application/x-www-form-urlencoded")
156            .form(&params)
157            .send()
158            .await?;
159
160        if !response.status().is_success() {
161            let text = response.text().await.unwrap_or_default();
162            return Err(ConcurError::Auth(format!("Token request failed: {}", text)));
163        }
164
165        let mut token: Token = response.json().await?;
166        token.obtained_at = Some(Instant::now());
167        Ok(token)
168    }
169}
170
171#[async_trait]
172impl TokenManager for OAuth2TokenManager {
173    async fn access_token(&self) -> Result<String> {
174        {
175            let state = self
176                .state
177                .lock()
178                .map_err(|e| ConcurError::Other(e.to_string()))?;
179            if let Some(token) = &state.token
180                && !token.is_expired(Duration::from_secs(60))
181            {
182                return Ok(token.access_token.clone());
183            }
184        }
185
186        let token = self.fetch_token().await?;
187        let access = token.access_token.clone();
188
189        let mut state = self
190            .state
191            .lock()
192            .map_err(|e| ConcurError::Other(e.to_string()))?;
193        state.token = Some(token);
194        Ok(access)
195    }
196}
197
198/// Revoke all refresh tokens for a user/application.
199pub async fn revoke_tokens(
200    http: &reqwest::Client,
201    base_url: &str,
202    access_token: &str,
203) -> Result<()> {
204    let url = format!("{}/app-mgmt/v0/connections", base_url.trim_end_matches('/'));
205    let response = http
206        .delete(&url)
207        .header("Authorization", format!("Bearer {}", access_token))
208        .send()
209        .await?;
210
211    if response.status().is_success() {
212        Ok(())
213    } else {
214        let text = response.text().await.unwrap_or_default();
215        Err(ConcurError::Auth(format!("Revoke failed: {}", text)))
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use std::time::Duration;
223
224    #[test]
225    fn token_expiry_with_buffer() {
226        let mut token = Token {
227            access_token: "abc".to_string(),
228            token_type: "Bearer".to_string(),
229            expires_in: 300,
230            refresh_token: None,
231            scope: None,
232            geolocation: None,
233            id_token: None,
234            obtained_at: Some(Instant::now() - Duration::from_secs(200)),
235        };
236
237        assert!(!token.is_expired(Duration::from_secs(60)));
238
239        token.obtained_at = Some(Instant::now() - Duration::from_secs(245));
240        assert!(token.is_expired(Duration::from_secs(60)));
241    }
242
243    #[test]
244    fn token_expired_when_no_obtained_at() {
245        let token = Token {
246            access_token: "abc".to_string(),
247            token_type: "Bearer".to_string(),
248            expires_in: 300,
249            refresh_token: None,
250            scope: None,
251            geolocation: None,
252            id_token: None,
253            obtained_at: None,
254        };
255
256        assert!(token.is_expired(Duration::from_secs(60)));
257    }
258
259    #[tokio::test]
260    async fn static_token_returns_value() {
261        let manager = StaticToken::new("test-token");
262        assert_eq!(manager.access_token().await.unwrap(), "test-token");
263    }
264}