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    #[must_use]
86    pub fn extract_roles(raw_claims: &serde_json::Value) -> Vec<String> {
87        // Try standard Auth0 roles claim first
88        if let Some(roles_val) = raw_claims.get("https://fraiseql.dev/roles") {
89            if let Ok(roles) = serde_json::from_value::<Vec<String>>(roles_val.clone()) {
90                return roles;
91            }
92        }
93
94        // Fallback: check for roles array
95        if let Some(roles_array) = raw_claims.get("roles") {
96            if let Ok(roles) = serde_json::from_value::<Vec<String>>(roles_array.clone()) {
97                return roles;
98            }
99        }
100
101        Vec::new()
102    }
103
104    /// Map Auth0 roles to FraiseQL role permissions
105    ///
106    /// Maps Auth0 role names to FraiseQL role names.
107    /// Supports flexible role naming conventions.
108    ///
109    /// # Arguments
110    /// * `auth0_roles` - List of Auth0 role names
111    #[must_use]
112    pub fn map_auth0_roles_to_fraiseql(auth0_roles: Vec<String>) -> Vec<String> {
113        auth0_roles
114            .into_iter()
115            .filter_map(|role| {
116                let role_lower = role.to_lowercase();
117
118                match role_lower.as_str() {
119                    // Direct role matches
120                    "admin" | "fraiseql-admin" | "administrators" | "fraiseql_admin" => {
121                        Some("admin".to_string())
122                    },
123                    "operator" | "fraiseql-operator" | "operators" | "fraiseql_operator" => {
124                        Some("operator".to_string())
125                    },
126                    "viewer" | "fraiseql-viewer" | "viewers" | "fraiseql_viewer" | "user"
127                    | "fraiseql-user" | "viewer_user" | "read_only" => Some("viewer".to_string()),
128                    // Common patterns
129                    "admin_user" => Some("admin".to_string()),
130                    "operator_user" => Some("operator".to_string()),
131                    _ => None,
132                }
133            })
134            .collect()
135    }
136
137    /// Extract organization ID from Auth0 claims
138    ///
139    /// Auth0 supports org_id in custom claims or extracted from domain.
140    ///
141    /// # Arguments
142    /// * `raw_claims` - Raw JWT claims
143    /// * `email` - User email as fallback
144    #[must_use]
145    pub fn extract_org_id(raw_claims: &serde_json::Value, email: &str) -> Option<String> {
146        // Check for explicit org_id claim
147        if let Some(org_id_val) = raw_claims.get("org_id") {
148            if let Some(org_id_str) = org_id_val.as_str() {
149                return Some(org_id_str.to_string());
150            }
151        }
152
153        // Fallback: extract from email domain
154        email
155            .split('@')
156            .nth(1)
157            .and_then(|domain| domain.split('.').next())
158            .map(|domain_part| domain_part.to_string())
159    }
160}
161
162// Reason: OAuthProvider is defined with #[async_trait]; all implementations must match
163// its transformed method signatures to satisfy the trait contract
164// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
165#[async_trait]
166impl OAuthProvider for Auth0OAuth {
167    fn name(&self) -> &'static str {
168        "auth0"
169    }
170
171    fn authorization_url(&self, state: &str) -> String {
172        self.oidc.authorization_url(state)
173    }
174
175    async fn exchange_code(&self, code: &str) -> Result<TokenResponse> {
176        self.oidc.exchange_code(code).await
177    }
178
179    async fn user_info(&self, access_token: &str) -> Result<UserInfo> {
180        let mut user_info = self.oidc.user_info(access_token).await?;
181
182        // Extract Auth0-specific claims
183        let roles = Self::extract_roles(&user_info.raw_claims);
184        user_info.raw_claims["auth0_roles"] = json!(roles);
185
186        // Extract organization ID
187        if let Some(org_id) = Self::extract_org_id(&user_info.raw_claims, &user_info.email) {
188            user_info.raw_claims["org_id"] = json!(&org_id);
189        }
190
191        // Store Auth0 domain for reference
192        user_info.raw_claims["auth0_domain"] = json!(&self.domain);
193
194        // Add email verification status
195        if let Some(email_verified) = user_info.raw_claims.get("email_verified") {
196            user_info.raw_claims["auth0_email_verified"] = email_verified.clone();
197        }
198
199        Ok(user_info)
200    }
201
202    async fn refresh_token(&self, refresh_token: &str) -> Result<TokenResponse> {
203        self.oidc.refresh_token(refresh_token).await
204    }
205
206    async fn revoke_token(&self, token: &str) -> Result<()> {
207        self.oidc.revoke_token(token).await
208    }
209}