fraiseql_auth/providers/
auth0.rs1use async_trait::async_trait;
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5
6use crate::{
7 error::Result,
8 oidc_provider::OidcProvider,
9 provider::{OAuthProvider, TokenResponse, UserInfo},
10};
11
12#[derive(Debug)]
17pub struct Auth0OAuth {
18 oidc: OidcProvider,
19 domain: String,
20}
21
22#[derive(Debug, Clone, Deserialize)]
24pub struct Auth0User {
25 pub sub: String,
27 pub email: String,
29 pub email_verified: Option<bool>,
31 pub name: Option<String>,
33 pub picture: Option<String>,
35 pub locale: Option<String>,
37 pub nickname: Option<String>,
39}
40
41#[derive(Debug, Clone, Deserialize, Serialize)]
43pub struct Auth0Roles {
44 pub roles: Option<Vec<String>>,
46}
47
48impl Auth0OAuth {
49 pub async fn new(
61 client_id: String,
62 client_secret: String,
63 auth0_domain: String,
64 redirect_uri: String,
65 ) -> Result<Self> {
66 let issuer_url = format!("https://{}", auth0_domain);
67
68 let oidc =
69 OidcProvider::new("auth0", &issuer_url, &client_id, &client_secret, &redirect_uri)
70 .await?;
71
72 Ok(Self {
73 oidc,
74 domain: auth0_domain,
75 })
76 }
77
78 pub fn extract_roles(raw_claims: &serde_json::Value) -> Vec<String> {
86 if let Some(roles_val) = raw_claims.get("https://fraiseql.dev/roles") {
88 if let Ok(roles) = serde_json::from_value::<Vec<String>>(roles_val.clone()) {
89 return roles;
90 }
91 }
92
93 if let Some(roles_array) = raw_claims.get("roles") {
95 if let Ok(roles) = serde_json::from_value::<Vec<String>>(roles_array.clone()) {
96 return roles;
97 }
98 }
99
100 Vec::new()
101 }
102
103 pub fn map_auth0_roles_to_fraiseql(auth0_roles: Vec<String>) -> Vec<String> {
111 auth0_roles
112 .into_iter()
113 .filter_map(|role| {
114 let role_lower = role.to_lowercase();
115
116 match role_lower.as_str() {
117 "admin" | "fraiseql-admin" | "administrators" | "fraiseql_admin" => {
119 Some("admin".to_string())
120 },
121 "operator" | "fraiseql-operator" | "operators" | "fraiseql_operator" => {
122 Some("operator".to_string())
123 },
124 "viewer" | "fraiseql-viewer" | "viewers" | "fraiseql_viewer" | "user"
125 | "fraiseql-user" | "viewer_user" | "read_only" => Some("viewer".to_string()),
126 "admin_user" => Some("admin".to_string()),
128 "operator_user" => Some("operator".to_string()),
129 _ => None,
130 }
131 })
132 .collect()
133 }
134
135 pub fn extract_org_id(raw_claims: &serde_json::Value, email: &str) -> Option<String> {
143 if let Some(org_id_val) = raw_claims.get("org_id") {
145 if let Some(org_id_str) = org_id_val.as_str() {
146 return Some(org_id_str.to_string());
147 }
148 }
149
150 email
152 .split('@')
153 .nth(1)
154 .and_then(|domain| domain.split('.').next())
155 .map(|domain_part| domain_part.to_string())
156 }
157}
158
159#[async_trait]
163impl OAuthProvider for Auth0OAuth {
164 fn name(&self) -> &'static str {
165 "auth0"
166 }
167
168 fn authorization_url(&self, state: &str) -> String {
169 self.oidc.authorization_url(state)
170 }
171
172 async fn exchange_code(&self, code: &str) -> Result<TokenResponse> {
173 self.oidc.exchange_code(code).await
174 }
175
176 async fn user_info(&self, access_token: &str) -> Result<UserInfo> {
177 let mut user_info = self.oidc.user_info(access_token).await?;
178
179 let roles = Self::extract_roles(&user_info.raw_claims);
181 user_info.raw_claims["auth0_roles"] = json!(roles);
182
183 if let Some(org_id) = Self::extract_org_id(&user_info.raw_claims, &user_info.email) {
185 user_info.raw_claims["org_id"] = json!(&org_id);
186 }
187
188 user_info.raw_claims["auth0_domain"] = json!(&self.domain);
190
191 if let Some(email_verified) = user_info.raw_claims.get("email_verified") {
193 user_info.raw_claims["auth0_email_verified"] = email_verified.clone();
194 }
195
196 Ok(user_info)
197 }
198
199 async fn refresh_token(&self, refresh_token: &str) -> Result<TokenResponse> {
200 self.oidc.refresh_token(refresh_token).await
201 }
202
203 async fn revoke_token(&self, token: &str) -> Result<()> {
204 self.oidc.revoke_token(token).await
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 #[allow(clippy::wildcard_imports)]
211 use super::*;
213
214 #[test]
215 fn test_extract_roles_from_custom_namespace() {
216 let claims = json!({
217 "https://fraiseql.dev/roles": ["admin", "operator", "viewer"]
218 });
219
220 let roles = Auth0OAuth::extract_roles(&claims);
221 assert_eq!(roles.len(), 3);
222 assert!(roles.contains(&"admin".to_string()));
223 assert!(roles.contains(&"operator".to_string()));
224 assert!(roles.contains(&"viewer".to_string()));
225 }
226
227 #[test]
228 fn test_extract_roles_fallback() {
229 let claims = json!({
230 "roles": ["admin", "user"]
231 });
232
233 let roles = Auth0OAuth::extract_roles(&claims);
234 assert_eq!(roles.len(), 2);
235 assert!(roles.contains(&"admin".to_string()));
236 }
237
238 #[test]
239 fn test_extract_roles_missing() {
240 let claims = json!({});
241 let roles = Auth0OAuth::extract_roles(&claims);
242 assert!(roles.is_empty());
243 }
244
245 #[test]
246 fn test_map_auth0_roles_to_fraiseql() {
247 let roles = vec![
248 "admin".to_string(),
249 "fraiseql-operator".to_string(),
250 "viewer".to_string(),
251 "unknown".to_string(),
252 ];
253
254 let fraiseql_roles = Auth0OAuth::map_auth0_roles_to_fraiseql(roles);
255
256 assert_eq!(fraiseql_roles.len(), 3);
257 assert!(fraiseql_roles.contains(&"admin".to_string()));
258 assert!(fraiseql_roles.contains(&"operator".to_string()));
259 assert!(fraiseql_roles.contains(&"viewer".to_string()));
260 }
261
262 #[test]
263 fn test_map_auth0_roles_underscore_separator() {
264 let roles = vec![
265 "fraiseql_admin".to_string(),
266 "fraiseql_operator".to_string(),
267 "fraiseql_viewer".to_string(),
268 ];
269
270 let fraiseql_roles = Auth0OAuth::map_auth0_roles_to_fraiseql(roles);
271
272 assert_eq!(fraiseql_roles.len(), 3);
273 assert!(fraiseql_roles.contains(&"admin".to_string()));
274 assert!(fraiseql_roles.contains(&"operator".to_string()));
275 assert!(fraiseql_roles.contains(&"viewer".to_string()));
276 }
277
278 #[test]
279 fn test_map_auth0_roles_case_insensitive() {
280 let roles = vec![
281 "ADMIN".to_string(),
282 "Operator".to_string(),
283 "VIEWER".to_string(),
284 ];
285
286 let fraiseql_roles = Auth0OAuth::map_auth0_roles_to_fraiseql(roles);
287
288 assert_eq!(fraiseql_roles.len(), 3);
289 }
290
291 #[test]
292 fn test_map_auth0_roles_common_patterns() {
293 let roles = vec![
294 "admin_user".to_string(),
295 "operator_user".to_string(),
296 "viewer_user".to_string(),
297 "read_only".to_string(),
298 ];
299
300 let fraiseql_roles = Auth0OAuth::map_auth0_roles_to_fraiseql(roles);
301
302 assert_eq!(fraiseql_roles.len(), 4);
303 assert!(fraiseql_roles.contains(&"admin".to_string()));
304 assert!(fraiseql_roles.contains(&"operator".to_string()));
305 }
306
307 #[test]
308 fn test_extract_org_id_from_claim() {
309 let claims = json!({
310 "org_id": "example-corp"
311 });
312
313 let org_id = Auth0OAuth::extract_org_id(&claims, "user@company.com");
314 assert_eq!(org_id, Some("example-corp".to_string()));
315 }
316
317 #[test]
318 fn test_extract_org_id_from_email_domain() {
319 let claims = json!({});
320
321 let org_id = Auth0OAuth::extract_org_id(&claims, "user@example.com");
322 assert_eq!(org_id, Some("example".to_string()));
323 }
324
325 #[test]
326 fn test_extract_org_id_missing() {
327 let claims = json!({});
328
329 let org_id = Auth0OAuth::extract_org_id(&claims, "user@localhost");
330 assert_eq!(org_id, Some("localhost".to_string()));
331 }
332
333 #[test]
334 fn test_extract_org_id_claim_takes_precedence() {
335 let claims = json!({
336 "org_id": "explicit-org"
337 });
338
339 let org_id = Auth0OAuth::extract_org_id(&claims, "user@other.com");
340 assert_eq!(org_id, Some("explicit-org".to_string()));
341 }
342}