Skip to main content

ferro_cli/commands/
auth_link.rs

1//! auth:link command — generate a magic-link login URL for a given email address.
2//!
3//! Inserts a fresh token directly into the database (bypassing email delivery),
4//! then prints the full /auth/verify URL. Useful for admin impersonation and
5//! local development.
6
7use chrono::{Duration, Utc};
8use console::style;
9use rand::RngCore;
10use sea_orm::{ConnectionTrait, Database, DbBackend, Statement, Value};
11use sha2::{Digest, Sha256};
12use std::env;
13use std::process;
14
15pub fn run(email: String) {
16    dotenvy::dotenv().ok();
17
18    let database_url = match env::var("DATABASE_URL") {
19        Ok(url) => url,
20        Err(_) => {
21            eprintln!(
22                "{} DATABASE_URL not set in .env",
23                style("Error:").red().bold()
24            );
25            process::exit(1);
26        }
27    };
28
29    let app_url = env::var("APP_URL").unwrap_or_else(|_| "http://localhost:8080".into());
30
31    let rt = tokio::runtime::Runtime::new().unwrap();
32    rt.block_on(async {
33        match generate_link(&database_url, &app_url, &email).await {
34            Ok(url) => {
35                println!("{} {}", style("Magic link for").dim(), style(&email).cyan());
36                println!("{url}");
37                println!("{}", style("Expires in 15 minutes.").dim());
38            }
39            Err(e) => {
40                eprintln!("{} {}", style("Error:").red().bold(), e);
41                process::exit(1);
42            }
43        }
44    });
45}
46
47async fn generate_link(database_url: &str, app_url: &str, email: &str) -> Result<String, String> {
48    let backend = if database_url.starts_with("sqlite") {
49        DbBackend::Sqlite
50    } else {
51        DbBackend::Postgres
52    };
53
54    let db = Database::connect(database_url)
55        .await
56        .map_err(|e| format!("Cannot connect to database: {e}"))?;
57
58    let row = db
59        .query_one(Statement::from_sql_and_values(
60            backend,
61            "SELECT id FROM users WHERE email = ?",
62            [Value::String(Some(Box::new(email.to_string())))],
63        ))
64        .await
65        .map_err(|e| format!("Query failed: {e}"))?;
66
67    let user_id: i64 = match row {
68        Some(r) => r
69            .try_get_by_index::<i64>(0)
70            .map_err(|e| format!("Failed to read user id: {e}"))?,
71        None => return Err(format!("No user found with email: {email}")),
72    };
73
74    // 32-byte random token encoded as hex (matches the application auth controller)
75    let mut raw = [0u8; 32];
76    rand::thread_rng().fill_bytes(&mut raw);
77    let token_raw: String = raw.iter().map(|b| format!("{b:02x}")).collect();
78
79    // SHA256 of the hex string stored as binary blob
80    let token_hash: Vec<u8> = Sha256::digest(token_raw.as_bytes()).to_vec();
81
82    // UUID v4 from random bytes
83    let mut uid = [0u8; 16];
84    rand::thread_rng().fill_bytes(&mut uid);
85    uid[6] = (uid[6] & 0x0f) | 0x40;
86    uid[8] = (uid[8] & 0x3f) | 0x80;
87    let uuid = format!(
88        "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
89        u32::from_be_bytes(uid[0..4].try_into().unwrap()),
90        u16::from_be_bytes(uid[4..6].try_into().unwrap()),
91        u16::from_be_bytes(uid[6..8].try_into().unwrap()),
92        u16::from_be_bytes(uid[8..10].try_into().unwrap()),
93        {
94            let mut b = [0u8; 8];
95            b[2..8].copy_from_slice(&uid[10..16]);
96            u64::from_be_bytes(b)
97        }
98    );
99
100    let now = Utc::now();
101    let expires_at = now + Duration::minutes(15);
102
103    db.execute(Statement::from_sql_and_values(
104        backend,
105        "INSERT INTO magic_link_tokens (id, user_id, token_hash, expires_at, created_at) \
106         VALUES (?, ?, ?, ?, ?)",
107        [
108            Value::String(Some(Box::new(uuid))),
109            Value::BigInt(Some(user_id)),
110            Value::Bytes(Some(Box::new(token_hash))),
111            Value::String(Some(Box::new(
112                expires_at.format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string(),
113            ))),
114            Value::String(Some(Box::new(
115                now.format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string(),
116            ))),
117        ],
118    ))
119    .await
120    .map_err(|e| format!("Failed to insert token: {e}"))?;
121
122    let base = app_url.trim_end_matches('/');
123    Ok(format!("{base}/auth/verify?token={token_raw}"))
124}