Skip to main content

modo/auth/oauth/
google.rs

1use axum_extra::extract::cookie::Key;
2
3use crate::cookie::CookieConfig;
4
5use super::{
6    client,
7    config::{CallbackParams, OAuthProviderConfig},
8    profile::UserProfile,
9    provider::OAuthProvider,
10    state::{AuthorizationRequest, OAuthState, build_oauth_cookie, pkce_challenge},
11};
12
13const AUTHORIZE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth";
14const TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
15const USERINFO_URL: &str = "https://www.googleapis.com/oauth2/v2/userinfo";
16const DEFAULT_SCOPES: &[&str] = &["openid", "email", "profile"];
17
18/// OAuth 2.0 provider implementation for Google.
19///
20/// Implements the Authorization Code flow with PKCE (S256). Default scopes are
21/// `openid`, `email`, and `profile`. Override them via
22/// [`OAuthProviderConfig::scopes`](super::OAuthProviderConfig::scopes).
23pub struct Google {
24    config: OAuthProviderConfig,
25    cookie_config: CookieConfig,
26    key: Key,
27    http_client: reqwest::Client,
28}
29
30impl Google {
31    /// Creates a new `Google` provider from the given configuration.
32    ///
33    /// `cookie_config` and `key` are used to sign the `_oauth_state` cookie that carries the
34    /// PKCE verifier and state nonce across the redirect. `http_client` is a
35    /// [`reqwest::Client`] used for the token exchange and user-info API calls.
36    pub fn new(
37        config: &OAuthProviderConfig,
38        cookie_config: &CookieConfig,
39        key: &Key,
40        http_client: reqwest::Client,
41    ) -> Self {
42        Self {
43            config: config.clone(),
44            cookie_config: cookie_config.clone(),
45            key: key.clone(),
46            http_client,
47        }
48    }
49
50    fn scopes(&self) -> String {
51        if self.config.scopes.is_empty() {
52            DEFAULT_SCOPES.join(" ")
53        } else {
54            self.config.scopes.join(" ")
55        }
56    }
57}
58
59impl OAuthProvider for Google {
60    fn name(&self) -> &str {
61        "google"
62    }
63
64    fn authorize_url(&self) -> crate::Result<AuthorizationRequest> {
65        let (set_cookie_header, state_nonce, pkce_verifier) =
66            build_oauth_cookie("google", &self.key, &self.cookie_config);
67
68        let challenge = pkce_challenge(&pkce_verifier);
69
70        let redirect_url = format!(
71            "{AUTHORIZE_URL}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256",
72            urlencoding::encode(&self.config.client_id),
73            urlencoding::encode(&self.config.redirect_uri),
74            urlencoding::encode(&self.scopes()),
75            urlencoding::encode(&state_nonce),
76            urlencoding::encode(&challenge),
77        );
78
79        Ok(AuthorizationRequest {
80            redirect_url,
81            set_cookie_header,
82        })
83    }
84
85    async fn exchange(
86        &self,
87        params: &CallbackParams,
88        state: &OAuthState,
89    ) -> crate::Result<UserProfile> {
90        if state.provider() != "google" {
91            return Err(crate::Error::bad_request("OAuth state provider mismatch"));
92        }
93
94        if params.state != state.state_nonce() {
95            return Err(crate::Error::bad_request("OAuth state nonce mismatch"));
96        }
97
98        #[derive(serde::Deserialize)]
99        struct TokenResponse {
100            access_token: String,
101        }
102
103        let token: TokenResponse = client::post_form(
104            &self.http_client,
105            TOKEN_URL,
106            &[
107                ("grant_type", "authorization_code"),
108                ("code", &params.code),
109                ("redirect_uri", &self.config.redirect_uri),
110                ("client_id", &self.config.client_id),
111                ("client_secret", &self.config.client_secret),
112                ("code_verifier", state.pkce_verifier()),
113            ],
114        )
115        .await?;
116
117        let raw: serde_json::Value =
118            client::get_json(&self.http_client, USERINFO_URL, &token.access_token).await?;
119
120        let provider_user_id = raw["id"]
121            .as_str()
122            .ok_or_else(|| crate::Error::internal("google: missing user id"))?
123            .to_string();
124        let email = raw["email"]
125            .as_str()
126            .ok_or_else(|| crate::Error::internal("google: missing email"))?
127            .to_string();
128        let email_verified = raw["verified_email"].as_bool().unwrap_or(false);
129        let name = raw["name"].as_str().map(|s| s.to_string());
130        let avatar_url = raw["picture"].as_str().map(|s| s.to_string());
131
132        Ok(UserProfile {
133            provider: "google".to_string(),
134            provider_user_id,
135            email,
136            email_verified,
137            name,
138            avatar_url,
139            raw,
140        })
141    }
142}
143
144mod urlencoding {
145    pub fn encode(s: &str) -> String {
146        let mut result = String::with_capacity(s.len());
147        for b in s.bytes() {
148            match b {
149                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
150                    result.push(b as char);
151                }
152                _ => {
153                    result.push_str(&format!("%{b:02X}"));
154                }
155            }
156        }
157        result
158    }
159}