scufflecloud_core/
google_api.rs

1use std::sync::Arc;
2
3use base64::Engine;
4
5pub(crate) const ADMIN_DIRECTORY_API_USER_SCOPE: &str = "https://www.googleapis.com/auth/admin.directory.user.readonly";
6const ALL_SCOPES: [&str; 4] = ["openid", "profile", "email", ADMIN_DIRECTORY_API_USER_SCOPE];
7const REQUIRED_SCOPES: [&str; 3] = ["openid", "profile", "email"];
8
9#[derive(serde_derive::Deserialize, Debug)]
10pub(crate) struct GoogleToken {
11    pub access_token: String,
12    pub expires_in: u64,
13    #[serde(deserialize_with = "deserialize_google_id_token")]
14    pub id_token: GoogleIdToken,
15    pub scope: String,
16    pub token_type: String,
17}
18
19/// https://developers.google.com/identity/openid-connect/openid-connect#obtainuserinfo
20#[derive(serde_derive::Deserialize, Debug, Clone)]
21pub(crate) struct GoogleIdToken {
22    pub sub: String,
23    pub email: String,
24    pub email_verified: bool,
25    pub family_name: Option<String>,
26    pub given_name: Option<String>,
27    pub hd: Option<String>,
28    pub name: Option<String>,
29    pub picture: Option<String>,
30}
31
32fn deserialize_google_id_token<'de, D>(deserialzer: D) -> Result<GoogleIdToken, D::Error>
33where
34    D: serde::Deserializer<'de>,
35{
36    let token: String = serde::Deserialize::deserialize(deserialzer)?;
37    let parts: Vec<&str> = token.split('.').collect();
38    if parts.len() != 3 {
39        return Err(serde::de::Error::custom("Invalid ID token format"));
40    }
41
42    let payload = base64::prelude::BASE64_URL_SAFE_NO_PAD
43        .decode(parts[1])
44        .map_err(serde::de::Error::custom)?;
45
46    serde_json::from_slice(&payload).map_err(serde::de::Error::custom)
47}
48
49#[derive(thiserror::Error, Debug)]
50pub(crate) enum GoogleTokenError {
51    #[error("invalid token type: {0}")]
52    InvalidTokenType(String),
53    #[error("missing scope: {0}")]
54    MissingScope(String),
55    #[error("HTTP request failed: {0}")]
56    RequestFailed(#[from] reqwest::Error),
57}
58
59fn redirect_uri(dashboard_origin: &url::Url) -> String {
60    dashboard_origin.join("/oauth2-callback/google").unwrap().to_string()
61}
62
63pub(crate) fn authorization_url<G: core_traits::Global>(
64    global: &Arc<G>,
65    dashboard_origin: &url::Url,
66    state: &str,
67) -> String {
68    format!(
69        "https://accounts.google.com/o/oauth2/v2/auth?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}",
70        global.google_oauth2_config().client_id,
71        urlencoding::encode(&redirect_uri(dashboard_origin)),
72        ALL_SCOPES.join("%20"), // URL-encoded space
73        urlencoding::encode(state),
74    )
75}
76
77pub(crate) async fn request_tokens<G: core_traits::Global>(
78    global: &Arc<G>,
79    dashboard_origin: &url::Url,
80    code: &str,
81) -> Result<GoogleToken, GoogleTokenError> {
82    let tokens: GoogleToken = global
83        .external_http_client()
84        .post("https://oauth2.googleapis.com/token")
85        .form(&[
86            ("client_id", global.google_oauth2_config().client_id.as_ref()),
87            ("client_secret", global.google_oauth2_config().client_secret.as_ref()),
88            ("code", code),
89            ("grant_type", "authorization_code"),
90            ("redirect_uri", &redirect_uri(dashboard_origin)),
91        ])
92        .send()
93        .await?
94        .json()
95        .await?;
96
97    if tokens.token_type != "Bearer" {
98        return Err(GoogleTokenError::InvalidTokenType(tokens.token_type));
99    }
100
101    if let Some(missing) = REQUIRED_SCOPES.iter().find(|scope| !tokens.scope.contains(*scope)) {
102        return Err(GoogleTokenError::MissingScope(missing.to_string()));
103    }
104
105    Ok(tokens)
106}
107
108#[derive(serde_derive::Deserialize, Debug)]
109pub(crate) struct GoogleWorkspaceUser {
110    #[serde(rename = "isAdmin")]
111    pub is_admin: bool,
112    #[serde(rename = "customerId")]
113    pub customer_id: String,
114}
115
116#[derive(thiserror::Error, Debug)]
117pub(crate) enum GoogleWorkspaceGetUserError {
118    #[error("HTTP request failed: {0}")]
119    RequestFailed(#[from] reqwest::Error),
120    #[error("invalid status code: {0}")]
121    InvalidStatusCode(reqwest::StatusCode),
122}
123
124pub(crate) async fn request_google_workspace_user<G: core_traits::Global>(
125    global: &Arc<G>,
126    access_token: &str,
127    user_id: &str,
128) -> Result<Option<GoogleWorkspaceUser>, GoogleWorkspaceGetUserError> {
129    let response = global
130        .external_http_client()
131        .get(format!("https://www.googleapis.com/admin/directory/v1/users/{user_id}"))
132        .bearer_auth(access_token)
133        .send()
134        .await?;
135
136    if response.status() == reqwest::StatusCode::FORBIDDEN {
137        return Ok(None);
138    }
139
140    if !response.status().is_success() {
141        return Err(GoogleWorkspaceGetUserError::InvalidStatusCode(response.status()));
142    }
143
144    Ok(Some(response.json().await?))
145}