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(""));
}
}