Skip to main content

rustlavel_oauth/
client.rs

1//! The client half: sending somebody to a provider, and trading what comes
2//! back for a token.
3//!
4//! ```ignore
5//! let google = OAuthClient::new(Provider::google())
6//!     .credentials(config.string("services.google.id", ""), config.string("services.google.secret", ""))
7//!     .redirect_uri("https://app.test/auth/google/callback");
8//!
9//! // Leg one: send them off, remembering the state.
10//! let start = google.begin(&StateGuard::session(req.session()))?;
11//! Response::see_other(start.url());
12//!
13//! // Leg two: they come back.
14//! let token = google.callback(req.query_string(), &guard).await?;
15//! let user = google.user(&token).await?;
16//! ```
17
18use crate::error::{OAuthError, OAuthErrorCode};
19use crate::pkce::Pkce;
20use crate::provider::{ClientAuth, Provider};
21use crate::scope::Scopes;
22use crate::state::StateGuard;
23use crate::token::TokenResponse;
24use crate::url;
25use crate::user::SocialUser;
26use rustlavel_auth::base64;
27use rustlavel_client::{Client, ClientResponse};
28use rustlavel_core::Json;
29
30/// Where a visitor should be sent, and the two secrets that must survive until
31/// they come back.
32///
33/// `Debug` redacts the verifier: the whole point of PKCE is that it is the one
34/// value an interceptor of the redirect does not have.
35pub struct Authorization {
36    url: String,
37    state: String,
38    pkce: Pkce,
39}
40
41impl Authorization {
42    pub fn url(&self) -> &str {
43        &self.url
44    }
45
46    pub fn state(&self) -> &str {
47        &self.state
48    }
49
50    pub fn verifier(&self) -> &str {
51        self.pkce.verifier()
52    }
53
54    pub fn pkce(&self) -> &Pkce {
55        &self.pkce
56    }
57
58    pub fn into_url(self) -> String {
59        self.url
60    }
61}
62
63impl std::fmt::Debug for Authorization {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.debug_struct("Authorization")
66            .field("url", &self.url)
67            .field("state", &self.state)
68            .field("verifier", &"<redacted>")
69            .finish()
70    }
71}
72
73/// An application's registration with one provider.
74///
75/// `Debug` redacts the secret. This type ends up in application state and in
76/// the development error page, and a client secret in either is a credential
77/// that has to be rotated.
78#[derive(Clone)]
79pub struct OAuthClient {
80    provider: Provider,
81    client_id: String,
82    client_secret: String,
83    redirect_uri: String,
84    scopes: Option<Scopes>,
85    params: Vec<(String, String)>,
86    http: Client,
87}
88
89impl OAuthClient {
90    pub fn new(provider: Provider) -> OAuthClient {
91        OAuthClient {
92            provider,
93            client_id: String::new(),
94            client_secret: String::new(),
95            redirect_uri: String::new(),
96            scopes: None,
97            params: Vec::new(),
98            http: Client::new(),
99        }
100    }
101
102    pub fn credentials(
103        mut self,
104        client_id: impl Into<String>,
105        client_secret: impl Into<String>,
106    ) -> OAuthClient {
107        self.client_id = client_id.into();
108        self.client_secret = client_secret.into();
109        self
110    }
111
112    /// The URL the provider redirects back to.
113    ///
114    /// It must match what is registered with the provider byte for byte,
115    /// including the scheme and any trailing slash — a mismatch is rejected at
116    /// the token endpoint, after the visitor has already consented, which is
117    /// why the error is so often reported as "it worked yesterday".
118    pub fn redirect_uri(mut self, uri: impl Into<String>) -> OAuthClient {
119        self.redirect_uri = uri.into();
120        self
121    }
122
123    /// Ask for these scopes instead of the provider's defaults.
124    pub fn scopes(mut self, scopes: impl Into<Scopes>) -> OAuthClient {
125        self.scopes = Some(scopes.into());
126        self
127    }
128
129    /// Add one scope to whatever is already being asked for.
130    pub fn scope(mut self, scope: impl Into<String>) -> OAuthClient {
131        let mut scopes = self.scopes.take().unwrap_or_else(|| self.provider.scopes.clone());
132        scopes.add(scope);
133        self.scopes = Some(scopes);
134        self
135    }
136
137    /// A provider-specific authorisation parameter: `prompt`, `access_type`,
138    /// `login_hint`, `hd`.
139    ///
140    /// Setting a name the provider preset already sets replaces it rather than
141    /// sending it twice — two `prompt` values in one URL is a request most
142    /// providers reject and none interpret the way the caller meant.
143    pub fn with(mut self, name: impl Into<String>, value: impl Into<String>) -> OAuthClient {
144        let name = name.into();
145        self.params.retain(|(existing, _)| existing != &name);
146        self.params.push((name, value.into()));
147        self
148    }
149
150    /// Use a particular HTTP client — a shared one, or `Http::fake()` in tests.
151    pub fn using(mut self, http: Client) -> OAuthClient {
152        self.http = http;
153        self
154    }
155
156    pub fn provider(&self) -> &Provider {
157        &self.provider
158    }
159
160    pub fn http(&self) -> &Client {
161        &self.http
162    }
163
164    pub fn client_id(&self) -> &str {
165        &self.client_id
166    }
167
168    /// What is actually being asked for: the application's scopes, or the
169    /// provider's defaults when it did not say.
170    pub fn requested_scopes(&self) -> Scopes {
171        self.scopes.clone().unwrap_or_else(|| self.provider.scopes.clone())
172    }
173
174    /// Leg one: the URL to send a visitor to, with a fresh state and verifier.
175    ///
176    /// The caller has to keep both — see [`OAuthClient::begin`], which keeps
177    /// them for you.
178    pub fn authorize_url(&self) -> Authorization {
179        let pkce = Pkce::generate();
180        let state = base64::encode_url(&rustlavel_auth::random::bytes(16));
181        let url = self.authorize_url_for(&state, &pkce);
182
183        Authorization { url, state, pkce }
184    }
185
186    /// Leg one, with the state issued and remembered by a guard.
187    pub fn begin(&self, guard: &StateGuard) -> Result<Authorization, OAuthError> {
188        let pkce = Pkce::generate();
189        let state = guard.issue(&pkce)?;
190        let url = self.authorize_url_for(&state, &pkce);
191
192        Ok(Authorization { url, state, pkce })
193    }
194
195    /// The authorisation URL for a state and verifier chosen elsewhere.
196    ///
197    /// PKCE is not a parameter here and there is no overload without it:
198    /// OAuth 2.1 requires it for every authorisation code flow, and a client
199    /// that could be talked into omitting it is a client that will be.
200    pub fn authorize_url_for(&self, state: &str, pkce: &Pkce) -> String {
201        let scopes = self.requested_scopes().to_string();
202        let challenge = pkce.challenge();
203        let extras = self.authorize_params();
204
205        let mut pairs: Vec<(&str, &str)> = vec![
206            ("response_type", "code"),
207            ("client_id", &self.client_id),
208            ("redirect_uri", &self.redirect_uri),
209        ];
210        if !scopes.is_empty() {
211            pairs.push(("scope", &scopes));
212        }
213        pairs.push(("state", state));
214        pairs.push(("code_challenge", &challenge));
215        pairs.push(("code_challenge_method", pkce.method().as_str()));
216        pairs.extend(extras.iter().map(|(name, value)| (name.as_str(), value.as_str())));
217
218        url::append_query(&self.provider.authorize_url, &url::form_encode(&pairs))
219    }
220
221    /// The provider's parameters, with the application's overriding by name.
222    fn authorize_params(&self) -> Vec<(String, String)> {
223        let mut params = self.provider.authorize_params.clone();
224        for (name, value) in &self.params {
225            params.retain(|(existing, _)| existing != name);
226            params.push((name.clone(), value.clone()));
227        }
228        params
229    }
230
231    /// Leg two: everything the callback has to do, in one call.
232    ///
233    /// `query` is the raw query string the provider redirected to — in a
234    /// handler, `req.target()`'s query part.
235    ///
236    /// The state is checked *before* the provider's own `error` is read. An
237    /// error response is still a response to a request somebody made, and
238    /// answering an unsolicited one — even to say "access denied" — is a page
239    /// an attacker can drive.
240    pub async fn callback(
241        &self,
242        query: &str,
243        guard: &StateGuard,
244    ) -> Result<TokenResponse, OAuthError> {
245        let fields = url::form_decode(query);
246        let field = |name: &str| {
247            fields.iter().find(|(key, _)| key == name).map(|(_, value)| value.as_str())
248        };
249
250        let pkce = guard.verify(field("state"))?;
251
252        if let Some(code) = field("error") {
253            return Err(OAuthError {
254                code: OAuthErrorCode::parse(code).unwrap_or(OAuthErrorCode::ServerError),
255                description: field("error_description")
256                    .map(str::to_string)
257                    .or_else(|| Some(format!("the provider said: {code}"))),
258                uri: field("error_uri").map(str::to_string),
259                state: None,
260            });
261        }
262
263        let code = field("code").filter(|code| !code.is_empty()).ok_or_else(|| {
264            OAuthError::invalid_request("the callback carried neither a `code` nor an `error`")
265        })?;
266
267        self.exchange(code, pkce.verifier()).await
268    }
269
270    /// Trade an authorisation code for a token.
271    pub async fn exchange(
272        &self,
273        code: &str,
274        verifier: &str,
275    ) -> Result<TokenResponse, OAuthError> {
276        self.token_request(&[
277            ("grant_type", "authorization_code"),
278            ("code", code),
279            // Sent again even though the provider already has it: RFC 6749
280            // §4.1.3 makes it part of the grant, and the provider compares it
281            // with what leg one asked for.
282            ("redirect_uri", &self.redirect_uri),
283            ("code_verifier", verifier),
284        ])
285        .await
286    }
287
288    /// Trade a refresh token for a new access token, RFC 6749 §6.
289    ///
290    /// The response may or may not carry a new refresh token. Providers
291    /// disagree, and one that rotates them means the old one stops working the
292    /// moment this returns — so store whatever comes back rather than assuming
293    /// the one you had is still good.
294    pub async fn refresh(&self, refresh_token: &str) -> Result<TokenResponse, OAuthError> {
295        self.token_request(&[("grant_type", "refresh_token"), ("refresh_token", refresh_token)])
296            .await
297    }
298
299    /// Revoke a token, RFC 7009.
300    ///
301    /// `hint` is `access_token` or `refresh_token`; it is advisory, and a
302    /// provider that ignores it still has to search both. Revoking a refresh
303    /// token usually revokes the access tokens issued from it — usually,
304    /// because RFC 7009 §2.1 only recommends it.
305    pub async fn revoke(&self, token: &str, hint: &str) -> Result<(), OAuthError> {
306        let Some(endpoint) = self.provider.revoke_url.clone() else {
307            return Err(OAuthError::server_error(format!(
308                "`{}` has no revocation endpoint. Set one with `Provider::revoke(url)` if it \
309                 gained one, or drop the token and let it expire.",
310                self.provider.name
311            )));
312        };
313
314        let mut form: Vec<(&str, &str)> = vec![("token", token)];
315        if !hint.is_empty() {
316            form.push(("token_type_hint", hint));
317        }
318
319        let response = self.post_form(&endpoint, form).await?;
320
321        // RFC 7009 §2.2: a token that was already invalid is a *success*. Only
322        // a real refusal — bad credentials, an unsupported token type — is an
323        // error, and those carry an RFC 6749 body.
324        if response.status.is_success() {
325            return Ok(());
326        }
327        Err(self.error_from(&response))
328    }
329
330    /// Fetch the provider's profile for a token and normalise it.
331    pub async fn user(&self, token: &TokenResponse) -> Result<SocialUser, OAuthError> {
332        let Some(endpoint) = self.provider.userinfo_url.clone() else {
333            return Err(OAuthError::server_error(format!(
334                "`{}` has no userinfo endpoint. Set one with `Provider::userinfo(url)`.",
335                self.provider.name
336            )));
337        };
338
339        let mut request =
340            self.http.get(&endpoint).header("authorization", token.authorization_header());
341        for (name, value) in &self.provider.headers {
342            request = request.header(name, value.clone());
343        }
344
345        let response = request.send().await.map_err(|error| {
346            OAuthError::server_error(format!("could not reach {endpoint}: {error}"))
347        })?;
348
349        let body = decode_body(&response);
350        if !response.status.is_success() {
351            return Err(self.error_from(&response));
352        }
353
354        SocialUser::from_provider(&self.provider, &body).ok_or_else(|| {
355            OAuthError::server_error(format!(
356                "`{}` returned a profile with no `{}` field to identify it by",
357                self.provider.name,
358                self.provider.map.id.join("` or `")
359            ))
360        })
361    }
362
363    /// A request to the token endpoint, with the client's credentials attached
364    /// the way this provider wants them.
365    async fn token_request(&self, form: &[(&str, &str)]) -> Result<TokenResponse, OAuthError> {
366        let url = self.provider.token_url.clone();
367        let response = self.post_form(&url, form.to_vec()).await?;
368        let body = decode_body(&response);
369
370        if body.is_null() {
371            let text = response.text();
372            let excerpt: String = text.chars().take(200).collect();
373            return Err(OAuthError::server_error(format!(
374                "the token endpoint at {url} answered HTTP {} with a body that is neither JSON \
375                 nor a form: {excerpt}",
376                response.status
377            )));
378        }
379
380        // Not gated on the status: GitHub reports `bad_verification_code` with
381        // a 200, and `TokenResponse::from_json` reads `error` regardless.
382        TokenResponse::from_json(&body)
383    }
384
385    async fn post_form(
386        &self,
387        url: &str,
388        mut form: Vec<(&str, &str)>,
389    ) -> Result<ClientResponse, OAuthError> {
390        if self.provider.client_auth == ClientAuth::Body {
391            form.push(("client_id", &self.client_id));
392            form.push(("client_secret", &self.client_secret));
393        }
394
395        let mut request = self
396            .http
397            .post(url)
398            .header("content-type", "application/x-www-form-urlencoded")
399            .body(url::form_encode(&form));
400
401        for (name, value) in &self.provider.headers {
402            request = request.header(name, value.clone());
403        }
404        if self.provider.client_auth == ClientAuth::Basic {
405            request = request.header("authorization", self.basic_credentials());
406        }
407
408        request
409            .send()
410            .await
411            .map_err(|error| OAuthError::server_error(format!("could not reach {url}: {error}")))
412    }
413
414    /// `Basic base64(percent(id) ":" percent(secret))`, RFC 6749 §2.3.1.
415    ///
416    /// The percent-encoding is not optional and is the usual reason a secret
417    /// "works in curl but not here": a secret containing `:` would otherwise
418    /// split into a different id and secret entirely.
419    fn basic_credentials(&self) -> String {
420        let joined =
421            format!("{}:{}", url::encode(&self.client_id), url::encode(&self.client_secret));
422        format!("Basic {}", base64::encode(joined.as_bytes()))
423    }
424
425    /// Read a provider's refusal, falling back to the status when the body says
426    /// nothing useful.
427    fn error_from(&self, response: &ClientResponse) -> OAuthError {
428        if let Some(error) = OAuthError::from_json(&decode_body(response)) {
429            return error;
430        }
431
432        let text = response.text();
433        let excerpt: String = text.chars().take(200).collect();
434        OAuthError::because(
435            match response.status.code() {
436                401 => OAuthErrorCode::InvalidClient,
437                403 => OAuthErrorCode::AccessDenied,
438                503 => OAuthErrorCode::TemporarilyUnavailable,
439                _ => OAuthErrorCode::ServerError,
440            },
441            format!("`{}` answered HTTP {}: {excerpt}", self.provider.name, response.status),
442        )
443    }
444}
445
446impl std::fmt::Debug for OAuthClient {
447    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448        f.debug_struct("OAuthClient")
449            .field("provider", &self.provider.name)
450            .field("client_id", &self.client_id)
451            .field("client_secret", &"<redacted>")
452            .field("redirect_uri", &self.redirect_uri)
453            .field("scopes", &self.requested_scopes())
454            .finish()
455    }
456}
457
458/// A provider's response body as JSON, whatever it actually sent.
459///
460/// GitHub answers its token endpoint in `application/x-www-form-urlencoded`
461/// unless asked for JSON, so a client that only parses JSON works right up
462/// until somebody drops the `Accept` header. Form pairs are lifted into an
463/// object of strings, which the rest of the crate reads identically —
464/// `TokenResponse` already accepts a quoted `expires_in`.
465///
466/// `Json::Null` means neither shape parsed; the caller turns that into an error
467/// that quotes the body, because a provider returning an HTML error page is a
468/// real failure mode and "expected value at line 1" does not describe it.
469fn decode_body(response: &ClientResponse) -> Json {
470    let text = response.text();
471    let trimmed = text.trim();
472    if trimmed.is_empty() {
473        return Json::Null;
474    }
475
476    let content_type = response.headers.content_type().unwrap_or("");
477    if content_type.contains("json") || trimmed.starts_with('{') {
478        return Json::parse(trimmed).unwrap_or(Json::Null);
479    }
480
481    if content_type.contains("form-urlencoded") || (trimmed.contains('=') && !trimmed.contains(' '))
482    {
483        let pairs = url::form_decode(trimmed);
484        if !pairs.is_empty() {
485            return Json::object(pairs.into_iter().map(|(k, v)| (k, Json::String(v))));
486        }
487    }
488
489    Json::Null
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::pkce;
496    use rustlavel_client::fake::{Fake, FakeResponse};
497    use rustlavel_http::Headers;
498
499    fn client(provider: Provider, fake: Fake) -> OAuthClient {
500        OAuthClient::new(provider)
501            .credentials("client-123", "s3cr+t/val=ue")
502            .redirect_uri("https://app.test/auth/callback")
503            .using(Client::new().faking(fake))
504    }
505
506    /// The query string of an authorisation URL, as pairs.
507    fn query_of(url: &str) -> Vec<(String, String)> {
508        url::form_decode(url.split_once('?').expect("a query string").1)
509    }
510
511    fn value<'a>(pairs: &'a [(String, String)], name: &str) -> Option<&'a str> {
512        pairs.iter().find(|(key, _)| key == name).map(|(_, v)| v.as_str())
513    }
514
515    #[test]
516    fn the_authorisation_url_carries_exactly_the_parameters_the_rfc_asks_for() {
517        let client = OAuthClient::new(Provider::google())
518            .credentials("client-123", "secret")
519            .redirect_uri("https://app.test/auth/google/callback");
520        let start = client.authorize_url();
521
522        let pairs = query_of(start.url());
523        let names: Vec<&str> = pairs.iter().map(|(name, _)| name.as_str()).collect();
524        assert_eq!(
525            names,
526            vec![
527                "response_type",
528                "client_id",
529                "redirect_uri",
530                "scope",
531                "state",
532                "code_challenge",
533                "code_challenge_method",
534            ]
535        );
536
537        assert!(start.url().starts_with("https://accounts.google.com/o/oauth2/v2/auth?"));
538        assert_eq!(value(&pairs, "response_type"), Some("code"));
539        assert_eq!(value(&pairs, "client_id"), Some("client-123"));
540        assert_eq!(value(&pairs, "redirect_uri"), Some("https://app.test/auth/google/callback"));
541        assert_eq!(value(&pairs, "scope"), Some("email openid profile"));
542        assert_eq!(value(&pairs, "state"), Some(start.state()));
543    }
544
545    #[test]
546    fn the_redirect_uri_is_escaped_rather_than_pasted_into_the_url() {
547        // Unescaped, the `?` and `&` of a redirect URI with its own query would
548        // become parameters of the authorisation request.
549        let client = OAuthClient::new(Provider::google())
550            .credentials("id", "secret")
551            .redirect_uri("https://app.test/cb?tenant=acme&next=/x");
552
553        let url = client.authorize_url().into_url();
554        assert!(url.contains("redirect_uri=https%3A%2F%2Fapp.test%2Fcb%3Ftenant%3Dacme%26next%3D%2Fx"));
555        assert_eq!(url.matches("tenant").count(), 1);
556    }
557
558    #[test]
559    fn the_authorisation_url_commits_to_a_pkce_challenge_and_never_the_verifier() {
560        let start = OAuthClient::new(Provider::github())
561            .credentials("id", "secret")
562            .redirect_uri("https://app.test/cb")
563            .authorize_url();
564
565        let pairs = query_of(start.url());
566        assert_eq!(value(&pairs, "code_challenge_method"), Some("S256"));
567        assert_eq!(value(&pairs, "code_challenge"), Some(pkce::s256(start.verifier()).as_str()));
568        assert!(
569            !start.url().contains(start.verifier()),
570            "the verifier reached the URL, which defeats PKCE entirely"
571        );
572    }
573
574    #[test]
575    fn extra_parameters_are_appended_and_replace_rather_than_repeat() {
576        let url = OAuthClient::new(Provider::google())
577            .credentials("id", "secret")
578            .redirect_uri("https://app.test/cb")
579            .with("access_type", "offline")
580            .with("prompt", "select_account")
581            .with("prompt", "consent")
582            .authorize_url()
583            .into_url();
584
585        let pairs = query_of(&url);
586        assert_eq!(value(&pairs, "access_type"), Some("offline"));
587        assert_eq!(value(&pairs, "prompt"), Some("consent"));
588        assert_eq!(pairs.iter().filter(|(name, _)| name == "prompt").count(), 1);
589    }
590
591    #[test]
592    fn scopes_replace_or_extend_the_providers_defaults() {
593        let base = OAuthClient::new(Provider::github()).credentials("id", "secret");
594
595        assert_eq!(base.clone().requested_scopes().to_string(), "read:user user:email");
596        assert_eq!(base.clone().scopes("repo").requested_scopes().to_string(), "repo");
597        assert_eq!(
598            base.scope("gist").requested_scopes().to_string(),
599            "gist read:user user:email"
600        );
601    }
602
603    #[test]
604    fn two_authorisations_never_share_a_state_or_a_verifier() {
605        let client = OAuthClient::new(Provider::google()).credentials("id", "secret");
606        let (first, second) = (client.authorize_url(), client.authorize_url());
607
608        assert_ne!(first.state(), second.state());
609        assert_ne!(first.verifier(), second.verifier());
610    }
611
612    #[tokio::test]
613    async fn a_successful_exchange_sends_the_grant_and_returns_the_token() {
614        let client = client(
615            Provider::google(),
616            Fake::new().on(
617                "oauth2.googleapis.com/token",
618                FakeResponse::json(
619                    Json::parse(
620                        r#"{"access_token":"at-1","token_type":"Bearer","expires_in":3599,
621                            "refresh_token":"rt-1","scope":"openid email"}"#,
622                    )
623                    .unwrap(),
624                ),
625            ),
626        );
627
628        let token = client.exchange("the-code", &"v".repeat(43)).await.unwrap();
629        assert_eq!(token.access_token, "at-1");
630        assert_eq!(token.expires_in, Some(3599));
631        assert_eq!(token.refresh_token.as_deref(), Some("rt-1"));
632
633        let sent = &client.http().fake().unwrap().recorded()[0];
634        let form = url::form_decode(&sent.body_text());
635        assert_eq!(value(&form, "grant_type"), Some("authorization_code"));
636        assert_eq!(value(&form, "code"), Some("the-code"));
637        assert_eq!(value(&form, "code_verifier"), Some("v".repeat(43).as_str()));
638        assert_eq!(value(&form, "redirect_uri"), Some("https://app.test/auth/callback"));
639        assert_eq!(sent.headers.get("content-type"), Some("application/x-www-form-urlencoded"));
640    }
641
642    #[tokio::test]
643    async fn basic_credentials_are_percent_encoded_before_they_are_joined() {
644        // The secret in the fixture contains `+`, `/` and `=`. Without the
645        // encoding RFC 6749 §2.3.1 requires, a secret containing `:` would
646        // split into a different id and secret and the provider would answer
647        // `invalid_client`.
648        let client = client(
649            Provider::google(),
650            Fake::new().fallback(FakeResponse::json(
651                Json::parse(r#"{"access_token":"at"}"#).unwrap(),
652            )),
653        );
654        client.exchange("code", &"v".repeat(43)).await.unwrap();
655
656        let sent = &client.http().fake().unwrap().recorded()[0];
657        let header = sent.headers.get("authorization").unwrap();
658        let decoded = String::from_utf8(
659            base64::decode(header.strip_prefix("Basic ").unwrap()).unwrap(),
660        )
661        .unwrap();
662
663        assert_eq!(decoded, "client-123:s3cr%2Bt%2Fval%3Due");
664        let form = url::form_decode(&sent.body_text());
665        assert_eq!(value(&form, "client_secret"), None, "Basic means not in the body too");
666    }
667
668    #[tokio::test]
669    async fn a_body_authenticating_provider_puts_the_credentials_in_the_form() {
670        let client = client(
671            Provider::github(),
672            Fake::new().fallback(FakeResponse::json(
673                Json::parse(r#"{"access_token":"at"}"#).unwrap(),
674            )),
675        );
676        client.exchange("code", &"v".repeat(43)).await.unwrap();
677
678        let sent = &client.http().fake().unwrap().recorded()[0];
679        let form = url::form_decode(&sent.body_text());
680
681        assert_eq!(value(&form, "client_id"), Some("client-123"));
682        assert_eq!(value(&form, "client_secret"), Some("s3cr+t/val=ue"));
683        assert_eq!(sent.headers.get("authorization"), None);
684    }
685
686    #[tokio::test]
687    async fn github_is_asked_for_json_and_is_understood_anyway_when_it_sends_a_form() {
688        // Both halves of GitHub's quirk. The preset sends the header; the
689        // parser does not depend on it, because a provider that drops it must
690        // not break the grant.
691        let asked = client(
692            Provider::github(),
693            Fake::new().fallback(FakeResponse::json(
694                Json::parse(r#"{"access_token":"at-json"}"#).unwrap(),
695            )),
696        );
697        assert_eq!(asked.exchange("c", &"v".repeat(43)).await.unwrap().access_token, "at-json");
698        assert_eq!(
699            asked.http().fake().unwrap().recorded()[0].headers.get("accept"),
700            Some("application/json")
701        );
702
703        let mut headers = Headers::new();
704        headers.set("content-type", "application/x-www-form-urlencoded; charset=utf-8");
705        let form_response = FakeResponse {
706            status: rustlavel_http::Status::OK,
707            headers,
708            body: b"access_token=at-form&scope=read%3Auser&token_type=bearer".to_vec(),
709        };
710
711        let unasked = client(
712            Provider::github().without_headers(),
713            Fake::new().fallback(form_response),
714        );
715        let token = unasked.exchange("c", &"v".repeat(43)).await.unwrap();
716
717        assert_eq!(token.access_token, "at-form");
718        assert_eq!(token.token_type, "bearer");
719        assert_eq!(token.scope.unwrap().to_string(), "read:user");
720    }
721
722    #[tokio::test]
723    async fn an_error_reported_with_a_200_is_still_an_error() {
724        // GitHub's actual behaviour. Trusting the status would hand the caller
725        // a token response with no token in it.
726        let client = client(
727            Provider::github(),
728            Fake::new().fallback(FakeResponse::json(
729                Json::parse(
730                    r#"{"error":"bad_verification_code",
731                        "error_description":"The code passed is incorrect or expired."}"#,
732                )
733                .unwrap(),
734            )),
735        );
736
737        let error = client.exchange("stale", &"v".repeat(43)).await.unwrap_err();
738        assert_eq!(error.code, OAuthErrorCode::ServerError, "an unrecognised code, kept visible");
739        assert!(error.to_string().contains("incorrect or expired"), "{error}");
740    }
741
742    #[tokio::test]
743    async fn a_400_with_an_rfc_error_body_keeps_its_code() {
744        let client = client(
745            Provider::google(),
746            Fake::new().fallback(
747                FakeResponse::json(
748                    Json::parse(
749                        r#"{"error":"invalid_grant","error_description":"Code was already redeemed."}"#,
750                    )
751                    .unwrap(),
752                )
753                .status(400),
754            ),
755        );
756
757        let error = client.exchange("used", &"v".repeat(43)).await.unwrap_err();
758        assert_eq!(error.code, OAuthErrorCode::InvalidGrant);
759        assert!(error.to_string().contains("already redeemed"));
760    }
761
762    #[tokio::test]
763    async fn a_redirect_uri_mismatch_is_reported_with_the_providers_own_words() {
764        // The single most common setup failure, and the reason the message is
765        // passed through rather than replaced: `redirect_uri_mismatch` is not
766        // an RFC 6749 code, so without this the developer sees `server_error`
767        // and nothing to search for.
768        let client = client(
769            Provider::google(),
770            Fake::new().fallback(
771                FakeResponse::json(
772                    Json::parse(r#"{"error":"redirect_uri_mismatch"}"#).unwrap(),
773                )
774                .status(400),
775            ),
776        );
777
778        let error = client.exchange("code", &"v".repeat(43)).await.unwrap_err();
779        assert!(error.to_string().contains("redirect_uri_mismatch"), "{error}");
780    }
781
782    #[tokio::test]
783    async fn a_token_with_no_stated_expiry_is_accepted_and_never_looks_expired() {
784        // GitHub's OAuth apps issue tokens that do not expire; treating a
785        // missing `expires_in` as zero would refresh on every request.
786        let client = client(
787            Provider::github(),
788            Fake::new().fallback(FakeResponse::json(
789                Json::parse(r#"{"access_token":"at","token_type":"bearer","scope":""}"#).unwrap(),
790            )),
791        );
792
793        let token = client.exchange("c", &"v".repeat(43)).await.unwrap();
794        assert_eq!(token.expires_in, None);
795        assert!(!token.expires_within(86_400, 60));
796    }
797
798    #[tokio::test]
799    async fn an_html_error_page_is_quoted_rather_than_reported_as_a_parse_error() {
800        let client = client(
801            Provider::google(),
802            Fake::new().fallback(FakeResponse::text("<html><body>502 Bad Gateway</body>").status(502)),
803        );
804
805        let error = client.exchange("c", &"v".repeat(43)).await.unwrap_err().to_string();
806        assert!(error.contains("502"), "{error}");
807        assert!(error.contains("Bad Gateway"), "{error}");
808    }
809
810    #[tokio::test]
811    async fn refreshing_sends_the_refresh_grant_and_keeps_the_new_refresh_token() {
812        let client = client(
813            Provider::google(),
814            Fake::new().fallback(FakeResponse::json(
815                Json::parse(
816                    r#"{"access_token":"at-2","expires_in":3599,"refresh_token":"rt-2"}"#,
817                )
818                .unwrap(),
819            )),
820        );
821
822        let token = client.refresh("rt-1").await.unwrap();
823        assert_eq!(token.access_token, "at-2");
824        assert_eq!(token.refresh_token.as_deref(), Some("rt-2"), "rotated tokens must be kept");
825
826        let form = url::form_decode(&client.http().fake().unwrap().recorded()[0].body_text());
827        assert_eq!(value(&form, "grant_type"), Some("refresh_token"));
828        assert_eq!(value(&form, "refresh_token"), Some("rt-1"));
829        assert_eq!(value(&form, "code"), None);
830    }
831
832    #[tokio::test]
833    async fn a_refused_refresh_says_the_grant_is_invalid_rather_than_returning_nothing() {
834        let client = client(
835            Provider::google(),
836            Fake::new().fallback(
837                FakeResponse::json(Json::parse(r#"{"error":"invalid_grant"}"#).unwrap())
838                    .status(400),
839            ),
840        );
841
842        assert_eq!(client.refresh("revoked").await.unwrap_err().code, OAuthErrorCode::InvalidGrant);
843    }
844
845    #[tokio::test]
846    async fn revoking_posts_the_token_to_the_revocation_endpoint() {
847        let client = client(
848            Provider::google(),
849            Fake::new().on("oauth2.googleapis.com/revoke", FakeResponse::text("")),
850        );
851
852        client.revoke("rt-1", "refresh_token").await.unwrap();
853
854        let sent = &client.http().fake().unwrap().recorded()[0];
855        let form = url::form_decode(&sent.body_text());
856        assert_eq!(value(&form, "token"), Some("rt-1"));
857        assert_eq!(value(&form, "token_type_hint"), Some("refresh_token"));
858    }
859
860    #[tokio::test]
861    async fn revoking_a_token_that_was_already_dead_is_a_success() {
862        // RFC 7009 §2.2 is explicit about this, and a caller that treated it as
863        // a failure would retry a logout forever.
864        let client =
865            client(Provider::google(), Fake::new().fallback(FakeResponse::text("").status(200)));
866
867        assert!(client.revoke("already-gone", "access_token").await.is_ok());
868    }
869
870    #[tokio::test]
871    async fn a_refused_revocation_is_reported() {
872        let client = client(
873            Provider::google(),
874            Fake::new().fallback(
875                FakeResponse::json(Json::parse(r#"{"error":"invalid_client"}"#).unwrap())
876                    .status(401),
877            ),
878        );
879
880        assert_eq!(
881            client.revoke("t", "access_token").await.unwrap_err().code,
882            OAuthErrorCode::InvalidClient
883        );
884    }
885
886    #[tokio::test]
887    async fn revoking_at_a_provider_with_no_endpoint_says_so_instead_of_pretending() {
888        let client = client(Provider::github(), Fake::new());
889
890        let error = client.revoke("t", "access_token").await.unwrap_err().to_string();
891        assert!(error.contains("no revocation endpoint"), "{error}");
892        client.http().fake().unwrap().assert_count(0);
893    }
894
895    #[tokio::test]
896    async fn the_userinfo_call_is_bearer_authenticated_and_mapped() {
897        let client = client(
898            Provider::github(),
899            Fake::new().on(
900                "api.github.com/user",
901                FakeResponse::json(
902                    Json::parse(
903                        r#"{"id":1234,"login":"ada","name":null,
904                            "avatar_url":"https://gh.test/a.png","email":null}"#,
905                    )
906                    .unwrap(),
907                ),
908            ),
909        );
910
911        let user = client.user(&TokenResponse::bearer("at-1")).await.unwrap();
912        assert_eq!(user.qualified_id(), "github:1234");
913        assert_eq!(user.name.as_deref(), Some("ada"));
914        assert_eq!(user.email, None, "GitHub hides a private address");
915
916        let sent = &client.http().fake().unwrap().recorded()[0];
917        assert_eq!(sent.headers.get("authorization"), Some("Bearer at-1"));
918        assert_eq!(sent.headers.get("accept"), Some("application/json"));
919    }
920
921    #[tokio::test]
922    async fn every_preset_maps_its_own_profile_shape() {
923        struct Case {
924            provider: Provider,
925            endpoint: &'static str,
926            body: &'static str,
927            id: &'static str,
928            name: &'static str,
929            email: Option<&'static str>,
930            avatar: Option<&'static str>,
931        }
932
933        let cases = vec![
934            Case {
935                provider: Provider::google(),
936                endpoint: "openidconnect.googleapis.com/v1/userinfo",
937                body: r#"{"sub":"107","name":"Ada","email":"ada@x.test",
938                         "picture":"https://g.test/a.png"}"#,
939                id: "google:107",
940                name: "Ada",
941                email: Some("ada@x.test"),
942                avatar: Some("https://g.test/a.png"),
943            },
944            Case {
945                provider: Provider::gitlab(),
946                endpoint: "gitlab.com/api/v4/user",
947                body: r#"{"id":42,"username":"ada","name":"Ada","email":"ada@x.test",
948                         "avatar_url":"https://gl.test/a.png"}"#,
949                id: "gitlab:42",
950                name: "Ada",
951                email: Some("ada@x.test"),
952                avatar: Some("https://gl.test/a.png"),
953            },
954            Case {
955                provider: Provider::microsoft(),
956                endpoint: "graph.microsoft.com/v1.0/me",
957                body: r#"{"id":"aad-9","displayName":"Ada","mail":null,
958                         "userPrincipalName":"ada@corp.test"}"#,
959                id: "microsoft:aad-9",
960                name: "Ada",
961                email: Some("ada@corp.test"),
962                avatar: None,
963            },
964            Case {
965                provider: Provider::discord(),
966                endpoint: "discord.com/api/users/@me",
967                body: r#"{"id":"803","username":"nelly","global_name":"Nelly",
968                         "avatar":"834","email":"n@x.test"}"#,
969                id: "discord:803",
970                name: "Nelly",
971                email: Some("n@x.test"),
972                avatar: Some("https://cdn.discordapp.com/avatars/803/834.png"),
973            },
974        ];
975
976        for case in cases {
977            let label = case.provider.name.clone();
978            let client = client(
979                case.provider,
980                Fake::new()
981                    .on(case.endpoint, FakeResponse::json(Json::parse(case.body).unwrap())),
982            );
983
984            let user = client.user(&TokenResponse::bearer("at")).await.unwrap();
985            assert_eq!(user.qualified_id(), case.id, "{label}");
986            assert_eq!(user.name.as_deref(), Some(case.name), "{label}");
987            assert_eq!(user.email.as_deref(), case.email, "{label}");
988            assert_eq!(user.avatar.as_deref(), case.avatar, "{label}");
989        }
990    }
991
992    #[tokio::test]
993    async fn a_userinfo_call_with_a_dead_token_is_reported_as_such() {
994        let client = client(
995            Provider::google(),
996            Fake::new().fallback(
997                FakeResponse::json(Json::parse(r#"{"error":"invalid_token"}"#).unwrap())
998                    .status(401),
999            ),
1000        );
1001
1002        assert_eq!(
1003            client.user(&TokenResponse::bearer("stale")).await.unwrap_err().code,
1004            OAuthErrorCode::InvalidToken
1005        );
1006    }
1007
1008    #[tokio::test]
1009    async fn a_profile_with_nothing_to_identify_it_by_is_an_error_not_an_empty_user() {
1010        let client = client(
1011            Provider::google(),
1012            Fake::new().fallback(FakeResponse::json(Json::parse(r#"{"name":"Ada"}"#).unwrap())),
1013        );
1014
1015        let error = client.user(&TokenResponse::bearer("at")).await.unwrap_err().to_string();
1016        assert!(error.contains("sub"), "the message should name the field it looked for: {error}");
1017    }
1018
1019    /// A client whose token endpoint always succeeds, and a session guard.
1020    fn round_trip() -> (OAuthClient, StateGuard) {
1021        let client = client(
1022            Provider::google(),
1023            Fake::new().fallback(FakeResponse::json(
1024                Json::parse(r#"{"access_token":"at-1","expires_in":3599}"#).unwrap(),
1025            )),
1026        );
1027        let guard = StateGuard::session(&rustlavel_auth::SessionHandle::new(
1028            rustlavel_auth::Session::new(),
1029        ));
1030
1031        (client, guard)
1032    }
1033
1034    #[tokio::test]
1035    async fn a_whole_flow_goes_out_with_a_state_and_comes_back_through_it() {
1036        let (client, guard) = round_trip();
1037        let start = client.begin(&guard).unwrap();
1038
1039        let query = format!("code=the-code&state={}", url::encode(start.state()));
1040        let token = client.callback(&query, &guard).await.unwrap();
1041
1042        assert_eq!(token.access_token, "at-1");
1043        // The verifier the callback used is the one leg one committed to.
1044        let form = url::form_decode(&client.http().fake().unwrap().recorded()[0].body_text());
1045        assert_eq!(value(&form, "code_verifier"), Some(start.verifier()));
1046    }
1047
1048    #[tokio::test]
1049    async fn a_callback_that_accepts_any_state_is_login_csrf_so_this_one_does_not() {
1050        // The attack: the attacker completes leg one against their own account,
1051        // holds the resulting code, and gets the victim's browser to load the
1052        // callback with it. If the code alone were enough, the victim is now
1053        // signed in as the attacker and everything they do next lands in the
1054        // attacker's account.
1055        let (client, guard) = round_trip();
1056        client.begin(&guard).unwrap();
1057
1058        for query in [
1059            "code=attackers-code",
1060            "code=attackers-code&state=",
1061            "code=attackers-code&state=guessed",
1062        ] {
1063            let error = client.callback(query, &guard).await.unwrap_err();
1064            assert_eq!(error.code, OAuthErrorCode::InvalidRequest, "{query}");
1065            client.http().fake().unwrap().assert_count(0);
1066        }
1067    }
1068
1069    #[tokio::test]
1070    async fn a_replayed_callback_is_refused_before_the_code_is_exchanged() {
1071        let (client, guard) = round_trip();
1072        let start = client.begin(&guard).unwrap();
1073        let query = format!("code=the-code&state={}", url::encode(start.state()));
1074
1075        assert!(client.callback(&query, &guard).await.is_ok());
1076        assert!(client.callback(&query, &guard).await.is_err());
1077        client.http().fake().unwrap().assert_count(1);
1078    }
1079
1080    #[tokio::test]
1081    async fn a_callback_from_another_visitors_flow_is_refused() {
1082        let (client, mine) = round_trip();
1083        let (_, theirs) = round_trip();
1084
1085        let start = client.begin(&theirs).unwrap();
1086        let query = format!("code=c&state={}", url::encode(start.state()));
1087
1088        assert!(client.callback(&query, &mine).await.is_err());
1089    }
1090
1091    #[tokio::test]
1092    async fn a_visitor_who_declined_gets_the_providers_reason_and_no_exchange() {
1093        let (client, guard) = round_trip();
1094        let start = client.begin(&guard).unwrap();
1095
1096        let query = format!(
1097            "error=access_denied&error_description=The%20user%20denied&state={}",
1098            url::encode(start.state())
1099        );
1100        let error = client.callback(&query, &guard).await.unwrap_err();
1101
1102        assert_eq!(error.code, OAuthErrorCode::AccessDenied);
1103        assert!(error.to_string().contains("denied"));
1104        client.http().fake().unwrap().assert_count(0);
1105    }
1106
1107    #[tokio::test]
1108    async fn an_unsolicited_error_is_refused_by_the_state_check_first() {
1109        // Checking `error` before `state` would leave a page an attacker can
1110        // drive with a crafted link, which is a nuisance at best and a
1111        // phishing surface at worst.
1112        let (client, guard) = round_trip();
1113
1114        let error = client.callback("error=access_denied", &guard).await.unwrap_err();
1115        assert_eq!(error.code, OAuthErrorCode::InvalidRequest, "the state, not the denial");
1116    }
1117
1118    #[tokio::test]
1119    async fn a_callback_with_a_good_state_and_no_code_says_what_is_missing() {
1120        let (client, guard) = round_trip();
1121        let start = client.begin(&guard).unwrap();
1122
1123        let query = format!("state={}", url::encode(start.state()));
1124        let error = client.callback(&query, &guard).await.unwrap_err();
1125
1126        assert!(error.to_string().contains("neither a `code` nor an `error`"), "{error}");
1127    }
1128
1129    #[tokio::test]
1130    async fn the_stateless_mode_completes_a_flow_with_nothing_stored() {
1131        let key = rustlavel_auth::AppKey::from_base64(&rustlavel_auth::AppKey::generate()).unwrap();
1132        let guard = StateGuard::sealed(&key);
1133        let (client, _) = round_trip();
1134
1135        let start = client.begin(&guard).unwrap();
1136        let query = format!("code=c&state={}", url::encode(start.state()));
1137
1138        assert!(client.callback(&query, &guard).await.is_ok());
1139        // A fresh guard on another machine, same application key: the state
1140        // still verifies, which is the whole point of the mode.
1141        assert!(client.callback(&query, &StateGuard::sealed(&key)).await.is_ok());
1142    }
1143
1144    #[test]
1145    fn debug_prints_the_client_id_and_never_the_secret() {
1146        let client = OAuthClient::new(Provider::google()).credentials("public-id", "TOP-SECRET");
1147        let printed = format!("{client:?}");
1148
1149        assert!(printed.contains("public-id"), "the id is not a secret and helps debugging");
1150        assert!(!printed.contains("TOP-SECRET"));
1151    }
1152
1153    #[test]
1154    fn debug_of_an_authorisation_never_prints_the_verifier() {
1155        let start = OAuthClient::new(Provider::google()).credentials("id", "s").authorize_url();
1156        let printed = format!("{start:?}");
1157
1158        assert!(!printed.contains(start.verifier()));
1159        assert!(printed.contains(start.state()), "the state is public and identifies the flow");
1160    }
1161}