auth_framework/profile_utils/
mod.rs

1//! Utilities for token-to-profile conversion and user profile management.
2
3use crate::errors::{AuthError, OAuthProviderError, Result};
4use crate::providers::{OAuthProvider, OAuthTokenResponse, UserProfile};
5use crate::tokens::AuthToken;
6use reqwest::Client;
7use serde_json::Value;
8// HashMap is used for token metadata
9
10/// Trait for converting tokens to user profiles
11#[allow(async_fn_in_trait)]
12pub trait TokenToProfile {
13    /// Convert a token to a user profile
14    async fn to_profile(&self, provider: &OAuthProvider) -> Result<UserProfile>;
15}
16
17/// Trait for automatic extraction of user profiles from responses
18pub trait ExtractProfile {
19    /// Extract a user profile from a JSON response
20    fn extract_profile(
21        &self,
22        provider: &OAuthProvider,
23        json_response: Value,
24    ) -> Result<UserProfile>;
25}
26
27impl TokenToProfile for OAuthTokenResponse {
28    async fn to_profile(&self, provider: &OAuthProvider) -> Result<UserProfile> {
29        let config = provider.config();
30        let userinfo_url = config.userinfo_url.clone().ok_or_else(|| {
31            AuthError::OAuthProvider(OAuthProviderError::UnsupportedFeature {
32                provider: format!("{:?}", provider),
33                feature: "userinfo endpoint".to_string(),
34            })
35        })?;
36
37        let client = Client::new();
38        let response = client
39            .get(&userinfo_url)
40            .bearer_auth(&self.access_token)
41            .send()
42            .await
43            .map_err(|e| AuthError::NetworkError(format!("Failed to fetch user profile: {}", e)))?;
44
45        let status = response.status();
46        if !status.is_success() {
47            return Err(AuthError::NetworkError(format!(
48                "Failed to fetch user profile. Status code: {}",
49                status
50            )));
51        }
52
53        let json_response = response.json::<Value>().await.map_err(|e| {
54            AuthError::ParseError(format!("Failed to parse user profile response: {}", e))
55        })?;
56
57        provider.extract_profile(provider, json_response)
58    }
59}
60
61impl ExtractProfile for OAuthProvider {
62    fn extract_profile(
63        &self,
64        _provider: &OAuthProvider,
65        json_response: Value,
66    ) -> Result<UserProfile> {
67        // Default extractor attempts to map standard fields based on provider
68        let mut profile = UserProfile::new();
69
70        // Get ID field based on provider
71        match self {
72            OAuthProvider::GitHub => {
73                profile = profile.with_id(extract_string(&json_response, "id")?);
74                profile = profile.with_username(extract_string_optional(&json_response, "login"));
75                profile = profile.with_name(extract_string_optional(&json_response, "name"));
76                profile = profile.with_email(extract_string_optional(&json_response, "email"));
77                profile =
78                    profile.with_picture(extract_string_optional(&json_response, "avatar_url"));
79            }
80            OAuthProvider::Google => {
81                profile = profile.with_id(extract_string(&json_response, "sub")?);
82                profile = profile.with_email(extract_string_optional(&json_response, "email"));
83                profile = profile.with_name(extract_string_optional(&json_response, "name"));
84                profile = profile.with_picture(extract_string_optional(&json_response, "picture"));
85                profile = profile.with_locale(extract_string_optional(&json_response, "locale"));
86
87                if let Some(email_verified) = json_response
88                    .get("email_verified")
89                    .and_then(|v| v.as_bool())
90                {
91                    profile = profile.with_email_verified(email_verified);
92                }
93            }
94            OAuthProvider::Microsoft => {
95                profile = profile.with_id(extract_string(&json_response, "id")?);
96                profile = profile.with_email(
97                    extract_string_optional(&json_response, "userPrincipalName")
98                        .or_else(|| extract_string_optional(&json_response, "mail")),
99                );
100                profile = profile.with_name(extract_string_optional(&json_response, "displayName"));
101
102                // Microsoft Graph API might have these in different formats
103                if let Some(photo) = json_response.get("photo") {
104                    profile = profile.with_picture(photo.as_str().map(String::from));
105                }
106            }
107            OAuthProvider::Discord => {
108                profile = profile.with_id(extract_string(&json_response, "id")?);
109                profile =
110                    profile.with_username(extract_string_optional(&json_response, "username"));
111                profile = profile.with_email(extract_string_optional(&json_response, "email"));
112
113                if let Some(avatar) = extract_string_optional(&json_response, "avatar") {
114                    let id = extract_string_optional(&json_response, "id").unwrap_or_default();
115                    let avatar_url =
116                        format!("https://cdn.discordapp.com/avatars/{}/{}.png", id, avatar);
117                    profile = profile.with_picture(Some(avatar_url));
118                }
119            }
120            OAuthProvider::GitLab => {
121                profile = profile.with_id(extract_string(&json_response, "id")?);
122                profile =
123                    profile.with_username(extract_string_optional(&json_response, "username"));
124                profile = profile.with_name(extract_string_optional(&json_response, "name"));
125                profile = profile.with_email(extract_string_optional(&json_response, "email"));
126                profile =
127                    profile.with_picture(extract_string_optional(&json_response, "avatar_url"));
128            }
129            OAuthProvider::Twitter => {
130                // Twitter API v2 has a different structure
131                if let Some(data) = json_response.get("data") {
132                    profile = profile.with_id(extract_string_from_value(data, "id")?);
133                    profile =
134                        profile.with_username(extract_string_optional_from_value(data, "username"));
135                    profile = profile.with_name(extract_string_optional_from_value(data, "name"));
136                } else {
137                    profile = profile.with_id(extract_string(&json_response, "id")?);
138                    profile = profile
139                        .with_username(extract_string_optional(&json_response, "screen_name"));
140                    profile = profile.with_name(extract_string_optional(&json_response, "name"));
141                    profile = profile.with_picture(extract_string_optional(
142                        &json_response,
143                        "profile_image_url_https",
144                    ));
145                }
146            }
147            OAuthProvider::Facebook => {
148                profile = profile.with_id(extract_string(&json_response, "id")?);
149                profile = profile.with_name(extract_string_optional(&json_response, "name"));
150                profile = profile.with_email(extract_string_optional(&json_response, "email"));
151                // Facebook requires requesting the picture separately or with fields parameter
152                if let Some(id) = extract_string_optional(&json_response, "id") {
153                    let picture_url =
154                        format!("https://graph.facebook.com/{}/picture?type=large", id);
155                    profile = profile.with_picture(Some(picture_url));
156                }
157            }
158            OAuthProvider::LinkedIn => {
159                profile = profile.with_id(extract_string(&json_response, "id")?);
160                // LinkedIn API structure is complex, try to navigate it
161                if let Some(name) = json_response
162                    .get("localizedFirstName")
163                    .and_then(|f| f.as_str())
164                    .zip(
165                        json_response
166                            .get("localizedLastName")
167                            .and_then(|l| l.as_str()),
168                    )
169                {
170                    profile = profile.with_name(Some(format!("{} {}", name.0, name.1)));
171                }
172                // For LinkedIn, email requires a separate API call with r_emailaddress permission
173            }
174            OAuthProvider::Custom { name, .. } => {
175                // For custom providers, try some common ID field names
176                let id = extract_string_optional(&json_response, "id")
177                    .or_else(|| extract_string_optional(&json_response, "sub"))
178                    .or_else(|| extract_string_optional(&json_response, "user_id"));
179
180                if let Some(id) = id {
181                    profile = profile.with_id(id);
182                } else {
183                    return Err(AuthError::validation(format!(
184                        "Could not find ID field in response from custom provider {}",
185                        name
186                    )));
187                }
188
189                // Try common fields
190                profile = profile.with_email(extract_string_optional(&json_response, "email"));
191                profile = profile.with_name(
192                    extract_string_optional(&json_response, "name")
193                        .or_else(|| extract_string_optional(&json_response, "display_name")),
194                );
195                profile = profile.with_username(
196                    extract_string_optional(&json_response, "username")
197                        .or_else(|| extract_string_optional(&json_response, "login")),
198                );
199                profile = profile.with_picture(
200                    extract_string_optional(&json_response, "picture")
201                        .or_else(|| extract_string_optional(&json_response, "avatar"))
202                        .or_else(|| extract_string_optional(&json_response, "avatar_url")),
203                );
204            }
205        }
206
207        // Store the full response in additional_data for access to all fields
208        profile = profile.with_additional_data("raw_profile", json_response);
209        Ok(profile)
210    }
211}
212
213impl TokenToProfile for AuthToken {
214    async fn to_profile(&self, _provider: &OAuthProvider) -> Result<UserProfile> {
215        // Create a basic profile from the token data
216        let mut profile = UserProfile::new();
217
218        // Use the user ID as the profile ID
219        profile = profile.with_id(self.user_id.clone());
220
221        // Use the auth method as the provider if available
222        profile = profile.with_provider(self.auth_method.clone());
223
224        // Add custom metadata if available
225        if let Some(name) = self.metadata.custom.get("name").and_then(|v| v.as_str()) {
226            profile = profile.with_name(Some(name.to_string()));
227        }
228
229        if let Some(email) = self.metadata.custom.get("email").and_then(|v| v.as_str()) {
230            profile = profile.with_email(Some(email.to_string()));
231        }
232
233        // We can add more information later if needed
234
235        Ok(profile)
236    }
237}
238
239/// Helper function to extract a required string from a JSON response
240fn extract_string(json: &Value, field: &str) -> Result<String> {
241    json.get(field)
242        .and_then(|v| v.as_str())
243        .map(String::from)
244        .ok_or_else(|| {
245            AuthError::validation(format!("Field '{}' not found or not a string", field))
246        })
247}
248
249/// Helper function to extract an optional string from a JSON response
250fn extract_string_optional(json: &Value, field: &str) -> Option<String> {
251    json.get(field).and_then(|v| v.as_str()).map(String::from)
252}
253
254/// Helper function to extract a required string from a JSON value
255fn extract_string_from_value(json: &Value, field: &str) -> Result<String> {
256    json.get(field)
257        .and_then(|v| v.as_str())
258        .map(String::from)
259        .ok_or_else(|| {
260            AuthError::validation(format!("Field '{}' not found or not a string", field))
261        })
262}
263
264/// Helper function to extract an optional string from a JSON value
265fn extract_string_optional_from_value(json: &Value, field: &str) -> Option<String> {
266    json.get(field).and_then(|v| v.as_str()).map(String::from)
267}
268
269// Include tests
270// #[cfg(test)]
271// mod tests;
272
273