Skip to main content

fraiseql_auth/providers/
auth0.rs

1//! Auth0 OAuth / OIDC provider implementation.
2use 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/// Auth0 OAuth provider wrapper
13///
14/// Handles Auth0-specific OAuth flows and role mapping.
15/// Supports both Auth0 rules and custom claim mapping.
16#[derive(Debug)]
17pub struct Auth0OAuth {
18    oidc:   OidcProvider,
19    domain: String,
20}
21
22/// Auth0 user information
23#[derive(Debug, Clone, Deserialize)]
24pub struct Auth0User {
25    /// Subject — unique user identifier (`sub` claim)
26    pub sub:            String,
27    /// User's primary email address
28    pub email:          String,
29    /// Whether the email address has been verified
30    pub email_verified: Option<bool>,
31    /// User's full display name
32    pub name:           Option<String>,
33    /// URL of the user's profile picture
34    pub picture:        Option<String>,
35    /// User's locale (e.g., `"en-US"`)
36    pub locale:         Option<String>,
37    /// Auth0 nickname (usually the part before `@` in the email)
38    pub nickname:       Option<String>,
39}
40
41/// Auth0 roles claim
42#[derive(Debug, Clone, Deserialize, Serialize)]
43pub struct Auth0Roles {
44    /// List of role names assigned to the user via Auth0 rules or management API
45    pub roles: Option<Vec<String>>,
46}
47
48impl Auth0OAuth {
49    /// Create a new Auth0 OAuth provider
50    ///
51    /// # Arguments
52    /// * `client_id` - Auth0 application client ID
53    /// * `client_secret` - Auth0 application client secret
54    /// * `auth0_domain` - Auth0 tenant domain (e.g., "example.auth0.com")
55    /// * `redirect_uri` - Redirect URI after authentication (e.g., "http://localhost:8000/auth/callback")
56    ///
57    /// # Errors
58    ///
59    /// Returns `AuthError` if OIDC discovery against the Auth0 domain fails.
60    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    /// Extract roles from Auth0 custom claims
79    ///
80    /// Auth0 supports custom claim namespaces to avoid claim collisions.
81    /// This extracts roles from the standard Auth0 roles claim or custom namespace.
82    ///
83    /// # Arguments
84    /// * `raw_claims` - Raw JWT claims from Auth0 token
85    pub fn extract_roles(raw_claims: &serde_json::Value) -> Vec<String> {
86        // Try standard Auth0 roles claim first
87        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        // Fallback: check for roles array
94        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    /// Map Auth0 roles to FraiseQL role permissions
104    ///
105    /// Maps Auth0 role names to FraiseQL role names.
106    /// Supports flexible role naming conventions.
107    ///
108    /// # Arguments
109    /// * `auth0_roles` - List of Auth0 role names
110    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                    // Direct role matches
118                    "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                    // Common patterns
127                    "admin_user" => Some("admin".to_string()),
128                    "operator_user" => Some("operator".to_string()),
129                    _ => None,
130                }
131            })
132            .collect()
133    }
134
135    /// Extract organization ID from Auth0 claims
136    ///
137    /// Auth0 supports org_id in custom claims or extracted from domain.
138    ///
139    /// # Arguments
140    /// * `raw_claims` - Raw JWT claims
141    /// * `email` - User email as fallback
142    pub fn extract_org_id(raw_claims: &serde_json::Value, email: &str) -> Option<String> {
143        // Check for explicit org_id claim
144        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        // Fallback: extract from email domain
151        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// Reason: OAuthProvider is defined with #[async_trait]; all implementations must match
160// its transformed method signatures to satisfy the trait contract
161// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
162#[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        // Extract Auth0-specific claims
180        let roles = Self::extract_roles(&user_info.raw_claims);
181        user_info.raw_claims["auth0_roles"] = json!(roles);
182
183        // Extract organization ID
184        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        // Store Auth0 domain for reference
189        user_info.raw_claims["auth0_domain"] = json!(&self.domain);
190
191        // Add email verification status
192        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    // Reason: test module — wildcard keeps test boilerplate minimal
212    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}