summaryrefslogtreecommitdiff
path: root/src/api/auth/device.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/auth/device.rs')
-rw-r--r--src/api/auth/device.rs455
1 files changed, 455 insertions, 0 deletions
diff --git a/src/api/auth/device.rs b/src/api/auth/device.rs
new file mode 100644
index 0000000..2b05e12
--- /dev/null
+++ b/src/api/auth/device.rs
@@ -0,0 +1,455 @@
+use std::time::Duration;
+use std::{collections::HashMap, sync::Arc};
+
+use chrono::{DateTime, Utc};
+use futures::future::join_all;
+use rocket::{serde::json::Json, State};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::api::auth::{WithSession, WithVerifiedFxaLogin, WithVerifiedSession};
+use crate::api::{Empty, EMPTY};
+use crate::db::DbConn;
+use crate::push::PushClient;
+use crate::utils::DeferAction;
+use crate::{
+ api::{auth, serialize_dt_opt},
+ auth::Authenticated,
+ db::Db,
+ types::{
+ Device, DeviceCommand, DeviceCommands, DeviceID, DevicePush, DeviceUpdate, OauthTokenID,
+ SessionID,
+ },
+};
+
+fn map_error(e: sqlx::Error) -> auth::Error {
+ match &e {
+ // not-null violations can presumably only be caused by bad parameters
+ sqlx::Error::Database(de) if de.code().as_deref() == Some("23502") => {
+ auth::Error::MissingParameter
+ },
+ sqlx::Error::RowNotFound => auth::Error::UnknownDevice,
+ _ => auth::Error::Other(anyhow!(e)),
+ }
+}
+
+#[derive(Debug, Serialize, Deserialize, PartialEq)]
+#[allow(non_snake_case)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Info {
+ isCurrentDevice: bool,
+ id: DeviceID,
+ lastAccessTime: i64,
+ name: String,
+ r#type: String,
+ pushCallback: Option<String>,
+ pushPublicKey: Option<String>,
+ pushAuthKey: Option<String>,
+ pushEndpointExpired: bool,
+ availableCommands: HashMap<String, String>,
+ // NOTE location is optional per the spec, but fenix crashes if it isn't present
+ location: Value,
+ // MISSING lastAccessTimeFormatted
+ // MISSING approximateLastAccessTime
+ // MISSING approximateLastAccessTimeFormatted
+}
+
+fn device_to_json(current: Option<&DeviceID>, dev: Device) -> Info {
+ let (pcb, ppk, pak) = match dev.push {
+ Some(p) => (Some(p.callback), Some(p.public_key), Some(p.auth_key)),
+ None => (None, None, None),
+ };
+ Info {
+ isCurrentDevice: Some(&dev.device_id) == current,
+ id: dev.device_id,
+ lastAccessTime: dev.last_active.timestamp(),
+ name: dev.name,
+ r#type: dev.type_,
+ pushCallback: pcb,
+ pushPublicKey: ppk,
+ pushAuthKey: pak,
+ pushEndpointExpired: dev.push_expired,
+ availableCommands: dev.available_commands.into_map(),
+ location: dev.location,
+ }
+}
+
+#[derive(Serialize, Deserialize, PartialEq)]
+#[serde(transparent)]
+pub(crate) struct ListResp(Vec<Info>);
+
+#[get("/account/devices")]
+pub(crate) async fn devices(
+ db: &DbConn,
+ auth: Authenticated<(), WithVerifiedSession>,
+) -> auth::Result<ListResp> {
+ let devs = db.get_devices(&auth.context.uid).await?;
+ Ok(Json(ListResp(
+ devs.into_iter().map(|dev| device_to_json(auth.context.device_id.as_ref(), dev)).collect(),
+ )))
+}
+
+#[derive(Debug, Deserialize)]
+#[allow(non_snake_case)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct DeviceReq {
+ id: Option<DeviceID>,
+ name: Option<String>,
+ r#type: Option<String>,
+ pushCallback: Option<String>,
+ pushPublicKey: Option<String>,
+ pushAuthKey: Option<String>,
+ availableCommands: Option<HashMap<String, String>>,
+ // present for legacy reasons, ignored
+ #[allow(dead_code)]
+ capabilities: Option<Vec<String>>,
+ location: Option<Value>,
+}
+
+#[post("/account/device", data = "<data>")]
+pub(crate) async fn device(
+ db: &DbConn,
+ db_pool: &Db,
+ defer: &DeferAction,
+ client: &State<Arc<PushClient>>,
+ // need to allow registrations to all sessions, otherwise the "now verified"
+ // notification can't be sent
+ data: Authenticated<DeviceReq, WithSession>,
+) -> auth::Result<Info> {
+ let dev = data.body;
+ if let (None, None, None) = (&dev.name, &dev.r#type, &dev.pushCallback) {
+ return Err(auth::Error::MissingParameter);
+ }
+
+ let push = dev.pushCallback.map(|pcb| DevicePush {
+ callback: pcb,
+ public_key: dev.pushPublicKey.unwrap_or_default(),
+ auth_key: dev.pushAuthKey.unwrap_or_default(),
+ });
+
+ let (own_id, changed_id, notify) = match (dev.id, data.context.device_id) {
+ (None, None) => {
+ let new = DeviceID::random();
+ (Some(new.clone()), new, true)
+ },
+ (None, Some(own)) => (Some(own.clone()), own, false),
+ (Some(other), own) => (own, other, false),
+ };
+ let result = db
+ .change_device(
+ &data.context.uid,
+ &changed_id,
+ DeviceUpdate {
+ name: dev.name.as_ref().map(AsRef::as_ref),
+ type_: dev.r#type.as_ref().map(AsRef::as_ref),
+ push,
+ available_commands: dev.availableCommands.map(DeviceCommands),
+ location: dev.location,
+ },
+ )
+ .await
+ .map_err(map_error)?;
+ if notify {
+ db.set_session_device(&data.session, Some(&changed_id)).await?;
+ match db.get_devices(&data.context.uid).await {
+ Err(e) => warn!("device_connected push failed: {e}"),
+ Ok(mut devs) => defer.spawn_after_success("api::auth/account/device(post)", {
+ devs.retain(|d| d.device_id != changed_id);
+ let (client, db) = (Arc::clone(client), db_pool.clone());
+ let name = result.name.clone();
+ async move {
+ let db = db.begin().await?;
+ client.device_connected(&db, &devs, &name).await;
+ db.commit().await?;
+ Ok(())
+ }
+ }),
+ };
+ }
+ Ok(Json(device_to_json(own_id.as_ref(), result)))
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Command {
+ target: DeviceID,
+ command: String,
+ payload: Value,
+ ttl: Option<u32>,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[allow(non_snake_case)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct InvokeResp {
+ enqueued: bool,
+ notified: bool,
+ notifyError: Option<String>,
+}
+
+// NOTE fenix doesn't register a push callback for some reason, so receiving tabs
+// always requires opening the tab share menu or tab list first.
+#[post("/account/devices/invoke_command", data = "<cmd>")]
+pub(crate) async fn invoke(
+ client: &State<Arc<PushClient>>,
+ db: &DbConn,
+ cmd: Authenticated<Command, WithVerifiedSession>,
+) -> auth::Result<InvokeResp> {
+ let sender = cmd.context.device_id;
+ let dev = db.get_device(&cmd.context.uid, &cmd.body.target).await.map_err(map_error)?;
+ if dev.available_commands.get(&cmd.body.command).is_none() {
+ return Err(auth::Error::NoDeviceCommand);
+ }
+ let ttl = cmd.body.ttl.unwrap_or(30 * 86400).clamp(60, 30 * 86400);
+ let idx = db
+ .enqueue_command(&cmd.body.target, &sender, &cmd.body.command, &cmd.body.payload, ttl)
+ .await?;
+ let (notified, error) = client
+ .command_received(db, &dev, &cmd.body.command, idx, &sender)
+ .await
+ .map_or_else(|e| (false, Some(e.to_string())), |_| (true, None));
+ Ok(Json(InvokeResp { enqueued: true, notified, notifyError: error }))
+}
+
+#[derive(Debug, Serialize, Deserialize, PartialEq)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct CommandData {
+ command: String,
+ payload: Value,
+ sender: Option<String>,
+}
+
+#[derive(Debug, Serialize, Deserialize, PartialEq)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct CommandsEntry {
+ index: i64,
+ data: CommandData,
+}
+
+#[derive(Debug, Serialize, Deserialize, PartialEq)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct CommandsResp {
+ index: i64,
+ last: bool,
+ messages: Vec<CommandsEntry>,
+}
+
+fn map_command(c: DeviceCommand) -> CommandsEntry {
+ CommandsEntry {
+ index: c.index,
+ data: CommandData { command: c.command, payload: c.payload, sender: c.sender },
+ }
+}
+
+#[get("/account/device/commands?<index>&<limit>")]
+pub(crate) async fn commands(
+ db: &DbConn,
+ index: i64,
+ limit: Option<i64>,
+ auth: Authenticated<(), WithVerifiedSession>,
+) -> auth::Result<CommandsResp> {
+ let dev = auth.context.device_id.as_ref().ok_or(auth::Error::UnknownDevice)?;
+ let (more, cmds) =
+ db.get_commands(&auth.context.uid, dev, index, limit.unwrap_or(100).clamp(0, 100)).await?;
+ Ok(Json(CommandsResp {
+ index: cmds.iter().map(|c| c.index).max().unwrap_or(0),
+ last: !more,
+ messages: cmds.into_iter().map(map_command).collect(),
+ }))
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct DestroyReq {
+ id: DeviceID,
+}
+
+#[post("/account/device/destroy", data = "<req>")]
+pub(crate) async fn destroy(
+ db: &DbConn,
+ db_pool: &Db,
+ defer: &DeferAction,
+ client: &State<Arc<PushClient>>,
+ req: crate::auth::Authenticated<DestroyReq, WithVerifiedSession>,
+) -> auth::Result<Empty> {
+ db.delete_device(&req.context.uid, &req.body.id).await.map_err(map_error)?;
+ match db.get_devices(&req.context.uid).await {
+ Err(e) => warn!("device_disconnected push failed: {e}"),
+ Ok(devs) => defer.spawn_after_success("api::auth/account/device/destroy(post)", {
+ let (client, db) = (Arc::clone(client), db_pool.clone());
+ async move {
+ let db = db.begin().await?;
+ client.device_disconnected(&db, &devs, &req.body.id).await;
+ db.commit().await?;
+ Ok(())
+ }
+ }),
+ };
+ Ok(EMPTY)
+}
+
+#[derive(Debug, Deserialize)]
+pub(crate) enum NotifyTarget {
+ #[serde(rename = "all")]
+ All,
+}
+
+#[derive(Debug, Deserialize)]
+pub(crate) enum NotifyEPAction {
+ #[serde(rename = "accountVerify")]
+ AccountVerify,
+}
+
+#[derive(Debug, Deserialize)]
+#[allow(non_snake_case)]
+#[serde(untagged, deny_unknown_fields)]
+pub(crate) enum NotifyReq {
+ // deny_unknown_fields and flatten don't work together
+ All {
+ #[allow(dead_code)]
+ to: NotifyTarget,
+ _endpointAction: Option<NotifyEPAction>,
+ excluded: Option<Vec<DeviceID>>,
+ payload: Value,
+ TTL: Option<u32>,
+ },
+ Some {
+ to: Vec<DeviceID>,
+ _endpointAction: Option<NotifyEPAction>,
+ payload: Value,
+ TTL: Option<u32>,
+ },
+}
+
+#[post("/account/devices/notify", data = "<req>")]
+pub(crate) async fn notify(
+ db: &DbConn,
+ client: &State<Arc<PushClient>>,
+ req: Authenticated<NotifyReq, WithVerifiedSession>,
+) -> auth::Result<Empty> {
+ let (to, payload, ttl) = match req.body {
+ NotifyReq::All { excluded, payload, TTL: ttl, .. } => {
+ let excluded = excluded.unwrap_or_default();
+ let mut devs = db.get_devices(&req.context.uid).await?;
+ devs.retain(|d| !excluded.contains(&d.device_id));
+ (devs, payload, ttl)
+ },
+ NotifyReq::Some { to, payload, TTL: ttl, .. } => {
+ let to = join_all(to.iter().map(|id| db.get_device(&req.context.uid, id)))
+ .await
+ .into_iter()
+ .collect::<Result<Vec<_>, _>>()?;
+ (to, payload, ttl)
+ },
+ };
+ client.push_any(db, &to, Duration::from_secs(ttl.unwrap_or(0).into()), payload).await;
+ Ok(EMPTY)
+}
+
+#[derive(Debug, Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct AttachedClient {
+ clientId: Option<String>,
+ deviceId: Option<DeviceID>,
+ sessionTokenId: Option<SessionID>,
+ refreshTokenId: Option<OauthTokenID>,
+ isCurrentSession: bool,
+ deviceType: Option<String>,
+ name: Option<String>,
+ #[serde(serialize_with = "serialize_dt_opt")]
+ createdTime: Option<DateTime<Utc>>,
+ // MISSING createdTimeFormatted
+ #[serde(serialize_with = "serialize_dt_opt")]
+ lastAccessTime: Option<DateTime<Utc>>,
+ // MISSING lastAccessTimeFormatted
+ // MISSING approximateLastAccessTime
+ // MISSING approximateLastAccessTimeFormatted
+ scope: Option<String>,
+ // MISSING location
+ // MISSING userAgent
+ // MISSING os
+}
+
+// MISSING filterIdleDevicesTimestamp
+#[get("/account/attached_clients")]
+pub(crate) async fn attached_clients(
+ db: &DbConn,
+ auth: Authenticated<(), WithVerifiedFxaLogin>,
+) -> auth::Result<Vec<AttachedClient>> {
+ let clients = db.get_attached_clients(&auth.context.uid).await?;
+ Ok(Json(
+ clients
+ .into_iter()
+ .map(|dev| AttachedClient {
+ clientId: dev.client_id,
+ deviceId: dev.device_id,
+ refreshTokenId: dev.refresh_token_id,
+ isCurrentSession: dev.session_token_id.as_ref() == Some(&auth.session),
+ sessionTokenId: dev.session_token_id,
+ deviceType: dev.device_type,
+ name: dev.name,
+ createdTime: dev.created_time,
+ lastAccessTime: dev.last_access_time,
+ scope: dev.scope,
+ })
+ .collect::<Vec<_>>(),
+ ))
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct DestroyAttachedClientReq {
+ // NOTE should be used to verify token deletion, but since we allow only a fixed
+ // number of clients that makes little sense.
+ #[allow(dead_code)]
+ clientId: Option<String>,
+ sessionTokenId: Option<SessionID>,
+ refreshTokenId: Option<OauthTokenID>,
+ deviceId: Option<DeviceID>,
+}
+
+#[post("/account/attached_client/destroy", data = "<req>")]
+pub(crate) async fn destroy_attached_client(
+ db: &DbConn,
+ db_pool: &Db,
+ defer: &DeferAction,
+ client: &State<Arc<PushClient>>,
+ req: Authenticated<DestroyAttachedClientReq, WithVerifiedFxaLogin>,
+) -> auth::Result<Empty> {
+ // only one id may be given, otherwise deleting things properly is more work.
+ if (req.body.sessionTokenId.is_some() as u32)
+ + (req.body.refreshTokenId.is_some() as u32)
+ + (req.body.deviceId.is_some() as u32)
+ != 1
+ {
+ return Err(auth::Error::InvalidParameter);
+ }
+
+ if let Some(dev) = req.body.deviceId {
+ let devs = db.get_devices(&req.context.uid).await;
+ db.delete_device(&req.context.uid, &dev).await?;
+ match devs {
+ Err(e) => warn!("device_disconnected push failed: {e}"),
+ Ok(devs) => {
+ defer.spawn_after_success("api::auth/account/attached_client/destroy(post)", {
+ let (client, db) = (Arc::clone(client), db_pool.clone());
+ async move {
+ let db = db.begin().await?;
+ client.device_disconnected(&db, &devs, &dev).await;
+ db.commit().await?;
+ Ok(())
+ }
+ })
+ },
+ };
+ }
+ if let Some(id) = req.body.sessionTokenId {
+ db.delete_session(&req.context.uid, &id).await?;
+ }
+ if let Some(id) = req.body.refreshTokenId {
+ db.delete_refresh_token(&id).await?;
+ }
+
+ Ok(EMPTY)
+}