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::{DateTime, 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;
14use uuid::Uuid;
15
16pub fn run(email: String) {
17    dotenvy::dotenv().ok();
18
19    let database_url = match env::var("DATABASE_URL") {
20        Ok(url) => url,
21        Err(_) => {
22            eprintln!(
23                "{} DATABASE_URL not set in .env",
24                style("Error:").red().bold()
25            );
26            process::exit(1);
27        }
28    };
29
30    let app_url = env::var("APP_URL").unwrap_or_else(|_| "http://localhost:8080".into());
31
32    let rt = tokio::runtime::Runtime::new().unwrap();
33    rt.block_on(async {
34        match generate_link(&database_url, &app_url, &email).await {
35            Ok(url) => {
36                println!("{} {}", style("Magic link for").dim(), style(&email).cyan());
37                println!("{url}");
38                println!("{}", style("Expires in 15 minutes.").dim());
39            }
40            Err(e) => {
41                eprintln!("{} {}", style("Error:").red().bold(), e);
42                process::exit(1);
43            }
44        }
45    });
46}
47
48async fn generate_link(database_url: &str, app_url: &str, email: &str) -> Result<String, String> {
49    let backend = if database_url.starts_with("sqlite") {
50        DbBackend::Sqlite
51    } else {
52        DbBackend::Postgres
53    };
54
55    let db = Database::connect(database_url)
56        .await
57        .map_err(|e| format!("Cannot connect to database: {e}"))?;
58
59    let select_sql = match backend {
60        DbBackend::Postgres => "SELECT id FROM users WHERE email = $1",
61        _ => "SELECT id FROM users WHERE email = ?",
62    };
63
64    let row = db
65        .query_one(Statement::from_sql_and_values(
66            backend,
67            select_sql,
68            [Value::String(Some(Box::new(email.to_string())))],
69        ))
70        .await
71        .map_err(|e| format!("Query failed: {e}"))?;
72
73    let user_id: i64 = match row {
74        Some(r) => r
75            .try_get_by_index::<i64>(0)
76            .map_err(|e| format!("Failed to read user id: {e}"))?,
77        None => return Err(format!("No user found with email: {email}")),
78    };
79
80    // 32-byte random token encoded as hex (matches the application auth controller).
81    let mut raw = [0u8; 32];
82    rand::thread_rng().fill_bytes(&mut raw);
83    let token_raw: String = raw.iter().map(|b| format!("{b:02x}")).collect();
84
85    // SHA256 of the hex string stored as binary blob.
86    let token_hash: Vec<u8> = Sha256::digest(token_raw.as_bytes()).to_vec();
87
88    let id: Uuid = Uuid::new_v4();
89    let now: DateTime<Utc> = Utc::now();
90    let expires_at: DateTime<Utc> = now + Duration::minutes(15);
91
92    // Bind native typed `Value` variants. SeaORM's Postgres driver maps these
93    // to `uuid` / `timestamptz` directly; the SQLite driver stores them as
94    // text/blob via the same code path. No SQL casts needed.
95    let insert_sql = match backend {
96        DbBackend::Postgres => {
97            "INSERT INTO magic_link_tokens (id, user_id, token_hash, expires_at, created_at) \
98             VALUES ($1, $2, $3, $4, $5)"
99        }
100        _ => {
101            "INSERT INTO magic_link_tokens (id, user_id, token_hash, expires_at, created_at) \
102             VALUES (?, ?, ?, ?, ?)"
103        }
104    };
105
106    db.execute(Statement::from_sql_and_values(
107        backend,
108        insert_sql,
109        [
110            Value::Uuid(Some(Box::new(id))),
111            Value::BigInt(Some(user_id)),
112            Value::Bytes(Some(Box::new(token_hash))),
113            Value::ChronoDateTimeUtc(Some(Box::new(expires_at))),
114            Value::ChronoDateTimeUtc(Some(Box::new(now))),
115        ],
116    ))
117    .await
118    .map_err(|e| format!("Failed to insert token: {e}"))?;
119
120    let base = app_url.trim_end_matches('/');
121    Ok(format!("{base}/auth/verify?token={token_raw}"))
122}