AkurAI Build
Menu

Public repository / e20fdc20ba73ed38f462643569dcb30aa8cb2187

AkurAI-Drive / backend/src/device.rs

Latest change feb03c4c2e9a6b0e869a63e52329c938e68dc8e8 - backend: device-token auth, SigV4 presigning, and file API for the sync client by olibuijr

use axum::http::{HeaderMap, header};

use crate::db::with_db;

/// ~365 days. Device tokens are long-lived so a CLI logs in once per machine.
const DEVICE_TOKEN_TTL_SECS: i64 = 365 * 24 * 60 * 60;

/// The identity a Bearer device token resolves to.
pub struct DeviceUser {
    pub sub: String,
    pub email: String,
}

/// Mint and persist a new device token for `sub`/`email`; returns the opaque token
/// (to hand back to the CLI via the loopback redirect).
pub fn create_device_token(sub: &str, email: &str) -> String {
    let id = crate::util::token();
    let token = crate::util::token();
    let now = chrono::Utc::now().timestamp();
    let expires = now + DEVICE_TOKEN_TTL_SECS;

    with_db(|conn| {
        conn.execute(
            "INSERT INTO device_tokens (id, token, sub, email, created, expires, last_used)
             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
            rusqlite::params![id, token, sub, email, now, expires, now],
        )
        .expect("failed to insert device token");
    });

    token
}

/// Resolve an `Authorization: Bearer <token>` header to a user. Looks up a
/// non-expired `device_tokens` row, bumps `last_used`, and yields the identity.
pub fn lookup_bearer(headers: &HeaderMap) -> Option<DeviceUser> {
    let raw = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
    let token = raw
        .strip_prefix("Bearer ")
        .or_else(|| raw.strip_prefix("bearer "))?
        .trim();
    if token.is_empty() {
        return None;
    }
    let now = chrono::Utc::now().timestamp();

    with_db(|conn| {
        let user = conn
            .query_row(
                "SELECT sub, email FROM device_tokens WHERE token = ?1 AND expires > ?2",
                rusqlite::params![token, now],
                |row| {
                    Ok(DeviceUser {
                        sub: row.get(0)?,
                        email: row.get(1)?,
                    })
                },
            )
            .ok()?;

        conn.execute(
            "UPDATE device_tokens SET last_used = ?1 WHERE token = ?2",
            rusqlite::params![now, token],
        )
        .ok();

        Some(user)
    })
}

/// Whether `url` is a loopback callback URL the CLI may receive its token at:
/// `http://127.0.0.1[:port][/path]` or `http://localhost[:port][/path]`. Anything
/// else (https, other hosts, host-spoof tricks like `127.0.0.1.evil.com`) is rejected.
pub fn is_loopback_redirect(url: &str) -> bool {
    let rest = match url.strip_prefix("http://") {
        Some(r) => r,
        None => return false,
    };
    // Authority = everything before the first '/' or '?'.
    let authority = rest.split(['/', '?']).next().unwrap_or("");
    // Strip an optional ":port"; reject any userinfo ('@').
    if authority.contains('@') {
        return false;
    }
    let host = authority.split(':').next().unwrap_or("");
    host == "127.0.0.1" || host == "localhost"
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn accepts_loopback_forms() {
        assert!(is_loopback_redirect("http://127.0.0.1:53123"));
        assert!(is_loopback_redirect("http://127.0.0.1:53123/callback"));
        assert!(is_loopback_redirect("http://localhost:8080"));
        assert!(is_loopback_redirect("http://localhost"));
        assert!(is_loopback_redirect("http://127.0.0.1/cb?foo=bar"));
    }

    #[test]
    fn rejects_non_loopback() {
        assert!(!is_loopback_redirect("https://127.0.0.1:53123")); // not http
        assert!(!is_loopback_redirect("http://evil.com"));
        assert!(!is_loopback_redirect("http://127.0.0.1.evil.com")); // host spoof
        assert!(!is_loopback_redirect("http://localhost@evil.com")); // userinfo spoof
        assert!(!is_loopback_redirect("http://10.0.0.1:53123"));
        assert!(!is_loopback_redirect("ftp://localhost"));
        assert!(!is_loopback_redirect(""));
    }
}