scufflecloud_core/
google_api.rs1use 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#[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<G: core_traits::Global>(global: &Arc<G>) -> String {
60 global.dashboard_origin().join("/oauth2-callback/google").unwrap().to_string()
61}
62
63pub(crate) fn authorization_url<G: core_traits::Global>(global: &Arc<G>, state: &str) -> String {
64 format!(
65 "https://accounts.google.com/o/oauth2/v2/auth?client_id={}&redirect_uri={}&response_type=code&scope={}&state={state}",
66 global.google_oauth2_config().client_id,
67 urlencoding::encode(&redirect_uri(global)),
68 ALL_SCOPES.join("%20"), )
70}
71
72pub(crate) async fn request_tokens<G: core_traits::Global>(
73 global: &Arc<G>,
74 code: &str,
75) -> Result<GoogleToken, GoogleTokenError> {
76 let tokens: GoogleToken = global
77 .external_http_client()
78 .post("https://oauth2.googleapis.com/token")
79 .form(&[
80 ("client_id", global.google_oauth2_config().client_id.as_ref()),
81 ("client_secret", global.google_oauth2_config().client_secret.as_ref()),
82 ("code", code),
83 ("grant_type", "authorization_code"),
84 ("redirect_uri", &redirect_uri(global)),
85 ])
86 .send()
87 .await?
88 .json()
89 .await?;
90
91 if tokens.token_type != "Bearer" {
92 return Err(GoogleTokenError::InvalidTokenType(tokens.token_type));
93 }
94
95 if let Some(missing) = REQUIRED_SCOPES.iter().find(|scope| !tokens.scope.contains(*scope)) {
96 return Err(GoogleTokenError::MissingScope(missing.to_string()));
97 }
98
99 Ok(tokens)
100}
101
102#[derive(serde_derive::Deserialize, Debug)]
103pub(crate) struct GoogleWorkspaceUser {
104 #[serde(rename = "isAdmin")]
105 pub is_admin: bool,
106 #[serde(rename = "customerId")]
107 pub customer_id: String,
108}
109
110#[derive(thiserror::Error, Debug)]
111pub(crate) enum GoogleWorkspaceGetUserError {
112 #[error("HTTP request failed: {0}")]
113 RequestFailed(#[from] reqwest::Error),
114 #[error("invalid status code: {0}")]
115 InvalidStatusCode(reqwest::StatusCode),
116}
117
118pub(crate) async fn request_google_workspace_user<G: core_traits::Global>(
119 global: &Arc<G>,
120 access_token: &str,
121 user_id: &str,
122) -> Result<Option<GoogleWorkspaceUser>, GoogleWorkspaceGetUserError> {
123 let response = global
124 .external_http_client()
125 .get(format!("https://www.googleapis.com/admin/directory/v1/users/{user_id}"))
126 .bearer_auth(access_token)
127 .send()
128 .await?;
129
130 if response.status() == reqwest::StatusCode::FORBIDDEN {
131 return Ok(None);
132 }
133
134 if !response.status().is_success() {
135 return Err(GoogleWorkspaceGetUserError::InvalidStatusCode(response.status()));
136 }
137
138 Ok(Some(response.json().await?))
139}