Skip to main content

zeal_sdk/
auth.rs

1//! Authentication utilities for ZIP SDK
2//! Generates auth tokens with the required information for zeal-auth
3
4use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
5use hmac::{Hmac, Mac};
6use rand::Rng;
7use serde::{Deserialize, Serialize};
8use sha2::Sha256;
9use std::collections::HashMap;
10use std::env;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13type HmacSha256 = Hmac<Sha256>;
14
15/// Subject information required by zeal-auth
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct TokenSubject {
18    pub id: String,
19    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
20    pub subject_type: Option<String>, // user, service, api_key
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub tenant_id: Option<String>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub organization_id: Option<String>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub teams: Option<Vec<String>>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub groups: Option<Vec<String>>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub roles: Option<Vec<String>>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub permissions: Option<Vec<String>>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub metadata: Option<HashMap<String, serde_json::Value>>,
35}
36
37/// Token generation options
38#[derive(Debug, Clone, Default)]
39pub struct TokenOptions {
40    pub expires_in: Option<u64>, // seconds
41    pub issuer: Option<String>,
42    pub audience: Option<Vec<String>>,
43    pub not_before: Option<u64>,    // timestamp
44    pub secret_key: Option<String>, // ZEAL_SECRET_KEY for signing
45}
46
47/// Token payload structure expected by zeal-auth
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct TokenPayload {
50    pub sub: String,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub iss: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub aud: Option<Vec<String>>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub exp: Option<u64>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub iat: Option<u64>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub nbf: Option<u64>,
61    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
62    pub subject_type: Option<String>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub tenant_id: Option<String>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub organization_id: Option<String>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub teams: Option<Vec<String>>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub groups: Option<Vec<String>>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub roles: Option<Vec<String>>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub permissions: Option<Vec<String>>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub metadata: Option<HashMap<String, serde_json::Value>>,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub sdk_version: Option<String>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub application_id: Option<String>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub session_id: Option<String>,
83}
84
85/// Error type for auth operations
86#[derive(Debug)]
87pub enum AuthError {
88    MissingSecretKey,
89    InvalidTokenFormat,
90    InvalidSignature,
91    InvalidPayload(String),
92    SerializationError(String),
93}
94
95impl std::fmt::Display for AuthError {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            AuthError::MissingSecretKey => write!(
99                f,
100                "ZEAL_SECRET_KEY is required for token generation. Set it as an environment variable or pass it in options"
101            ),
102            AuthError::InvalidTokenFormat => write!(f, "Invalid token format"),
103            AuthError::InvalidSignature => write!(f, "Invalid token signature"),
104            AuthError::InvalidPayload(msg) => write!(f, "Invalid token payload: {}", msg),
105            AuthError::SerializationError(msg) => write!(f, "Serialization error: {}", msg),
106        }
107    }
108}
109
110impl std::error::Error for AuthError {}
111
112/// Generate a signed token for self-hosted Zeal integrators
113/// Uses HMAC-SHA256 for signing with the provided secret key
114/// Returns signed token string in format: base64(payload).signature
115pub fn generate_auth_token(
116    subject: &TokenSubject,
117    options: Option<TokenOptions>,
118) -> Result<String, AuthError> {
119    let options = options.unwrap_or_default();
120
121    // Get secret key from options or environment
122    let secret_key = options
123        .secret_key
124        .or_else(|| env::var("ZEAL_SECRET_KEY").ok())
125        .ok_or(AuthError::MissingSecretKey)?;
126
127    let now = SystemTime::now()
128        .duration_since(UNIX_EPOCH)
129        .unwrap()
130        .as_secs();
131
132    // Generate session ID
133    let session_id: String = rand::thread_rng()
134        .sample_iter(&rand::distributions::Alphanumeric)
135        .take(16)
136        .map(char::from)
137        .collect();
138
139    let mut payload = TokenPayload {
140        sub: subject.id.clone(),
141        iat: Some(now),
142        subject_type: subject.subject_type.clone(),
143        tenant_id: subject.tenant_id.clone(),
144        organization_id: subject.organization_id.clone(),
145        teams: subject.teams.clone(),
146        groups: subject.groups.clone(),
147        roles: subject.roles.clone(),
148        permissions: subject.permissions.clone(),
149        metadata: subject.metadata.clone(),
150        sdk_version: Some("1.0.0".to_string()),
151        application_id: Some("zeal-rust-sdk".to_string()),
152        session_id: Some(session_id),
153        iss: None,
154        aud: None,
155        exp: None,
156        nbf: None,
157    };
158
159    // Add optional claims
160    if let Some(expires_in) = options.expires_in {
161        payload.exp = Some(now + expires_in);
162    }
163    if let Some(issuer) = options.issuer {
164        payload.iss = Some(issuer);
165    }
166    if let Some(audience) = options.audience {
167        payload.aud = Some(audience);
168    }
169    if let Some(not_before) = options.not_before {
170        payload.nbf = Some(not_before);
171    }
172
173    // Encode payload as base64url
174    let payload_json = serde_json::to_string(&payload)
175        .map_err(|e| AuthError::SerializationError(e.to_string()))?;
176    let encoded_payload = URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
177
178    // Create HMAC signature
179    let mut mac = HmacSha256::new_from_slice(secret_key.as_bytes())
180        .map_err(|e| AuthError::SerializationError(e.to_string()))?;
181    mac.update(encoded_payload.as_bytes());
182    let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
183
184    // Return token in format: payload.signature
185    Ok(format!("{}.{}", encoded_payload, signature))
186}
187
188/// Verify and parse a signed token
189/// Returns parsed token payload or error if invalid
190pub fn verify_and_parse_token(
191    token: &str,
192    secret_key: Option<String>,
193) -> Result<TokenPayload, AuthError> {
194    let key = secret_key
195        .or_else(|| env::var("ZEAL_SECRET_KEY").ok())
196        .ok_or(AuthError::MissingSecretKey)?;
197
198    let parts: Vec<&str> = token.split('.').collect();
199    if parts.len() != 2 {
200        return Err(AuthError::InvalidTokenFormat);
201    }
202
203    let encoded_payload = parts[0];
204    let signature = parts[1];
205
206    // Verify signature
207    let mut mac = HmacSha256::new_from_slice(key.as_bytes())
208        .map_err(|e| AuthError::SerializationError(e.to_string()))?;
209    mac.update(encoded_payload.as_bytes());
210    let expected_signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
211
212    if signature != expected_signature {
213        return Err(AuthError::InvalidSignature);
214    }
215
216    // Decode and parse payload
217    let payload_bytes = URL_SAFE_NO_PAD
218        .decode(encoded_payload)
219        .map_err(|e| AuthError::InvalidPayload(e.to_string()))?;
220    let payload: TokenPayload = serde_json::from_slice(&payload_bytes)
221        .map_err(|e| AuthError::InvalidPayload(e.to_string()))?;
222
223    Ok(payload)
224}
225
226/// Parse a token without verification (USE WITH CAUTION)
227/// Only use this for debugging or when you don't have the secret key
228pub fn parse_token_unsafe(token: &str) -> Result<TokenPayload, AuthError> {
229    let parts: Vec<&str> = token.split('.').collect();
230    if parts.len() != 2 {
231        return Err(AuthError::InvalidTokenFormat);
232    }
233
234    let encoded_payload = parts[0];
235
236    let payload_bytes = URL_SAFE_NO_PAD
237        .decode(encoded_payload)
238        .map_err(|e| AuthError::InvalidPayload(e.to_string()))?;
239    let payload: TokenPayload = serde_json::from_slice(&payload_bytes)
240        .map_err(|e| AuthError::InvalidPayload(e.to_string()))?;
241
242    Ok(payload)
243}
244
245/// Create a service account token
246/// Convenience function for creating tokens for service-to-service auth
247pub fn create_service_token(
248    service_id: &str,
249    tenant_id: &str,
250    permissions: Vec<String>,
251    options: Option<TokenOptions>,
252) -> Result<String, AuthError> {
253    let mut metadata = HashMap::new();
254    metadata.insert("service".to_string(), serde_json::json!(true));
255    metadata.insert(
256        "created_at".to_string(),
257        serde_json::json!(chrono::Utc::now().to_rfc3339()),
258    );
259
260    generate_auth_token(
261        &TokenSubject {
262            id: service_id.to_string(),
263            subject_type: Some("service".to_string()),
264            tenant_id: Some(tenant_id.to_string()),
265            permissions: Some(permissions),
266            metadata: Some(metadata),
267            organization_id: None,
268            teams: None,
269            groups: None,
270            roles: None,
271        },
272        options,
273    )
274}
275
276/// Create a user token
277/// Convenience function for creating user authentication tokens
278pub fn create_user_token(
279    user_id: &str,
280    tenant_id: &str,
281    roles: Vec<String>,
282    options: Option<TokenOptions>,
283) -> Result<String, AuthError> {
284    let mut metadata = HashMap::new();
285    metadata.insert(
286        "created_at".to_string(),
287        serde_json::json!(chrono::Utc::now().to_rfc3339()),
288    );
289
290    generate_auth_token(
291        &TokenSubject {
292            id: user_id.to_string(),
293            subject_type: Some("user".to_string()),
294            tenant_id: Some(tenant_id.to_string()),
295            roles: Some(roles),
296            metadata: Some(metadata),
297            organization_id: None,
298            teams: None,
299            groups: None,
300            permissions: None,
301        },
302        options,
303    )
304}
305
306/// Create an API key token
307/// Convenience function for creating API key authentication tokens
308pub fn create_api_key_token(
309    api_key_id: &str,
310    tenant_id: &str,
311    permissions: Vec<String>,
312    options: Option<TokenOptions>,
313) -> Result<String, AuthError> {
314    let mut metadata = HashMap::new();
315    metadata.insert("api_key".to_string(), serde_json::json!(true));
316    metadata.insert(
317        "created_at".to_string(),
318        serde_json::json!(chrono::Utc::now().to_rfc3339()),
319    );
320
321    generate_auth_token(
322        &TokenSubject {
323            id: api_key_id.to_string(),
324            subject_type: Some("api_key".to_string()),
325            tenant_id: Some(tenant_id.to_string()),
326            permissions: Some(permissions),
327            metadata: Some(metadata),
328            organization_id: None,
329            teams: None,
330            groups: None,
331            roles: None,
332        },
333        options,
334    )
335}
336
337/// Validate token expiration and signature
338/// Returns true if token is valid and not expired, false otherwise
339pub fn is_token_valid(token: &str, secret_key: Option<String>) -> bool {
340    match verify_and_parse_token(token, secret_key) {
341        Ok(payload) => {
342            let now = SystemTime::now()
343                .duration_since(UNIX_EPOCH)
344                .unwrap()
345                .as_secs();
346
347            // Check expiration
348            if let Some(exp) = payload.exp {
349                if exp < now {
350                    return false;
351                }
352            }
353
354            // Check not before
355            if let Some(nbf) = payload.nbf {
356                if nbf > now {
357                    return false;
358                }
359            }
360
361            true
362        }
363        Err(_) => false,
364    }
365}