Skip to main content

axum_security/oauth2/
builder.rs

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    /// Appends an extra query parameter to every authorization redirect —
83    /// provider-specific knobs like Google's `access_type=offline` +
84    /// `prompt=consent` (required to receive a refresh token) or GitHub's
85    /// `allow_signup=false`.
86    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    /// Authenticate to the token endpoint with HTTP Basic (RFC 6749
92    /// §2.3.1). This is the default; call this only to undo a prior
93    /// [`request_body`](Self::request_body).
94    pub fn basic_auth(mut self) -> Self {
95        self.client_builder = self.client_builder.basic_auth();
96        self
97    }
98
99    /// Send `client_id` and `client_secret` in the request body instead of
100    /// an HTTP Basic header, for providers that don't support Basic.
101    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    /// Sets the secret used to sign the login state cookie.
136    ///
137    /// A secret is **required**: [`try_build`](Self::try_build) fails with
138    /// [`OAuth2BuilderError::MissingCookieSecret`] if none is set. Use a
139    /// stable secret (e.g. from the environment) so that in-flight logins
140    /// survive restarts and work across instances; for local development
141    /// [`random_cookie_secret`](Self::random_cookie_secret) opts into an
142    /// ephemeral one.
143    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    /// Signs the login state cookie with a fresh random per-process secret.
149    ///
150    /// The secret is regenerated on every restart and differs between
151    /// instances, so a login begun on one process cannot be completed on
152    /// another. Only appropriate for local development or single-instance
153    /// deployments; otherwise set a stable [`cookie_secret`](Self::cookie_secret).
154    pub fn random_cookie_secret(mut self) -> Self {
155        self.cookie_builder.use_random_secret();
156        self
157    }
158
159    /// max length of the entire login flow.
160    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    /// max length of the entire login flow.
167    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    /// The default
177    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        // The protocol crate treats redirect_url as optional; here the
196        // callback route is derived from it, so it is required.
197        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            // The reqwest backend is always enabled here, so a default
222            // HTTP client always exists; `#[non_exhaustive]` forces the arm.
223            _ => 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    /// No cookie signing secret was set. Provide one with `cookie_secret`
244    /// or opt into an ephemeral one with `random_cookie_secret`.
245    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}