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 select_sql = match backend {
59        DbBackend::Postgres => "SELECT id FROM users WHERE email = $1",
60        _ => "SELECT id FROM users WHERE email = ?",
61    };
62
63    let row = db
64        .query_one(Statement::from_sql_and_values(
65            backend,
66            select_sql,
67            [Value::String(Some(Box::new(email.to_string())))],
68        ))
69        .await
70        .map_err(|e| format!("Query failed: {e}"))?;
71
72    let user_id: i64 = match row {
73        Some(r) => r
74            .try_get_by_index::<i64>(0)
75            .map_err(|e| format!("Failed to read user id: {e}"))?,
76        None => return Err(format!("No user found with email: {email}")),
77    };
78
79    // 32-byte random token encoded as hex (matches the application auth controller)
80    let mut raw = [0u8; 32];
81    rand::thread_rng().fill_bytes(&mut raw);
82    let token_raw: String = raw.iter().map(|b| format!("{b:02x}")).collect();
83
84    // SHA256 of the hex string stored as binary blob
85    let token_hash: Vec<u8> = Sha256::digest(token_raw.as_bytes()).to_vec();
86
87    // UUID v4 from random bytes
88    let mut uid = [0u8; 16];
89    rand::thread_rng().fill_bytes(&mut uid);
90    uid[6] = (uid[6] & 0x0f) | 0x40;
91    uid[8] = (uid[8] & 0x3f) | 0x80;
92    let uuid = format!(
93        "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
94        u32::from_be_bytes(uid[0..4].try_into().unwrap()),
95        u16::from_be_bytes(uid[4..6].try_into().unwrap()),
96        u16::from_be_bytes(uid[6..8].try_into().unwrap()),
97        u16::from_be_bytes(uid[8..10].try_into().unwrap()),
98        {
99            let mut b = [0u8; 8];
100            b[2..8].copy_from_slice(&uid[10..16]);
101            u64::from_be_bytes(b)
102        }
103    );
104
105    let now = Utc::now();
106    let expires_at = now + Duration::minutes(15);
107
108    let insert_sql = match backend {
109        DbBackend::Postgres => {
110            "INSERT INTO magic_link_tokens (id, user_id, token_hash, expires_at, created_at) \
111             VALUES ($1, $2, $3, $4, $5)"
112        }
113        _ => {
114            "INSERT INTO magic_link_tokens (id, user_id, token_hash, expires_at, created_at) \
115             VALUES (?, ?, ?, ?, ?)"
116        }
117    };
118
119    db.execute(Statement::from_sql_and_values(
120        backend,
121        insert_sql,
122        [
123            Value::String(Some(Box::new(uuid))),
124            Value::BigInt(Some(user_id)),
125            Value::Bytes(Some(Box::new(token_hash))),
126            Value::String(Some(Box::new(
127                expires_at.format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string(),
128            ))),
129            Value::String(Some(Box::new(
130                now.format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string(),
131            ))),
132        ],
133    ))
134    .await
135    .map_err(|e| format!("Failed to insert token: {e}"))?;
136
137    let base = app_url.trim_end_matches('/');
138    Ok(format!("{base}/auth/verify?token={token_raw}"))
139}