Skip to main content

openclaw_gateway/auth/
setup.rs

1//! First-run setup and bootstrap management.
2
3use std::time::{Duration, Instant};
4
5use serde::{Deserialize, Serialize};
6
7use super::AuthError;
8use super::users::{User, UserRole, UserStore};
9
10/// Bootstrap token validity duration.
11const BOOTSTRAP_TOKEN_VALIDITY: Duration = Duration::from_secs(3600); // 1 hour
12
13/// Setup status response.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SetupStatus {
16    /// Whether the system is initialized (has at least one admin).
17    pub initialized: bool,
18    /// Number of users configured.
19    pub user_count: usize,
20    /// Whether a bootstrap token is active.
21    pub bootstrap_active: bool,
22    /// Bootstrap URL (only if bootstrap is active and not initialized).
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub setup_url: Option<String>,
25}
26
27/// Manages the first-run bootstrap process.
28pub struct BootstrapManager {
29    /// Bootstrap token (if active).
30    token: Option<BootstrapToken>,
31    /// When the manager was created.
32    created_at: Instant,
33}
34
35/// Internal bootstrap token state.
36struct BootstrapToken {
37    /// The token value (URL-safe base64).
38    value: String,
39    /// When the token was created.
40    created_at: Instant,
41    /// When the token expires.
42    expires_at: Instant,
43}
44
45impl BootstrapManager {
46    /// Create a new bootstrap manager.
47    #[must_use]
48    pub fn new() -> Self {
49        Self {
50            token: None,
51            created_at: Instant::now(),
52        }
53    }
54
55    /// Check if setup is required and generate bootstrap token if needed.
56    ///
57    /// Returns the bootstrap token if one was generated.
58    pub fn check_and_generate(&mut self, user_store: &UserStore) -> Option<String> {
59        if !user_store.is_empty() {
60            // System already initialized
61            self.token = None;
62            return None;
63        }
64
65        // Check if we already have a valid token
66        if let Some(ref token) = self.token {
67            if Instant::now() < token.expires_at {
68                return Some(token.value.clone());
69            }
70        }
71
72        // Generate new bootstrap token
73        let token_value = Self::generate_token();
74        let now = Instant::now();
75
76        self.token = Some(BootstrapToken {
77            value: token_value.clone(),
78            created_at: now,
79            expires_at: now + BOOTSTRAP_TOKEN_VALIDITY,
80        });
81
82        Some(token_value)
83    }
84
85    /// Validate a bootstrap token.
86    pub fn validate_token(&self, token: &str) -> Result<(), AuthError> {
87        match &self.token {
88            Some(bt) if bt.value == token && Instant::now() < bt.expires_at => Ok(()),
89            Some(_) => Err(AuthError::InvalidBootstrapToken),
90            None => Err(AuthError::InvalidBootstrapToken),
91        }
92    }
93
94    /// Complete setup with the bootstrap token.
95    ///
96    /// Creates the initial admin user and invalidates the bootstrap token.
97    ///
98    /// # Errors
99    ///
100    /// Returns error if token is invalid or user creation fails.
101    pub fn complete_setup(
102        &mut self,
103        user_store: &UserStore,
104        token: &str,
105        username: &str,
106        password: &str,
107        email: Option<String>,
108    ) -> Result<User, AuthError> {
109        // Validate token
110        self.validate_token(token)?;
111
112        // Check system isn't already initialized
113        if !user_store.is_empty() {
114            return Err(AuthError::Config("System already initialized".to_string()));
115        }
116
117        // Create admin user
118        let mut admin = User::new(username, password, UserRole::Admin)?;
119        admin.email = email;
120
121        user_store.create(&admin)?;
122
123        // Invalidate bootstrap token
124        self.token = None;
125
126        tracing::info!(
127            username = %admin.username,
128            "Initial admin user created via bootstrap"
129        );
130
131        Ok(admin)
132    }
133
134    /// Get setup status.
135    #[must_use]
136    pub fn status(&self, user_store: &UserStore, base_url: Option<&str>) -> SetupStatus {
137        let initialized = !user_store.is_empty();
138        let bootstrap_active = self
139            .token
140            .as_ref()
141            .is_some_and(|t| Instant::now() < t.expires_at);
142
143        let setup_url = if !initialized && bootstrap_active {
144            self.token
145                .as_ref()
146                .and_then(|t| base_url.map(|url| format!("{url}/setup?token={}", t.value)))
147        } else {
148            None
149        };
150
151        SetupStatus {
152            initialized,
153            user_count: user_store.count(),
154            bootstrap_active,
155            setup_url,
156        }
157    }
158
159    /// Check if bootstrap is required (no users exist).
160    #[must_use]
161    pub fn is_required(user_store: &UserStore) -> bool {
162        user_store.is_empty()
163    }
164
165    /// Get time remaining on bootstrap token (if any).
166    #[must_use]
167    pub fn token_time_remaining(&self) -> Option<Duration> {
168        self.token.as_ref().and_then(|t| {
169            let now = Instant::now();
170            if now < t.expires_at {
171                Some(t.expires_at - now)
172            } else {
173                None
174            }
175        })
176    }
177
178    /// Invalidate the current bootstrap token.
179    pub fn invalidate_token(&mut self) {
180        self.token = None;
181    }
182
183    /// Generate a secure random token.
184    fn generate_token() -> String {
185        use rand::RngCore;
186        let mut bytes = [0u8; 48];
187        rand::thread_rng().fill_bytes(&mut bytes);
188        // Use URL-safe base64 encoding
189        base64_url_encode(&bytes)
190    }
191
192    /// Print bootstrap information to console.
193    pub fn print_bootstrap_info(&self, base_url: &str) {
194        if let Some(ref token) = self.token {
195            let remaining = self.token_time_remaining().unwrap_or_default();
196            let minutes = remaining.as_secs() / 60;
197
198            println!();
199            println!("┌─────────────────────────────────────────────────────────┐");
200            println!("│  OpenClaw Gateway Started                                │");
201            println!("│                                                          │");
202            println!("│  No admin user configured. Complete setup at:            │");
203            println!("│  {}/setup?token={}...", base_url, &token.value[..16]);
204            println!("│                                                          │");
205            println!("│  Or via CLI:                                             │");
206            println!("│  openclaw admin create --username admin --password ...   │");
207            println!("│                                                          │");
208            println!("│  Bootstrap token expires in {minutes} minutes.                  │");
209            println!("└─────────────────────────────────────────────────────────┘");
210            println!();
211        }
212    }
213}
214
215impl Default for BootstrapManager {
216    fn default() -> Self {
217        Self::new()
218    }
219}
220
221/// Auto-setup from environment variables.
222///
223/// Checks for `OPENCLAW_ADMIN_USERNAME` and `OPENCLAW_ADMIN_PASSWORD` env vars
224/// and creates admin user if both are set and no users exist.
225///
226/// # Errors
227///
228/// Returns error if user creation fails.
229pub fn auto_setup_from_env(user_store: &UserStore) -> Result<Option<User>, AuthError> {
230    // Only auto-setup if no users exist
231    if !user_store.is_empty() {
232        return Ok(None);
233    }
234
235    let username = match std::env::var("OPENCLAW_ADMIN_USERNAME") {
236        Ok(u) if !u.is_empty() => u,
237        _ => return Ok(None),
238    };
239
240    let password = match std::env::var("OPENCLAW_ADMIN_PASSWORD") {
241        Ok(p) if !p.is_empty() => p,
242        _ => return Ok(None),
243    };
244
245    let admin = User::new(&username, &password, UserRole::Admin)?;
246    user_store.create(&admin)?;
247
248    tracing::info!(
249        username = %admin.username,
250        "Admin user created from environment variables"
251    );
252
253    Ok(Some(admin))
254}
255
256/// Generate a secure random password.
257#[must_use]
258pub fn generate_password(length: usize) -> String {
259    use rand::RngCore;
260    const CHARSET: &[u8] =
261        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
262    let mut rng = rand::thread_rng();
263
264    (0..length)
265        .map(|_| {
266            let idx = (rng.next_u32() as usize) % CHARSET.len();
267            CHARSET[idx] as char
268        })
269        .collect()
270}
271
272/// URL-safe base64 encoding without padding.
273fn base64_url_encode(data: &[u8]) -> String {
274    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
275
276    let mut result = String::with_capacity((data.len() * 4).div_ceil(3));
277
278    for chunk in data.chunks(3) {
279        let b0 = chunk[0] as usize;
280        let b1 = chunk.get(1).copied().unwrap_or(0) as usize;
281        let b2 = chunk.get(2).copied().unwrap_or(0) as usize;
282
283        result.push(ALPHABET[b0 >> 2] as char);
284        result.push(ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)] as char);
285
286        if chunk.len() > 1 {
287            result.push(ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)] as char);
288        }
289        if chunk.len() > 2 {
290            result.push(ALPHABET[b2 & 0x3f] as char);
291        }
292    }
293
294    result
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use tempfile::TempDir;
301
302    #[test]
303    fn test_bootstrap_manager_new() {
304        let manager = BootstrapManager::new();
305        assert!(manager.token.is_none());
306    }
307
308    #[test]
309    fn test_generate_token() {
310        let token1 = BootstrapManager::generate_token();
311        let token2 = BootstrapManager::generate_token();
312
313        assert!(!token1.is_empty());
314        assert_ne!(token1, token2);
315        // URL-safe base64 should not contain + or /
316        assert!(!token1.contains('+'));
317        assert!(!token1.contains('/'));
318    }
319
320    #[test]
321    fn test_check_and_generate() {
322        let temp_dir = TempDir::new().unwrap();
323        let store = UserStore::open(temp_dir.path()).unwrap();
324        let mut manager = BootstrapManager::new();
325
326        // Should generate token when no users
327        let token = manager.check_and_generate(&store);
328        assert!(token.is_some());
329
330        // Should return same token on second call
331        let token2 = manager.check_and_generate(&store);
332        assert_eq!(token, token2);
333    }
334
335    #[test]
336    fn test_no_bootstrap_when_users_exist() {
337        let temp_dir = TempDir::new().unwrap();
338        let store = UserStore::open(temp_dir.path()).unwrap();
339
340        // Create a user
341        let user = User::new("admin", "password", UserRole::Admin).unwrap();
342        store.create(&user).unwrap();
343
344        let mut manager = BootstrapManager::new();
345        let token = manager.check_and_generate(&store);
346        assert!(token.is_none());
347    }
348
349    #[test]
350    fn test_complete_setup() {
351        let temp_dir = TempDir::new().unwrap();
352        let store = UserStore::open(temp_dir.path()).unwrap();
353        let mut manager = BootstrapManager::new();
354
355        let token = manager.check_and_generate(&store).unwrap();
356
357        let admin = manager
358            .complete_setup(&store, &token, "admin", "secret123", None)
359            .unwrap();
360
361        assert_eq!(admin.username, "admin");
362        assert_eq!(admin.role, UserRole::Admin);
363        assert!(!store.is_empty());
364
365        // Token should be invalidated
366        assert!(manager.token.is_none());
367    }
368
369    #[test]
370    fn test_invalid_bootstrap_token() {
371        let temp_dir = TempDir::new().unwrap();
372        let store = UserStore::open(temp_dir.path()).unwrap();
373        let mut manager = BootstrapManager::new();
374
375        let _token = manager.check_and_generate(&store).unwrap();
376
377        let result = manager.complete_setup(&store, "wrong_token", "admin", "secret", None);
378        assert!(matches!(result, Err(AuthError::InvalidBootstrapToken)));
379    }
380
381    #[test]
382    fn test_setup_status() {
383        let temp_dir = TempDir::new().unwrap();
384        let store = UserStore::open(temp_dir.path()).unwrap();
385        let mut manager = BootstrapManager::new();
386
387        // Before bootstrap
388        let status = manager.status(&store, Some("http://localhost:18789"));
389        assert!(!status.initialized);
390        assert!(!status.bootstrap_active);
391
392        // Generate token
393        manager.check_and_generate(&store);
394        let status = manager.status(&store, Some("http://localhost:18789"));
395        assert!(!status.initialized);
396        assert!(status.bootstrap_active);
397        assert!(status.setup_url.is_some());
398    }
399
400    #[test]
401    fn test_generate_password() {
402        let pwd1 = generate_password(16);
403        let pwd2 = generate_password(16);
404
405        assert_eq!(pwd1.len(), 16);
406        assert_ne!(pwd1, pwd2);
407    }
408}