1use crate::auth::{
2 error::AuthError, state::Identity, state::OAuth2State, state::OAuthToken, ErasedOAuthFlow,
3 OAuthProvider, UserMapper,
4};
5use crate::flow::{Flow, FlowContext, FlowResult};
6use async_trait::async_trait;
7
8pub struct OAuth2Flow<P: OAuthProvider, M: UserMapper = ()> {
10 provider: P,
11 mapper: Option<M>,
12 scopes: Vec<String>,
13 use_pkce: bool,
14}
15
16#[async_trait]
17impl<P: OAuthProvider + 'static, M: UserMapper + 'static> Flow for OAuth2Flow<P, M> {
18 fn id(&self) -> &str {
19 self.provider.provider_id()
20 }
21
22 async fn execute(&self, ctx: FlowContext) -> Result<FlowResult, AuthError> {
23 if let Some(_code) = ctx.params.get("code") {
24 let _received_state = ctx.params.get("state").ok_or(AuthError::CsrfMismatch)?;
25
26 Err(AuthError::Token(
30 "Direct Flow execution not updated for encrypted state".to_string(),
31 ))
32 } else {
33 let scopes_str = ctx.params.get("scopes").map(|s| s.as_str()).unwrap_or("");
35 let scopes_vec: Vec<&str> = if scopes_str.is_empty() {
36 Vec::new()
37 } else {
38 scopes_str.split(',').collect()
39 };
40
41 let pkce_challenge = ctx.params.get("pkce_challenge").map(|s| s.as_str());
42 let (url, _state) = self.initiate_login(&scopes_vec, pkce_challenge);
43 Ok(FlowResult::Redirect(url))
44 }
45 }
46}
47
48#[async_trait]
49impl<P: OAuthProvider + 'static, M: UserMapper + 'static> ErasedOAuthFlow for OAuth2Flow<P, M> {
50 fn provider_id(&self) -> String {
51 self.provider.provider_id().to_string()
52 }
53
54 fn initiate_login(
55 &self,
56 scopes: &[&str],
57 pkce_challenge: Option<&str>,
58 ) -> (String, OAuth2State) {
59 let effective_scopes = if !scopes.is_empty() {
60 scopes
61 } else {
62 &self
63 .scopes
64 .iter()
65 .map(|s| s.as_str())
66 .collect::<Vec<&str>>()
67 };
68
69 self.initiate_login(effective_scopes, pkce_challenge)
70 }
71
72 async fn finalize_login(
73 &self,
74 code: &str,
75 received_state: &str,
76 expected_state: &OAuth2State,
77 ) -> Result<(Identity, OAuthToken), AuthError> {
78 let (identity, token, _) = self
79 .finalize_login(code, received_state, expected_state)
80 .await?;
81 Ok((identity, token))
82 }
83}
84
85impl<P: OAuthProvider> OAuth2Flow<P, ()> {
86 pub fn new(provider: P) -> Self {
88 Self {
89 provider,
90 mapper: None,
91 scopes: Vec::new(),
92 use_pkce: true,
93 }
94 }
95}
96
97impl<P: OAuthProvider, M: UserMapper> OAuth2Flow<P, M> {
98 pub fn with_mapper(provider: P, mapper: M) -> Self {
100 Self {
101 provider,
102 mapper: Some(mapper),
103 scopes: Vec::new(),
104 use_pkce: true,
105 }
106 }
107
108 pub fn with_scopes(mut self, scopes: Vec<impl Into<String>>) -> Self {
110 self.scopes = scopes.into_iter().map(|s| s.into()).collect();
111 self
112 }
113
114 pub fn with_pkce(mut self, use_pkce: bool) -> Self {
116 self.use_pkce = use_pkce;
117 self
118 }
119
120 #[tracing::instrument(skip(self), fields(provider_id = %self.provider.provider_id()))]
122 pub fn initiate_login(
123 &self,
124 scopes: &[&str],
125 pkce_challenge: Option<&str>,
126 ) -> (String, OAuth2State) {
127 let state = uuid::Uuid::new_v4().to_string();
128 let nonce = Some(uuid::Uuid::new_v4().to_string());
134
135 let effective_scopes = if !scopes.is_empty() {
136 scopes
137 } else {
138 &self
139 .scopes
140 .iter()
141 .map(|s| s.as_str())
142 .collect::<Vec<&str>>()
143 };
144
145 tracing::debug!(scopes = ?effective_scopes, "generating authorization URL");
146
147 let url = self.provider.get_authorization_url(
148 &state,
149 effective_scopes,
150 pkce_challenge,
151 nonce.as_deref(),
152 );
153
154 let auth_state = OAuth2State {
155 state: state.clone(),
156 nonce,
157 code_verifier: None, success_url: None,
159 provider_id: self.provider.provider_id().to_string(),
160 expires_at: chrono::Utc::now().timestamp() + 600,
161 };
162
163 tracing::info!("authorization login initiated successfully");
164 (url, auth_state)
165 }
166
167 #[tracing::instrument(skip(self, code, expected_state), fields(provider_id = %self.provider.provider_id()))]
170 pub async fn finalize_login(
171 &self,
172 code: &str,
173 received_state: &str,
174 expected_state: &OAuth2State,
175 ) -> Result<(Identity, OAuthToken, Option<M::LocalUser>), AuthError> {
176 if received_state != expected_state.state {
177 tracing::error!("CSRF mismatch: received state does not match expected state");
178 return Err(AuthError::CsrfMismatch);
179 }
180
181 tracing::debug!("exchanging code for identity");
182 let (identity, token) = self
183 .provider
184 .exchange_code_for_identity(
185 code,
186 expected_state.code_verifier.as_deref(),
187 expected_state.nonce.as_deref(),
188 )
189 .await
190 .map_err(|e| {
191 tracing::error!(error = %e, "failed to exchange code for identity");
192 e
193 })?;
194
195 tracing::info!(user_id = %identity.external_id, "successfully retrieved identity from provider");
196
197 if self.provider.validates_nonce() {
209 if let Some(expected_nonce) = expected_state.nonce.as_deref() {
210 if identity.attributes.get("nonce").map(|s| s.as_str()) != Some(expected_nonce) {
211 tracing::error!("nonce mismatch or missing in identity attributes");
212 return Err(AuthError::Token("Nonce mismatch".to_string()));
213 }
214 }
215 }
216
217 let local_user = if let Some(mapper) = &self.mapper {
218 tracing::debug!("mapping user identity");
219 Some(mapper.map_user(&identity).await.map_err(|e| {
220 tracing::error!(error = %e, "failed to map user");
221 e
222 })?)
223 } else {
224 None
225 };
226
227 Ok((identity, token, local_user))
228 }
229
230 pub async fn refresh_access_token(&self, refresh_token: &str) -> Result<OAuthToken, AuthError> {
232 self.provider.refresh_token(refresh_token).await
233 }
234
235 pub async fn revoke_token(&self, token: &str) -> Result<(), AuthError> {
237 self.provider.revoke_token(token).await
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use crate::auth::{Provider, ProviderConfig};
245 use async_trait::async_trait;
246 use std::collections::HashMap;
247
248 struct MockProvider {
249 id: String,
250 auth_url: String,
251 expected_code: String,
252 identity: Identity,
253 token: OAuthToken,
254 }
255
256 #[async_trait]
257 impl Provider for MockProvider {
258 async fn config(&self) -> ProviderConfig {
259 ProviderConfig {
260 id: self.id.clone(),
261 name: self.id.clone(),
262 extra: HashMap::new(),
263 }
264 }
265 }
266
267 #[async_trait]
268 impl OAuthProvider for MockProvider {
269 fn provider_id(&self) -> &str {
270 &self.id
271 }
272
273 fn get_authorization_url(
274 &self,
275 state: &str,
276 _scopes: &[&str],
277 _code_challenge: Option<&str>,
278 _nonce: Option<&str>,
279 ) -> String {
280 format!("{}?state={}", self.auth_url, state)
281 }
282
283 async fn exchange_code_for_identity(
284 &self,
285 code: &str,
286 _code_verifier: Option<&str>,
287 _nonce: Option<&str>,
288 ) -> Result<(Identity, OAuthToken), AuthError> {
289 if code == self.expected_code {
290 Ok((self.identity.clone(), self.token.clone()))
291 } else {
292 Err(AuthError::Token("Invalid code".to_string()))
293 }
294 }
295 }
296
297 #[tokio::test]
298 async fn test_oauth2_flow_initiate() {
299 let provider = MockProvider {
300 id: "mock".to_string(),
301 auth_url: "http://mock/auth".to_string(),
302 expected_code: "code123".to_string(),
303 identity: Identity {
304 provider_id: "mock".to_string(),
305 external_id: "1".to_string(),
306 email: None,
307 username: None,
308 attributes: HashMap::new(),
309 },
310 token: OAuthToken {
311 access_token: "acc".to_string(),
312 token_type: "Bearer".to_string(),
313 expires_in: None,
314 refresh_token: None,
315 scope: None,
316 id_token: None,
317 },
318 };
319
320 let flow = OAuth2Flow::new(provider).with_scopes(vec!["scope1"]);
321 let (url, state) = flow.initiate_login(&["scope2"], None);
322 assert!(url.contains("http://mock/auth?state="));
323 assert_eq!(state.provider_id, "mock");
324 }
325
326 #[tokio::test]
327 async fn test_oauth2_flow_finalize_success() {
328 let provider = MockProvider {
329 id: "mock".to_string(),
330 auth_url: "http://mock/auth".to_string(),
331 expected_code: "code123".to_string(),
332 identity: Identity {
333 provider_id: "mock".to_string(),
334 external_id: "1".to_string(),
335 email: None,
336 username: None,
337 attributes: HashMap::new(),
338 },
339 token: OAuthToken {
340 access_token: "acc".to_string(),
341 token_type: "Bearer".to_string(),
342 expires_in: None,
343 refresh_token: None,
344 scope: None,
345 id_token: None,
346 },
347 };
348
349 let flow = OAuth2Flow::new(provider);
350 let expected_state = OAuth2State {
351 state: "state123".to_string(),
352 nonce: None,
353 code_verifier: None,
354 success_url: None,
355 provider_id: "mock".to_string(),
356 expires_at: 0,
357 };
358
359 let (ident, tok, _) = flow
360 .finalize_login("code123", "state123", &expected_state)
361 .await
362 .unwrap();
363 assert_eq!(ident.external_id, "1");
364 assert_eq!(tok.access_token, "acc");
365 }
366
367 #[tokio::test]
368 async fn test_oauth2_flow_finalize_csrf_mismatch() {
369 let provider = MockProvider {
370 id: "mock".to_string(),
371 auth_url: "http://mock/auth".to_string(),
372 expected_code: "code123".to_string(),
373 identity: Identity {
374 provider_id: "mock".to_string(),
375 external_id: "1".to_string(),
376 email: None,
377 username: None,
378 attributes: HashMap::new(),
379 },
380 token: OAuthToken {
381 access_token: "acc".to_string(),
382 token_type: "Bearer".to_string(),
383 expires_in: None,
384 refresh_token: None,
385 scope: None,
386 id_token: None,
387 },
388 };
389
390 let flow = OAuth2Flow::new(provider);
391 let expected_state = OAuth2State {
392 state: "state123".to_string(),
393 nonce: None,
394 code_verifier: None,
395 success_url: None,
396 provider_id: "mock".to_string(),
397 expires_at: 0,
398 };
399
400 let result = flow
401 .finalize_login("code123", "wrong_state", &expected_state)
402 .await;
403 assert!(matches!(result, Err(AuthError::CsrfMismatch)));
404 }
405
406 fn mock(id: &str) -> MockProvider {
407 MockProvider {
408 id: id.to_string(),
409 auth_url: "http://mock/auth".to_string(),
410 expected_code: "code123".to_string(),
411 identity: Identity {
412 provider_id: id.to_string(),
413 external_id: "1".to_string(),
414 email: None,
415 username: None,
416 attributes: HashMap::new(),
417 },
418 token: OAuthToken {
419 access_token: "acc".to_string(),
420 token_type: "Bearer".to_string(),
421 expires_in: None,
422 refresh_token: None,
423 scope: None,
424 id_token: None,
425 },
426 }
427 }
428
429 struct NonceEchoing {
432 inner: MockProvider,
433 echo_instead: Option<String>,
435 }
436
437 #[async_trait]
438 impl Provider for NonceEchoing {
439 async fn config(&self) -> ProviderConfig {
440 self.inner.config().await
441 }
442 }
443
444 #[async_trait]
445 impl OAuthProvider for NonceEchoing {
446 fn validates_nonce(&self) -> bool {
447 true
448 }
449
450 fn provider_id(&self) -> &str {
451 self.inner.provider_id()
452 }
453
454 fn get_authorization_url(
455 &self,
456 state: &str,
457 scopes: &[&str],
458 code_challenge: Option<&str>,
459 nonce: Option<&str>,
460 ) -> String {
461 self.inner
462 .get_authorization_url(state, scopes, code_challenge, nonce)
463 }
464
465 async fn exchange_code_for_identity(
466 &self,
467 code: &str,
468 code_verifier: Option<&str>,
469 nonce: Option<&str>,
470 ) -> Result<(Identity, OAuthToken), AuthError> {
471 let (mut identity, token) = self
472 .inner
473 .exchange_code_for_identity(code, code_verifier, nonce)
474 .await?;
475 let surfaced = self
476 .echo_instead
477 .clone()
478 .or_else(|| nonce.map(|n| n.to_string()));
479 if let Some(value) = surfaced {
480 identity.attributes.insert("nonce".to_string(), value);
481 }
482 Ok((identity, token))
483 }
484 }
485
486 #[tokio::test]
490 async fn a_plain_provider_can_complete_a_login() {
491 let flow = OAuth2Flow::new(mock("plain"));
492 let (_url, state) = flow.initiate_login(&["email"], None);
493
494 let result = flow.finalize_login("code123", &state.state, &state).await;
495
496 assert!(
497 result.is_ok(),
498 "a plain OAuth2 login should complete; got {:?}",
499 result.err()
500 );
501 }
502
503 #[test]
510 fn every_provider_still_receives_a_nonce() {
511 let plain = OAuth2Flow::new(mock("plain"));
512 let (_url, state) = plain.initiate_login(&["email"], None);
513 assert!(
514 state.nonce.is_some(),
515 "a provider that does not advertise itself must still get a nonce"
516 );
517 }
518
519 #[tokio::test]
522 async fn a_wrong_nonce_is_still_rejected() {
523 let flow = OAuth2Flow::new(NonceEchoing {
524 inner: mock("oidc"),
525 echo_instead: Some("not-the-nonce".to_string()),
526 });
527 let (_url, state) = flow.initiate_login(&["email"], None);
528
529 let result = flow.finalize_login("code123", &state.state, &state).await;
530
531 assert!(
532 result.is_err(),
533 "gating the check must not disable it where a provider opts in"
534 );
535 }
536
537 #[tokio::test]
539 async fn a_matching_nonce_is_accepted() {
540 let flow = OAuth2Flow::new(NonceEchoing {
541 inner: mock("oidc"),
542 echo_instead: None, });
544 let (_url, state) = flow.initiate_login(&["email"], None);
545
546 let result = flow.finalize_login("code123", &state.state, &state).await;
547
548 assert!(
549 result.is_ok(),
550 "a correctly echoed nonce should pass; got {:?}",
551 result.err()
552 );
553 }
554}