1use std::{borrow::Cow, error::Error, fmt::Display, sync::Arc, time::Duration};
2
3use axum_security_oauth2::{ConfigError, HttpClient, OAuth2Client, OAuth2ClientBuilder};
4use cookie_monster::CookieBuilder;
5
6use crate::{
7 oauth2::{
8 OAuth2Context, OAuth2Handler, context::OAuth2ContextInner, cookie::OAuthCookieBuilder,
9 },
10 utils::get_env,
11};
12
13pub struct OAuth2ContextBuilder {
14 cookie_builder: OAuthCookieBuilder,
15 login_path: Option<Cow<'static, str>>,
16 client_builder: OAuth2ClientBuilder,
17 auth_params: Vec<(String, String)>,
18 flow_type: FlowType,
19}
20
21impl OAuth2ContextBuilder {
22 pub fn new(oauth2_provider_name: impl Into<Cow<'static, str>>) -> OAuth2ContextBuilder {
23 Self {
24 cookie_builder: OAuthCookieBuilder::new(oauth2_provider_name.into()),
25 login_path: None,
26 client_builder: OAuth2Client::builder(),
27 auth_params: Vec::new(),
28 flow_type: FlowType::AuthorizationCodeFlowPkce,
29 }
30 }
31
32 pub fn redirect_url(mut self, url: impl Into<String>) -> Self {
33 self.client_builder.set_redirect_url(url);
34 self
35 }
36
37 pub fn redirect_uri_env(self, name: &str) -> Self {
38 self.redirect_url(get_env(name))
39 }
40
41 pub fn client_id(mut self, client_id: impl Into<String>) -> Self {
42 self.client_builder.set_client_id(client_id);
43 self
44 }
45
46 pub fn client_id_env(self, name: &str) -> Self {
47 self.client_id(get_env(name))
48 }
49
50 pub fn client_secret(mut self, client_secret: impl Into<String>) -> Self {
51 self.client_builder.set_client_secret(client_secret);
52 self
53 }
54
55 pub fn client_secret_env(self, name: &str) -> Self {
56 self.client_secret(get_env(name))
57 }
58
59 pub fn auth_url(mut self, auth_url: impl Into<String>) -> Self {
60 self.client_builder.set_auth_url(auth_url);
61 self
62 }
63
64 pub fn auth_url_env(self, name: &str) -> Self {
65 self.auth_url(get_env(name))
66 }
67
68 pub fn token_url(mut self, token_url: impl Into<String>) -> Self {
69 self.client_builder.set_token_url(token_url);
70 self
71 }
72
73 pub fn token_url_env(self, name: &str) -> Self {
74 self.token_url(get_env(name))
75 }
76
77 pub fn scopes(mut self, scopes: &[&str]) -> Self {
78 self.client_builder.set_scopes(scopes);
79 self
80 }
81
82 pub fn auth_param(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
87 self.auth_params.push((name.into(), value.into()));
88 self
89 }
90
91 pub fn basic_auth(mut self) -> Self {
95 self.client_builder = self.client_builder.basic_auth();
96 self
97 }
98
99 pub fn request_body(mut self) -> Self {
102 self.client_builder = self.client_builder.request_body();
103 self
104 }
105
106 pub fn cookie(mut self, f: impl FnOnce(CookieBuilder) -> CookieBuilder) -> Self {
107 self.cookie_builder.cookie_builder.apply_cookie(f);
108 self
109 }
110
111 pub fn dev_cookie(mut self, f: impl FnOnce(CookieBuilder) -> CookieBuilder) -> Self {
112 self.cookie_builder.cookie_builder.apply_dev_cookie(f);
113 self
114 }
115
116 pub fn login_path(mut self, path: impl Into<Cow<'static, str>>) -> Self {
117 self.login_path = Some(path.into());
118 self
119 }
120
121 pub fn use_dev_cookies(mut self, dev: bool) -> Self {
122 self.cookie_builder.cookie_builder.dev = dev;
123 self
124 }
125
126 pub fn use_normal_cookies(self, prod: bool) -> Self {
127 self.use_dev_cookies(!prod)
128 }
129
130 pub fn http_client(mut self, http_client: impl Into<HttpClient>) -> Self {
131 self.client_builder.set_http_client(http_client);
132 self
133 }
134
135 pub fn cookie_secret(mut self, secret: impl AsRef<[u8]>) -> Self {
144 self.cookie_builder.secret = Some(secret.as_ref().to_vec());
145 self
146 }
147
148 pub fn random_cookie_secret(mut self) -> Self {
155 self.cookie_builder.use_random_secret();
156 self
157 }
158
159 pub fn max_login_duration(mut self, duration: Duration) -> Self {
161 self.cookie_builder
162 .set_max_login_duration_secs(duration.as_secs());
163 self
164 }
165
166 pub fn max_login_duration_minutes(self, minutes: u64) -> Self {
168 self.max_login_duration(Duration::from_mins(minutes))
169 }
170
171 pub fn authorization_code_flow(mut self) -> Self {
172 self.flow_type = FlowType::AuthorizationCodeFlow;
173 self
174 }
175
176 pub fn authorization_code_flow_with_pkce(mut self) -> Self {
178 self.flow_type = FlowType::AuthorizationCodeFlowPkce;
179 self
180 }
181
182 pub fn build<T>(self, inner: T) -> OAuth2Context<T>
183 where
184 T: OAuth2Handler,
185 {
186 self.try_build(inner).unwrap()
187 }
188
189 pub fn try_build<T>(self, inner: T) -> Result<OAuth2Context<T>, OAuth2BuilderError>
190 where
191 T: OAuth2Handler,
192 {
193 let client = self.client_builder.try_build()?;
194
195 if client.redirect_url().is_none() {
198 return Err(OAuth2BuilderError::MissingRedirectUrl);
199 }
200
201 Ok(OAuth2Context(Arc::new(OAuth2ContextInner {
202 client,
203 inner,
204 session: self.cookie_builder.try_build()?,
205 login_path: self.login_path,
206 auth_params: self.auth_params,
207 flow_type: self.flow_type,
208 })))
209 }
210}
211
212impl From<ConfigError> for OAuth2BuilderError {
213 fn from(error: ConfigError) -> Self {
214 match error {
215 ConfigError::MissingClientId => OAuth2BuilderError::MissingClientId,
216 ConfigError::MissingAuthUrl => OAuth2BuilderError::MissingAuthUrl,
217 ConfigError::MissingTokenUrl => OAuth2BuilderError::MissingTokenUrl,
218 ConfigError::InvalidAuthUrl(e) => OAuth2BuilderError::InvalidAuthUrl(e),
219 ConfigError::InvalidTokenUrl(e) => OAuth2BuilderError::InvalidTokenUrl(e),
220 ConfigError::InvalidRedirectUrl(e) => OAuth2BuilderError::InvalidRedirectUrl(e),
221 _ => unreachable!("unexpected oauth2 config error: {error}"),
224 }
225 }
226}
227
228pub(crate) enum FlowType {
229 AuthorizationCodeFlow,
230 AuthorizationCodeFlowPkce,
231}
232
233#[derive(Debug)]
234pub enum OAuth2BuilderError {
235 MissingClientId,
236 MissingRedirectUrl,
237 MissingAuthUrl,
238 MissingTokenUrl,
239 InvalidRedirectUrl(url::ParseError),
240 InvalidAuthUrl(url::ParseError),
241 InvalidTokenUrl(url::ParseError),
242 WhitespaceInProviderName,
243 MissingCookieSecret,
246}
247
248impl Error for OAuth2BuilderError {}
249
250impl Display for OAuth2BuilderError {
251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 match self {
253 OAuth2BuilderError::MissingClientId => f.write_str("client id is missing"),
254 OAuth2BuilderError::MissingRedirectUrl => f.write_str("redirect url is missing"),
255 OAuth2BuilderError::MissingAuthUrl => f.write_str("authorization url is missing"),
256 OAuth2BuilderError::MissingTokenUrl => f.write_str("token url is missing"),
257 OAuth2BuilderError::InvalidRedirectUrl(parse_error) => {
258 write!(f, "could not parse redirect url: {}", parse_error)
259 }
260 OAuth2BuilderError::InvalidAuthUrl(parse_error) => {
261 write!(f, "could not parse authorization url: {}", parse_error)
262 }
263 OAuth2BuilderError::InvalidTokenUrl(parse_error) => {
264 write!(f, "could not parse token url: {}", parse_error)
265 }
266 OAuth2BuilderError::WhitespaceInProviderName => {
267 f.write_str("provider name can't contain whitespaces")
268 }
269 OAuth2BuilderError::MissingCookieSecret => f.write_str(
270 "cookie signing secret is missing (set one with `cookie_secret`, or opt into an ephemeral one with `random_cookie_secret`)",
271 ),
272 }
273 }
274}
275
276#[cfg(test)]
277mod builder {
278 use axum::response::IntoResponse;
279
280 use crate::oauth2::{
281 AfterLoginCookies, OAuth2BuilderError, OAuth2Context, OAuth2Handler, TokenResponse,
282 providers::github,
283 };
284
285 const CLIENT_ID: &str = "test_client_id";
286 const CLIENT_SECRET: &str = "test_client_secret";
287 const REDIRECT_URL: &str = "http://rust-lang.org/redirect";
288 const AUTH_URL: &str = github::AUTH_URL;
289 const TOKEN_URL: &str = github::TOKEN_URL;
290
291 struct TestHandler {}
292
293 impl OAuth2Handler for TestHandler {
294 async fn after_login(
295 &self,
296 _token_res: TokenResponse,
297 _context: &mut AfterLoginCookies<'_>,
298 ) -> impl IntoResponse {
299 ()
300 }
301 }
302
303 #[test]
304 fn builder_errors() {
305 let res = OAuth2Context::builder("github")
306 .client_id(CLIENT_ID)
307 .client_secret(CLIENT_SECRET)
308 .auth_url(AUTH_URL)
309 .token_url(TOKEN_URL)
310 .redirect_url(REDIRECT_URL)
311 .random_cookie_secret()
312 .try_build(TestHandler {});
313
314 assert!(res.is_ok());
315
316 let res = OAuth2Context::builder("github")
317 .client_id(CLIENT_ID)
318 .auth_url(AUTH_URL)
319 .token_url(TOKEN_URL)
320 .redirect_url(REDIRECT_URL)
321 .random_cookie_secret()
322 .try_build(TestHandler {});
323
324 assert!(res.is_ok());
325 }
326
327 #[test]
328 fn missing_cookie_secret() {
329 let res = OAuth2Context::builder("github")
330 .client_id(CLIENT_ID)
331 .client_secret(CLIENT_SECRET)
332 .auth_url(AUTH_URL)
333 .token_url(TOKEN_URL)
334 .redirect_url(REDIRECT_URL)
335 .try_build(TestHandler {});
336
337 assert!(matches!(res, Err(OAuth2BuilderError::MissingCookieSecret)));
338 }
339
340 #[test]
341 fn client_id() {
342 let res = OAuth2Context::builder("github")
343 .client_secret(CLIENT_SECRET)
344 .auth_url(AUTH_URL)
345 .token_url(TOKEN_URL)
346 .redirect_url(REDIRECT_URL)
347 .try_build(TestHandler {});
348
349 assert!(matches!(res, Err(OAuth2BuilderError::MissingClientId)));
350 }
351
352 #[test]
353 fn auth_url() {
354 let res = OAuth2Context::builder("github")
355 .client_id(CLIENT_ID)
356 .client_secret(CLIENT_SECRET)
357 .token_url(TOKEN_URL)
358 .redirect_url(REDIRECT_URL)
359 .try_build(TestHandler {});
360
361 assert!(matches!(res, Err(OAuth2BuilderError::MissingAuthUrl)));
362
363 let res = OAuth2Context::builder("github")
364 .client_id(CLIENT_ID)
365 .client_secret(CLIENT_SECRET)
366 .auth_url("not an url")
367 .token_url(TOKEN_URL)
368 .redirect_url(REDIRECT_URL)
369 .try_build(TestHandler {});
370
371 assert!(matches!(res, Err(OAuth2BuilderError::InvalidAuthUrl(_))));
372 }
373
374 #[test]
375 fn token_url() {
376 let res = OAuth2Context::builder("github")
377 .client_id(CLIENT_ID)
378 .client_secret(CLIENT_SECRET)
379 .auth_url(AUTH_URL)
380 .redirect_url(REDIRECT_URL)
381 .try_build(TestHandler {});
382
383 assert!(matches!(res, Err(OAuth2BuilderError::MissingTokenUrl)));
384
385 let res = OAuth2Context::builder("github")
386 .client_id(CLIENT_ID)
387 .client_secret(CLIENT_SECRET)
388 .auth_url(AUTH_URL)
389 .token_url("not an url")
390 .redirect_url(REDIRECT_URL)
391 .try_build(TestHandler {});
392
393 assert!(matches!(res, Err(OAuth2BuilderError::InvalidTokenUrl(_))));
394 }
395
396 #[test]
397 fn redirect_url() {
398 let res = OAuth2Context::builder("github")
399 .client_id(CLIENT_ID)
400 .client_secret(CLIENT_SECRET)
401 .auth_url(AUTH_URL)
402 .token_url(TOKEN_URL)
403 .try_build(TestHandler {});
404
405 assert!(matches!(res, Err(OAuth2BuilderError::MissingRedirectUrl)));
406
407 let res = OAuth2Context::builder("github")
408 .client_id(CLIENT_ID)
409 .client_secret(CLIENT_SECRET)
410 .auth_url(AUTH_URL)
411 .token_url(TOKEN_URL)
412 .redirect_url("not an url")
413 .try_build(TestHandler {});
414
415 assert!(matches!(
416 res,
417 Err(OAuth2BuilderError::InvalidRedirectUrl(_))
418 ));
419 }
420
421 #[test]
422 fn provider_name() {
423 let res = OAuth2Context::builder("github ")
424 .client_id(CLIENT_ID)
425 .auth_url(AUTH_URL)
426 .token_url(TOKEN_URL)
427 .redirect_url(REDIRECT_URL)
428 .try_build(TestHandler {});
429
430 assert!(matches!(
431 res,
432 Err(OAuth2BuilderError::WhitespaceInProviderName)
433 ));
434 }
435}