libauth_rs/provider/stytch/b2b/
session.rs

1use crate::error::{AuthError, AuthResult};
2use crate::provider::Provider;
3use crate::traits::{Authenticator, Authn, Authorizer, ClientAccess, Session};
4use crate::types::{AuthProvider as AuthProviderType, Token, UserContext};
5use async_trait::async_trait;
6use std::collections::HashMap;
7use std::sync::Arc;
8use tracing::debug;
9
10/// Stytch B2B Session Provider
11///
12/// Handles session tokens for B2B (business-to-business) authentication.
13/// B2B sessions include organization context and member information.
14///
15/// This provider uses the Stytch SDK for session authentication, which provides
16/// typed API interactions and proper error handling.
17#[derive(Clone)]
18pub struct StytchProvider {
19    /// Official Stytch SDK client for B2B operations (includes RBAC)
20    stytch_client: Arc<stytch::client::Client>,
21}
22
23impl std::fmt::Debug for StytchProvider {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        f.debug_struct("StytchProvider").finish()
26    }
27}
28
29impl StytchProvider {
30    pub async fn new(config: &Provider) -> AuthResult<Self> {
31        let stytch = config.config.as_stytch()?;
32
33        // Initialize Stytch SDK client for B2B operations (includes RBAC)
34        let sdk_client =
35            stytch::client::Client::new_b2b(&stytch.project_id, &stytch.project_secret).map_err(
36                |e| AuthError::ConfigurationError {
37                    message: format!("Failed to create Stytch B2B SDK client: {}", e),
38                },
39            )?;
40
41        Ok(Self {
42            stytch_client: Arc::new(sdk_client),
43        })
44    }
45
46    /// Get the Stytch SDK client for advanced operations (e.g., RBAC)
47    pub fn sdk(&self) -> &stytch::client::Client {
48        &self.stytch_client
49    }
50
51    /// Validate token format without making API calls
52    ///
53    /// Stytch B2B session tokens follow the format: stytch_session_<env>_<...>
54    /// This is a basic format check before attempting SDK authentication.
55    fn is_valid_token_format(token: &str) -> bool {
56        // Must start with stytch_session_
57        if !token.starts_with("stytch_session_") {
58            return false;
59        }
60
61        // Split by underscore to validate structure
62        let parts: Vec<&str> = token.split('_').collect();
63
64        // Format: stytch_session_<env>_<...> (minimum 3 parts)
65        if parts.len() < 3 {
66            return false;
67        }
68
69        // Validate reasonable length (typical Stytch tokens are 40-100 characters)
70        let len = token.len();
71        (40..=100).contains(&len)
72    }
73}
74
75#[async_trait]
76impl Authenticator for StytchProvider {
77    async fn authenticate(&self, token: &Token) -> AuthResult<UserContext> {
78        if !Self::is_valid_token_format(token) {
79            return Err(AuthError::InvalidToken {
80                message: "Invalid Stytch B2B session token format".to_string(),
81                provider: Some("stytch-b2b-session".to_string()),
82            });
83        }
84
85        debug!(
86            token_prefix = &token[..15.min(token.len())],
87            "Authenticating Stytch B2B session"
88        );
89
90        // Use the Stytch SDK for B2B session authentication
91        let sessions = stytch::b2b::sessions::Sessions::new(self.stytch_client.as_ref().clone());
92
93        let request = stytch::b2b::sessions::AuthenticateRequest {
94            session_token: Some(token.clone()),
95            session_jwt: None,
96            session_duration_minutes: None,
97            session_custom_claims: None,
98            authorization_check: None,
99        };
100
101        let response = sessions.authenticate(request).await.map_err(|e| {
102            tracing::warn!(
103                error = %e,
104                "Stytch B2B session authentication failed"
105            );
106            AuthError::InvalidToken {
107                message: format!("B2B session authentication failed: {}", e),
108                provider: Some("stytch-b2b-session".to_string()),
109            }
110        })?;
111
112        // Extract member and organization information from the SDK response
113        let member = response.member;
114        let member_session = response.member_session;
115        let organization = response.organization;
116
117        let user_id = member.member_id;
118
119        // Extract primary email
120        let email = member.email_address;
121
122        // Build display name from member name
123        let name = member.name;
124
125        // Build metadata with B2B context
126        let mut metadata = HashMap::new();
127        metadata.insert(
128            "stytch_member_id".to_string(),
129            serde_json::Value::String(user_id.clone()),
130        );
131        metadata.insert(
132            "organization_id".to_string(),
133            serde_json::Value::String(organization.organization_id.clone()),
134        );
135        metadata.insert(
136            "organization_name".to_string(),
137            serde_json::Value::String(organization.organization_name.clone()),
138        );
139        metadata.insert(
140            "auth_type".to_string(),
141            serde_json::Value::String("b2b_session".to_string()),
142        );
143        metadata.insert(
144            "session_id".to_string(),
145            serde_json::Value::String(member_session.member_session_id.clone()),
146        );
147
148        // Add member roles (SDK returns Vec<MemberRole> directly)
149        if !member.roles.is_empty() {
150            metadata.insert("roles".to_string(), serde_json::json!(member.roles));
151        }
152
153        // Parse expiration time (SDK returns DateTime<Utc> directly)
154        let expires_at = Some(member_session.expires_at);
155
156        Ok(UserContext {
157            user_id,
158            email: Some(email),
159            name: Some(name),
160            provider: AuthProviderType::Stytch,
161            session_id: Some(member_session.member_session_id),
162            expires_at,
163            metadata,
164        })
165    }
166}
167
168#[async_trait]
169impl Authorizer for StytchProvider {
170    async fn authorize(&self, user: &UserContext, action: &str) -> AuthResult<()> {
171        // For B2B sessions, check roles for authorization
172        if let Some(roles) = user.metadata.get("roles") {
173            if let Some(roles_array) = roles.as_array() {
174                let has_role = roles_array.iter().any(|role| {
175                    if let Some(role_obj) = role.as_object() {
176                        if let Some(role_id) = role_obj.get("role_id").and_then(|v| v.as_str()) {
177                            role_id == action || role_id == "admin"
178                        } else {
179                            false
180                        }
181                    } else {
182                        false
183                    }
184                });
185
186                if has_role {
187                    return Ok(());
188                }
189            }
190        }
191
192        Err(AuthError::InsufficientPermissions(format!(
193            "User does not have required role: {}",
194            action
195        )))
196    }
197}
198
199#[async_trait]
200impl Session for StytchProvider {
201    async fn validate(&self, token: &Token) -> AuthResult<UserContext> {
202        // For session tokens, validation is the same as authentication
203        self.authenticate(token).await
204    }
205}
206
207// Authn is a composite trait that combines Authenticator, Authorizer, and Session
208impl Authn for StytchProvider {}
209
210// Implement ClientAccess to provide access to the Stytch SDK client
211impl ClientAccess for StytchProvider {
212    type Client = Arc<stytch::client::Client>;
213
214    fn client(&self) -> Self::Client {
215        Arc::clone(&self.stytch_client)
216    }
217}