summaryrefslogtreecommitdiff
path: root/src/api
diff options
context:
space:
mode:
authorpennae <github@quasiparticle.net>2022-07-13 10:33:30 +0200
committerpennae <github@quasiparticle.net>2022-07-13 13:27:12 +0200
commit2f8dce44d3f2be74b5c6ec0a2e7f4ceced715328 (patch)
treecaff55807c5fc773a36aa773cfde9cd6ebbbb6c8 /src/api
downloadminor-skulk-2f8dce44d3f2be74b5c6ec0a2e7f4ceced715328.tar.gz
minor-skulk-2f8dce44d3f2be74b5c6ec0a2e7f4ceced715328.tar.xz
minor-skulk-2f8dce44d3f2be74b5c6ec0a2e7f4ceced715328.zip
initial import
Diffstat (limited to 'src/api')
-rw-r--r--src/api/auth/account.rs413
-rw-r--r--src/api/auth/device.rs455
-rw-r--r--src/api/auth/email.rs126
-rw-r--r--src/api/auth/invite.rs47
-rw-r--r--src/api/auth/mod.rs238
-rw-r--r--src/api/auth/oauth.rs433
-rw-r--r--src/api/auth/password.rs260
-rw-r--r--src/api/auth/session.rs107
-rw-r--r--src/api/mod.rs32
-rw-r--r--src/api/oauth.rs163
-rw-r--r--src/api/profile/mod.rs324
11 files changed, 2598 insertions, 0 deletions
diff --git a/src/api/auth/account.rs b/src/api/auth/account.rs
new file mode 100644
index 0000000..51dd98e
--- /dev/null
+++ b/src/api/auth/account.rs
@@ -0,0 +1,413 @@
+use std::sync::Arc;
+
+use anyhow::Result;
+use chrono::{DateTime, Utc};
+use password_hash::SaltString;
+use rand::{thread_rng, Rng};
+use rocket::request::FromRequest;
+use rocket::State;
+use rocket::{serde::json::Json, Request};
+use serde::{Deserialize, Serialize};
+use validator::Validate;
+
+use crate::api::{Empty, EMPTY};
+use crate::db::{Db, DbConn};
+use crate::mailer::Mailer;
+use crate::push::PushClient;
+use crate::types::AccountResetID;
+use crate::utils::DeferAction;
+use crate::Config;
+use crate::{
+ api::{auth, serialize_dt},
+ auth::{AuthSource, Authenticated},
+ crypto::{AuthPW, KeyBundle, KeyFetchReq, SecretBytes, SessionCredentials},
+ types::{HawkKey, KeyFetchID, OauthToken, SecretKey, SessionID, User, UserID, VerifyHash},
+};
+
+// TODO better error handling
+
+// MISSING get /account/profile
+// MISSING get /account/status
+// MISSING post /account/status
+// MISSING post /account/reset
+
+#[derive(Deserialize, Debug, Validate)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct Create {
+ #[validate(email, length(min = 3, max = 256))]
+ email: String,
+ authPW: AuthPW,
+ // MISSING service
+ // MISSING redirectTo
+ // MISSING resume
+ // MISSING metricsContext
+ // NOTE we misuse style to communicate an invite token!
+ style: Option<String>,
+ // MISSING verificationMethod
+}
+
+#[derive(Serialize, Debug)]
+#[allow(non_snake_case)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct CreateResp {
+ uid: UserID,
+ sessionToken: SecretBytes<32>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ keyFetchToken: Option<SecretBytes<32>>,
+ #[serde(serialize_with = "serialize_dt")]
+ authAt: DateTime<Utc>,
+ // MISSING verificationMethod
+}
+
+// MISSING arg: service
+#[post("/account/create?<keys>", data = "<data>")]
+pub(crate) async fn create(
+ db: &DbConn,
+ cfg: &State<Config>,
+ mailer: &State<Arc<Mailer>>,
+ keys: Option<bool>,
+ data: Json<Create>,
+) -> auth::Result<CreateResp> {
+ let keys = keys.unwrap_or(false);
+ let data = data.into_inner();
+ data.validate().map_err(|_| auth::Error::InvalidParameter)?;
+
+ if db.user_email_exists(&data.email).await? {
+ return Err(auth::Error::AccountExists);
+ }
+
+ match (cfg.invite_only, data.style) {
+ (false, Some(_)) => return Err(auth::Error::InvalidParameter),
+ (false, None) => (),
+ (true, None) => return Err(auth::Error::InviteOnly),
+ (true, Some(code)) => {
+ db.use_invite_code(&code).await.map_err(|e| match e {
+ sqlx::Error::RowNotFound => auth::Error::InviteNotFound,
+ e => auth::Error::Other(anyhow!(e)),
+ })?;
+ },
+ }
+
+ let ka = SecretBytes::generate();
+ let wrapwrap_kb = SecretBytes::generate();
+ let auth_salt = SaltString::generate(rand::rngs::OsRng);
+ let stretched = data.authPW.stretch(auth_salt.as_salt())?;
+ let verify_hash = stretched.verify_hash();
+ let session_token = SecretBytes::generate();
+ let session = SessionCredentials::derive(&session_token);
+ let key_fetch_token = if keys {
+ let key_fetch_token = SecretBytes::generate();
+ let req = KeyFetchReq::from_token(&key_fetch_token);
+ let wrapped = req.derive_resp().wrap_keys(&KeyBundle {
+ ka: ka.clone(),
+ wrap_kb: stretched.decrypt_wwkb(&wrapwrap_kb),
+ });
+ db.add_key_fetch(KeyFetchID(req.token_id.0), &HawkKey(req.req_hmac_key), &wrapped).await?;
+ Some(key_fetch_token)
+ } else {
+ None
+ };
+ let uid = db
+ .add_user(User {
+ auth_salt,
+ email: data.email.to_owned(),
+ ka: SecretKey(ka),
+ wrapwrap_kb: SecretKey(wrapwrap_kb),
+ verify_hash: VerifyHash(verify_hash),
+ display_name: None,
+ verified: false,
+ })
+ .await?;
+ let session_id = SessionID(session.token_id.0);
+ let auth_at = db
+ .add_session(session_id.clone(), &uid, HawkKey(session.req_hmac_key), false, None)
+ .await?;
+ let verify_code = hex::encode(&SecretBytes::<16>::generate().0);
+ db.add_verify_code(&uid, &session_id, &verify_code).await?;
+ // NOTE we send the email in this context rather than a spawn to signal
+ // send errors to the client.
+ mailer.send_account_verify(&uid, &data.email, &verify_code).await.map_err(|e| {
+ error!("failed to send email: {e}");
+ auth::Error::EmailFailed
+ })?;
+ Ok(Json(CreateResp {
+ uid,
+ sessionToken: session_token,
+ keyFetchToken: key_fetch_token,
+ authAt: auth_at,
+ }))
+}
+
+#[derive(Deserialize, Debug, Validate)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct Login {
+ #[validate(email, length(min = 3, max = 256))]
+ email: String,
+ authPW: AuthPW,
+ // MISSING service
+ // MISSING redirectTo
+ // MISSING resume
+ // MISSING reason
+ // MISSING unblockCode
+ // MISSING originalLoginEmail
+ // MISSING verificationMethod
+}
+
+#[derive(Serialize, Debug)]
+#[allow(non_snake_case)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct LoginResp {
+ uid: UserID,
+ sessionToken: SecretBytes<32>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ keyFetchToken: Option<SecretBytes<32>>,
+ // MISSING verificationMethod
+ // MISSING verificationReason
+ // NOTE this is the *account* verified status, not the session status.
+ // the spec doesn't say.
+ verified: bool,
+ #[serde(serialize_with = "serialize_dt")]
+ authAt: DateTime<Utc>,
+ // MISSING metricsEnabled
+}
+
+// MISSING arg: service
+// MISSING arg: verificationMethod
+#[post("/account/login?<keys>", data = "<data>")]
+pub(crate) async fn login(
+ db: &DbConn,
+ mailer: &State<Arc<Mailer>>,
+ keys: Option<bool>,
+ data: Json<Login>,
+) -> auth::Result<LoginResp> {
+ let keys = keys.unwrap_or(false);
+ let data = data.into_inner();
+ data.validate().map_err(|_| auth::Error::InvalidParameter)?;
+
+ let (uid, user) = db.get_user(&data.email).await.map_err(|_| auth::Error::UnknownAccount)?;
+ if user.email != data.email {
+ return Err(auth::Error::IncorrectEmailCase);
+ }
+ if !user.verified {
+ return Err(auth::Error::UnverifiedAccount);
+ }
+
+ let stretched = data.authPW.stretch(user.auth_salt.as_salt())?;
+ if stretched.verify_hash() != user.verify_hash.0 {
+ return Err(auth::Error::IncorrectPassword);
+ }
+
+ let session_token = SecretBytes::generate();
+ let session = SessionCredentials::derive(&session_token);
+ let key_fetch_token = if keys {
+ let key_fetch_token = SecretBytes::generate();
+ let req = KeyFetchReq::from_token(&key_fetch_token);
+ let wrapped = req.derive_resp().wrap_keys(&KeyBundle {
+ ka: user.ka.0.clone(),
+ wrap_kb: stretched.decrypt_wwkb(&user.wrapwrap_kb.0),
+ });
+ db.add_key_fetch(KeyFetchID(req.token_id.0), &HawkKey(req.req_hmac_key), &wrapped).await?;
+ Some(key_fetch_token)
+ } else {
+ None
+ };
+
+ let session_id = SessionID(session.token_id.0);
+ let verify_code = format!("{:06}", thread_rng().gen_range(0..=999999));
+ let auth_at = db
+ .add_session(
+ session_id.clone(),
+ &uid,
+ HawkKey(session.req_hmac_key),
+ false,
+ Some(&verify_code),
+ )
+ .await?;
+ // NOTE we send the email in this context rather than a spawn to signal
+ // send errors to the client.
+ mailer.send_session_verify(&data.email, &verify_code).await.map_err(|e| {
+ error!("failed to send email: {e}");
+ auth::Error::EmailFailed
+ })?;
+ Ok(Json(LoginResp {
+ uid,
+ sessionToken: session_token,
+ keyFetchToken: key_fetch_token,
+ verified: true,
+ authAt: auth_at,
+ }))
+}
+
+#[derive(Deserialize, Debug, Validate)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct Destroy {
+ #[validate(email, length(min = 3, max = 256))]
+ email: String,
+ authPW: AuthPW,
+}
+
+// TODO may also be authenticated with a verified session
+#[post("/account/destroy", data = "<data>")]
+pub(crate) async fn destroy(
+ db: &DbConn,
+ db_pool: &Db,
+ defer: &DeferAction,
+ pc: &State<Arc<PushClient>>,
+ data: Json<Destroy>,
+) -> auth::Result<Empty> {
+ let data = data.into_inner();
+ data.validate().map_err(|_| auth::Error::InvalidParameter)?;
+
+ let (uid, user) = db.get_user(&data.email).await.map_err(|_| auth::Error::UnknownAccount)?;
+ if user.email != data.email {
+ return Err(auth::Error::IncorrectEmailCase);
+ }
+
+ let stretched = data.authPW.stretch(user.auth_salt.as_salt())?;
+ if stretched.verify_hash() != user.verify_hash.0 {
+ return Err(auth::Error::IncorrectPassword);
+ }
+
+ let devs = db.get_devices(&uid).await;
+ db.delete_user(&data.email).await?;
+ match devs {
+ Ok(devs) => defer.spawn_after_success("api::account/destroy(post)", {
+ let (pc, db) = (Arc::clone(pc), db_pool.clone());
+ async move {
+ let db = db.begin().await?;
+ pc.account_destroyed(&devs, &uid).await;
+ db.commit().await?;
+ Ok(())
+ }
+ }),
+ Err(e) => warn!("account_destroyed push failed: {e}"),
+ }
+
+ Ok(EMPTY)
+}
+
+#[derive(Deserialize, Serialize, Debug)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct KeysResp {
+ bundle: String,
+}
+
+// NOTE the key fetch endpoint must delete a key fetch token from the database
+// once it has identified it, regardless of whether the request succeeds or
+// fails. we'll do this with a single-use auth source that sets the db to always
+// commit. the auth source must not be used for anything else. we can get away
+// with using a request guard because we'll always commit even if the guard
+// fails, but this is only allowable because this is the only handler for the path.
+
+#[derive(Debug)]
+pub(crate) struct WithKeyFetch;
+
+#[async_trait]
+impl AuthSource for WithKeyFetch {
+ type ID = KeyFetchID;
+ type Context = Vec<u8>;
+ async fn hawk(r: &Request<'_>, id: &KeyFetchID) -> Result<(SecretBytes<32>, Self::Context)> {
+ let db = Authenticated::<(), Self>::get_conn(r).await?;
+ db.always_commit().await?;
+ Ok(db.finish_key_fetch(id).await.map(|(h, ks)| (h.0, ks))?)
+ }
+ async fn bearer_token(_: &Request<'_>, _: &OauthToken) -> Result<(KeyFetchID, Self::Context)> {
+ // key fetch tokens are only valid in hawk requests
+ bail!("invalid key fetch authentication")
+ }
+}
+
+#[get("/account/keys")]
+pub(crate) async fn keys(auth: Authenticated<(), WithKeyFetch>) -> Json<KeysResp> {
+ // NOTE contrary to its own api spec fxa does not delete a key fetch if the
+ // associated session is not verified. we don't duplicate this special case
+ // because we control the clients, and requesting keys on an unverified session
+ // can be interpreted as a protocol violation anyway.
+ Json(KeysResp { bundle: hex::encode(&auth.context) })
+}
+
+#[derive(Debug)]
+pub(crate) struct WithResetToken;
+
+#[async_trait]
+impl AuthSource for WithResetToken {
+ type ID = AccountResetID;
+ type Context = UserID;
+ async fn hawk(
+ r: &Request<'_>,
+ id: &AccountResetID,
+ ) -> Result<(SecretBytes<32>, Self::Context)> {
+ // unlike key fetch we'll use a separate transaction here since the body of the
+ // handler can fail.
+ let pool = <&Db as FromRequest>::from_request(r)
+ .await
+ .success_or_else(|| anyhow!("could not open db connection"))?;
+ let db = pool.begin().await?;
+ let result = db.finish_account_reset(id).await.map(|(h, ctx)| (h.0, ctx))?;
+ db.commit().await?;
+ Ok(result)
+ }
+ async fn bearer_token(
+ _: &Request<'_>,
+ _: &OauthToken,
+ ) -> Result<(AccountResetID, Self::Context)> {
+ bail!("invalid password change authentication")
+ }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct AccountResetReq {
+ authPW: AuthPW,
+ // MISSING wrapKb
+ // MISSING recoveryKeyId
+ // MISSING sessionToken
+}
+
+// NOTE resetting an account does not clear active sync data on the storage server,
+// so an account may be reported as disconnected for a while. this is not an error,
+// just an inconvenience we haven't found out how to fix yet.
+
+// MISSING arg: keys
+#[post("/account/reset", data = "<data>")]
+pub(crate) async fn reset(
+ db: &DbConn,
+ mailer: &State<Arc<Mailer>>,
+ client: &State<Arc<PushClient>>,
+ defer: &DeferAction,
+ data: Authenticated<AccountResetReq, WithResetToken>,
+) -> auth::Result<Empty> {
+ let user = db.get_user_by_id(&data.context).await?;
+
+ let notify_devs = db.get_devices(&data.context).await?;
+
+ let wrapwrap_kb = SecretBytes::generate();
+ let auth_salt = SaltString::generate(rand::rngs::OsRng);
+ let stretched = data.body.authPW.stretch(auth_salt.as_salt())?;
+ let verify_hash = stretched.verify_hash();
+
+ db.reset_user_auth(&data.context, auth_salt, SecretKey(wrapwrap_kb), VerifyHash(verify_hash))
+ .await?;
+
+ defer.spawn_after_success("api::auth/account/reset(post)", {
+ let client = Arc::clone(client);
+ async move {
+ client.password_reset(&notify_devs).await;
+ Ok(())
+ }
+ });
+
+ mailer
+ .send_account_reset(&user.email)
+ .await
+ .map_err(|e| {
+ warn!("account reset email send failed: {e}");
+ })
+ .ok();
+
+ Ok(EMPTY)
+}
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)
+}
diff --git a/src/api/auth/email.rs b/src/api/auth/email.rs
new file mode 100644
index 0000000..f206759
--- /dev/null
+++ b/src/api/auth/email.rs
@@ -0,0 +1,126 @@
+use std::sync::Arc;
+
+use rocket::{serde::json::Json, State};
+use serde::{Deserialize, Serialize};
+
+use crate::{
+ api::{
+ auth::{self, WithFxaLogin},
+ Empty, EMPTY,
+ },
+ auth::Authenticated,
+ db::{Db, DbConn},
+ mailer::Mailer,
+ push::PushClient,
+ types::UserID,
+ utils::DeferAction,
+};
+
+// MISSING get /recovery_emails
+// MISSING post /recovery_email
+// MISSING post /recovery_email/destroy
+// MISSING post /recovery_email/resend_code
+// MISSING post /recovery_email/set_primary
+// MISSING post /emails/reminders/cad
+// MISSING post /recovery_email/secondary/resend_code
+// MISSING post /recovery_email/secondary/verify_code
+
+#[derive(Debug, Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct StatusResp {
+ email: String,
+ verified: bool,
+ sessionVerified: bool,
+ emailVerified: bool,
+}
+
+// MISSING arg: reason
+#[get("/recovery_email/status")]
+pub(crate) async fn status(
+ db: &DbConn,
+ req: Authenticated<(), WithFxaLogin>,
+) -> auth::Result<StatusResp> {
+ let user = db.get_user_by_id(&req.context.uid).await?;
+ Ok(Json(StatusResp {
+ email: user.email,
+ verified: user.verified,
+ sessionVerified: req.context.verified,
+ emailVerified: user.verified,
+ }))
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct VerifyReq {
+ uid: UserID,
+ code: String,
+ // MISSING service
+ // MISSING reminder
+ // MISSING type
+ // MISSING style
+ // MISSING marketingOptIn
+ // MISSING newsletters
+}
+
+#[post("/recovery_email/verify_code", data = "<req>")]
+pub(crate) async fn verify_code(
+ db: &DbConn,
+ db_pool: &Db,
+ defer: &DeferAction,
+ pc: &State<Arc<PushClient>>,
+ req: Json<VerifyReq>,
+) -> auth::Result<Empty> {
+ let code = match db.try_use_verify_code(&req.uid, &req.code).await? {
+ Some(code) => code,
+ None => return Err(auth::Error::InvalidVerificationCode),
+ };
+ db.set_user_verified(&req.uid).await?;
+ if let Some(sid) = code.session_id {
+ db.set_session_verified(&sid).await?;
+ }
+ match db.get_devices(&req.uid).await {
+ Ok(devs) => defer.spawn_after_success("api::auth/recovery_email/verify_code(post)", {
+ let (pc, db) = (Arc::clone(pc), db_pool.clone());
+ async move {
+ let db = db.begin().await?;
+ pc.account_verified(&db, &devs).await;
+ db.commit().await?;
+ Ok(())
+ }
+ }),
+ Err(e) => warn!("account_verified push failed: {e}"),
+ }
+ Ok(EMPTY)
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct ResendReq {
+ // MISSING email
+ // MISSING service
+ // MISSING redirectTo
+ // MISSING resume
+ // MISSING style
+ // MISSING type
+}
+
+// MISSING arg: service
+// MISSING arg: type
+#[post("/recovery_email/resend_code", data = "<req>")]
+pub(crate) async fn resend_code(
+ db: &DbConn,
+ mailer: &State<Arc<Mailer>>,
+ req: Authenticated<ResendReq, WithFxaLogin>,
+) -> auth::Result<Empty> {
+ let (email, code) = match db.get_verify_code(&req.context.uid).await {
+ Ok(v) => v,
+ Err(_) => return Err(auth::Error::InvalidVerificationCode),
+ };
+ // NOTE we send the email in this context rather than a spawn to signal
+ // send errors to the client.
+ mailer.send_account_verify(&req.context.uid, &email, &code.code).await.map_err(|e| {
+ error!("failed to send email: {e}");
+ auth::Error::EmailFailed
+ })?;
+ Ok(EMPTY)
+}
diff --git a/src/api/auth/invite.rs b/src/api/auth/invite.rs
new file mode 100644
index 0000000..dd81540
--- /dev/null
+++ b/src/api/auth/invite.rs
@@ -0,0 +1,47 @@
+use base64::URL_SAFE_NO_PAD;
+use chrono::{Duration, Utc};
+use rocket::{http::uri::Reference, serde::json::Json, State};
+use serde::{Deserialize, Serialize};
+
+use crate::{api::auth, auth::Authenticated, crypto::SecretBytes, db::DbConn, Config};
+
+use super::WithVerifiedFxaLogin;
+
+pub(crate) async fn generate_invite_link(
+ db: &DbConn,
+ cfg: &Config,
+ ttl: Duration,
+) -> anyhow::Result<Reference<'static>> {
+ let code = base64::encode_config(&SecretBytes::<32>::generate().0, URL_SAFE_NO_PAD);
+ db.add_invite_code(&code, Utc::now() + ttl).await?;
+ Ok(Reference::parse_owned(format!("{}/#/register/{}", cfg.location, code))
+ .map_err(|e| anyhow!("url building failed at {e}"))?)
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct GenerateReq {
+ ttl_hours: u32,
+}
+
+#[derive(Debug, Serialize)]
+pub(crate) struct GenerateResp {
+ url: Reference<'static>,
+}
+
+#[post("/generate", data = "<req>")]
+pub(crate) async fn generate(
+ db: &DbConn,
+ cfg: &State<Config>,
+ req: Authenticated<GenerateReq, WithVerifiedFxaLogin>,
+) -> auth::Result<GenerateResp> {
+ if !req.context.verified {
+ return Err(auth::Error::UnverifiedSession);
+ }
+ let user = db.get_user_by_id(&req.context.uid).await?;
+ if user.email != cfg.invite_admin_address {
+ return Err(auth::Error::InvalidAuthToken);
+ }
+ let url = generate_invite_link(&db, &cfg, Duration::hours(req.body.ttl_hours as i64)).await?;
+ Ok(Json(GenerateResp { url }))
+}
diff --git a/src/api/auth/mod.rs b/src/api/auth/mod.rs
new file mode 100644
index 0000000..2c6d34d
--- /dev/null
+++ b/src/api/auth/mod.rs
@@ -0,0 +1,238 @@
+use rocket::{
+ http::Status,
+ response::{self, Responder},
+ serde::json::Json,
+ Request, Response,
+};
+use serde_json::json;
+
+use crate::{
+ auth::Authenticated,
+ crypto::SecretBytes,
+ types::{OauthToken, SessionID, UserSession},
+};
+
+pub(crate) mod account;
+pub(crate) mod device;
+pub(crate) mod email;
+pub(crate) mod invite;
+pub(crate) mod oauth;
+pub(crate) mod password;
+pub(crate) mod session;
+
+// we don't provide any additional fields. some we can't provide anyway (eg
+// invalid parameter `validation`), others are implied by the request body (eg
+// account exists `email`), and *our* client doesn't care about them anyway
+#[derive(Debug)]
+pub(crate) enum Error {
+ AccountExists,
+ UnknownAccount,
+ IncorrectPassword,
+ UnverifiedAccount,
+ InvalidVerificationCode,
+ InvalidBody,
+ InvalidParameter,
+ MissingParameter,
+ InvalidSignature,
+ InvalidAuthToken,
+ RequestTooLarge,
+ IncorrectEmailCase,
+ UnknownDevice,
+ UnverifiedSession,
+ EmailFailed,
+ NoDeviceCommand,
+ UnknownClientID,
+ ScopesNotAllowed,
+
+ InviteOnly,
+ InviteNotFound,
+
+ Other(anyhow::Error),
+ UnexpectedStatus(Status),
+}
+
+#[rustfmt::skip]
+impl<'r> Responder<'r, 'static> for Error {
+ fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> {
+ let (code, errno, msg) = match self {
+ Error::AccountExists => (Status::BadRequest, 101, "account already exists"),
+ Error::UnknownAccount => (Status::BadRequest, 102, "unknown account"),
+ Error::IncorrectPassword => (Status::BadRequest, 103, "incorrect password"),
+ Error::UnverifiedAccount => (Status::BadRequest, 104, "unverified account"),
+ Error::InvalidVerificationCode => (Status::BadRequest, 105, "invalid verification code"),
+ Error::InvalidBody => (Status::BadRequest, 106, "invalid json in request body"),
+ Error::InvalidParameter => (Status::BadRequest, 107, "invalid parameter in request body"),
+ Error::MissingParameter => (Status::BadRequest, 108, "missing parameter in request body"),
+ Error::InvalidSignature => (Status::Unauthorized, 109, "invalid request signature"),
+ Error::InvalidAuthToken => (Status::Unauthorized, 110, "invalid authentication token"),
+ Error::RequestTooLarge => (Status::PayloadTooLarge, 113, "request too large"),
+ Error::IncorrectEmailCase => (Status::BadRequest, 120, "incorrect email case"),
+ Error::UnknownDevice => (Status::BadRequest, 123, "unknown device"),
+ Error::UnverifiedSession => (Status::BadRequest, 138, "unverified session"),
+ Error::EmailFailed => (Status::UnprocessableEntity, 151, "failed to send email"),
+ Error::NoDeviceCommand => (Status::BadRequest, 157, "unavailable device command"),
+ Error::UnknownClientID => (Status::BadRequest, 162, "unknown client_id"),
+ Error::ScopesNotAllowed => (Status::BadRequest, 169, "requested scopes not allowed"),
+ Error::InviteOnly => (Status::BadRequest, -1, "invite code required"),
+ Error::InviteNotFound => (Status::BadRequest, -2, "invite code not found"),
+ Error::Other(e) => {
+ error!("non-api error during request: {:#?}", e);
+ (Status::InternalServerError, 999, "internal error")
+ },
+ Error::UnexpectedStatus(s) => (s, 999, ""),
+ };
+ let body = json!({
+ "code": code.code,
+ "errno": errno,
+ "error": code.reason_lossy(),
+ "message": msg
+ });
+ Response::build_from(Json(body).respond_to(request)?).status(code).ok()
+ }
+}
+
+impl From<sqlx::Error> for Error {
+ fn from(e: sqlx::Error) -> Self {
+ Error::Other(anyhow!(e))
+ }
+}
+
+impl From<anyhow::Error> for Error {
+ fn from(e: anyhow::Error) -> Self {
+ Error::Other(e)
+ }
+}
+
+pub(crate) type Result<T> = std::result::Result<Json<T>, Error>;
+
+// hack marker type to convey that auth failed due to an unverified session.
+// without this the catcher could convert the Unauthorized error we get from
+// auth failures into just one thing, even though we have multiple causes.
+#[derive(Clone, Copy, Debug)]
+struct UsedUnverifiedSession;
+
+#[catch(default)]
+pub(crate) fn catch_all(status: Status, req: &Request<'_>) -> Error {
+ match req.local_cache(|| None) {
+ Some(UsedUnverifiedSession) => Error::UnverifiedSession,
+ _ => {
+ match status.code {
+ 401 => Error::InvalidSignature,
+ // these three are caused by Json<T> errors
+ 400 => Error::InvalidBody,
+ 413 => Error::RequestTooLarge,
+ 422 => Error::InvalidParameter,
+ // generic unauthorized instead of 404 for eg wrong method or nonexistant endpoints
+ 404 => Error::InvalidSignature,
+ _ => {
+ error!("caught unexpected error {status}");
+ Error::UnexpectedStatus(status)
+ },
+ }
+ },
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct WithFxaLogin;
+
+#[async_trait]
+impl crate::auth::AuthSource for WithFxaLogin {
+ type ID = SessionID;
+ type Context = UserSession;
+ async fn hawk(
+ r: &Request<'_>,
+ id: &SessionID,
+ ) -> anyhow::Result<(SecretBytes<32>, Self::Context)> {
+ let db = Authenticated::<(), Self>::get_conn(r).await?;
+ let k = db.use_session(id).await?;
+ Ok((k.req_hmac_key.0.clone(), k))
+ }
+ async fn bearer_token(
+ _: &Request<'_>,
+ _: &OauthToken,
+ ) -> anyhow::Result<(SessionID, Self::Context)> {
+ bail!("refresh tokens not allowed here");
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct WithVerifiedFxaLogin;
+
+#[async_trait]
+impl crate::auth::AuthSource for WithVerifiedFxaLogin {
+ type ID = SessionID;
+ type Context = UserSession;
+ async fn hawk(
+ r: &Request<'_>,
+ id: &SessionID,
+ ) -> anyhow::Result<(SecretBytes<32>, Self::Context)> {
+ let res = WithFxaLogin::hawk(r, id).await?;
+ match res.1.verified {
+ true => Ok(res),
+ false => {
+ r.local_cache(|| Some(UsedUnverifiedSession));
+ bail!("session not verified");
+ },
+ }
+ }
+ async fn bearer_token(
+ _: &Request<'_>,
+ _: &OauthToken,
+ ) -> anyhow::Result<(SessionID, Self::Context)> {
+ bail!("refresh tokens not allowed here");
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct WithSession;
+
+#[rocket::async_trait]
+impl crate::auth::AuthSource for WithSession {
+ type ID = SessionID;
+ type Context = UserSession;
+ async fn hawk(
+ r: &Request<'_>,
+ id: &SessionID,
+ ) -> anyhow::Result<(SecretBytes<32>, Self::Context)> {
+ WithFxaLogin::hawk(r, id).await
+ }
+ async fn bearer_token(
+ r: &Request<'_>,
+ token: &OauthToken,
+ ) -> anyhow::Result<(SessionID, Self::Context)> {
+ let db = Authenticated::<(), Self>::get_conn(r).await?;
+ Ok(db.use_session_from_refresh(&token.hash()).await?)
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct WithVerifiedSession;
+
+#[rocket::async_trait]
+impl crate::auth::AuthSource for WithVerifiedSession {
+ type ID = SessionID;
+ type Context = UserSession;
+ async fn hawk(
+ r: &Request<'_>,
+ id: &SessionID,
+ ) -> anyhow::Result<(SecretBytes<32>, Self::Context)> {
+ WithVerifiedFxaLogin::hawk(r, id).await
+ }
+ async fn bearer_token(
+ r: &Request<'_>,
+ token: &OauthToken,
+ ) -> anyhow::Result<(SessionID, Self::Context)> {
+ let db = Authenticated::<(), Self>::get_conn(r).await?;
+ let res = db.use_session_from_refresh(&token.hash()).await?;
+ match res.1.verified {
+ true => Ok(res),
+ false => {
+ // technically unreachable because generating a refresh token requires a
+ // valid fxa session
+ r.local_cache(|| Some(UsedUnverifiedSession));
+ bail!("session not verified");
+ },
+ }
+ }
+}
diff --git a/src/api/auth/oauth.rs b/src/api/auth/oauth.rs
new file mode 100644
index 0000000..b0ed8ee
--- /dev/null
+++ b/src/api/auth/oauth.rs
@@ -0,0 +1,433 @@
+use std::collections::HashMap;
+
+use chrono::{DateTime, Duration, Local, Utc};
+use rocket::serde::json::Json;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use sha2::Digest;
+use subtle::ConstantTimeEq;
+
+use crate::api::auth::WithVerifiedFxaLogin;
+use crate::db::DbConn;
+use crate::types::oauth::{Scope, ScopeSet};
+use crate::{
+ api::{auth, serialize_dt},
+ auth::Authenticated,
+ crypto::{SecretBytes, SessionCredentials},
+ types::{
+ HawkKey, OauthAccessToken, OauthAccessType, OauthAuthorization, OauthAuthorizationID,
+ OauthRefreshToken, OauthToken, OauthTokenID, SessionID, UserID,
+ },
+};
+
+// MISSING get /oauth/client/{client_id}
+
+pub(crate) struct OauthClient {
+ pub(crate) id: &'static str,
+ // NOTE not read so far, but good to have
+ #[allow(dead_code)]
+ pub(crate) name: &'static str,
+ pub(crate) scopes: &'static [Scope<'static>],
+}
+
+const SESSION_SCOPE: Scope = Scope::borrowed("https://identity.mozilla.com/tokens/session");
+
+// NOTE the telemetry scopes don't seem to be needed. since we'd have to give
+// out keys for them (fxa does) we'll exclude them entirely.
+// see fxa-auth-server/config/dev.json for lists of predefined clients and permissions.
+pub(crate) const OAUTH_CLIENTS: [OauthClient; 2] = [
+ OauthClient {
+ id: "5882386c6d801776",
+ name: "Firefox",
+ scopes: &[
+ Scope::borrowed("profile:write"),
+ Scope::borrowed("https://identity.mozilla.com/apps/oldsync"),
+ Scope::borrowed("https://identity.mozilla.com/tokens/session"),
+ // "https://identity.mozilla.com/ids/ecosystem_telemetry",
+ ],
+ },
+ OauthClient {
+ id: "a2270f727f45f648",
+ name: "Fenix",
+ scopes: &[
+ Scope::borrowed("profile"),
+ Scope::borrowed("https://identity.mozilla.com/apps/oldsync"),
+ Scope::borrowed("https://identity.mozilla.com/tokens/session"),
+ // "https://identity.mozilla.com/ids/ecosystem_telemetry",
+ ],
+ },
+];
+
+// NOTE fxa dev config allows scoped keys only for:
+// - https://identity.mozilla.com/apps/notes
+// - https://identity.mozilla.com/apps/oldsync
+// - https://identity.mozilla.com/ids/ecosystem_telemetry
+// - https://identity.mozilla.com/apps/send
+// we only implement sync because notes and send are dead and
+// telemetry is of no use to us
+const SCOPES_WITH_KEYS: [Scope; 1] = [Scope::borrowed("https://identity.mozilla.com/apps/oldsync")];
+
+fn check_client_and_scopes(
+ client_id: &str,
+ scope: &ScopeSet,
+) -> Result<&'static OauthClient, auth::Error> {
+ let desc = match OAUTH_CLIENTS.iter().find(|&s| s.id == client_id) {
+ Some(d) => d,
+ None => return Err(auth::Error::UnknownClientID),
+ };
+ if !scope.is_allowed_by(desc.scopes) {
+ return Err(auth::Error::ScopesNotAllowed);
+ }
+ Ok(desc)
+}
+
+#[derive(Debug, Deserialize)]
+pub(crate) enum PkceChallengeType {
+ S256,
+}
+
+#[derive(Debug, Deserialize)]
+pub(crate) enum AuthResponseType {
+ #[serde(rename = "code")]
+ Code,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct OauthAuthReq {
+ client_id: String,
+ state: String,
+ keys_jwe: Option<String>,
+ scope: ScopeSet,
+ access_type: OauthAccessType,
+ // NOTE we don't support confidential clients, so PKCE is mandatory
+ code_challenge: String,
+
+ // MISSING redirect_uri
+ // MISSING acr_value
+
+ // for validation during deserialization only
+ #[allow(dead_code)]
+ code_challenge_method: PkceChallengeType,
+ #[allow(dead_code)]
+ response_type: AuthResponseType,
+}
+
+#[derive(Debug, Serialize)]
+pub(crate) struct OauthAuthResp {
+ code: OauthAuthorizationID,
+ state: String,
+ // MISSING redirect
+}
+
+#[post("/oauth/authorization", data = "<req>")]
+pub(crate) async fn authorization(
+ db: &DbConn,
+ req: Authenticated<OauthAuthReq, WithVerifiedFxaLogin>,
+) -> auth::Result<OauthAuthResp> {
+ check_client_and_scopes(&req.body.client_id, &req.body.scope)?;
+ let id = OauthAuthorizationID::random();
+ db.add_oauth_authorization(
+ &id,
+ OauthAuthorization {
+ user_id: req.context.uid,
+ client_id: req.body.client_id,
+ scope: req.body.scope,
+ access_type: req.body.access_type,
+ code_challenge: req.body.code_challenge,
+ keys_jwe: req.body.keys_jwe,
+ auth_at: req.context.created_at,
+ },
+ )
+ .await?;
+ Ok(Json(OauthAuthResp { code: id, state: req.body.state }))
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct ScopedKeysReq {
+ client_id: String,
+ scope: ScopeSet,
+}
+
+#[derive(Debug, Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct ScopedKey {
+ identifier: String,
+ keyRotationSecret: &'static str,
+ keyRotationTimestamp: u64,
+}
+
+#[post("/account/scoped-key-data", data = "<data>")]
+pub(crate) async fn scoped_key_data(
+ data: Authenticated<ScopedKeysReq, WithVerifiedFxaLogin>,
+) -> auth::Result<HashMap<String, ScopedKey>> {
+ check_client_and_scopes(&data.body.client_id, &data.body.scope)?;
+ // like fxa we'll stub out key rotation handling entirely and return the same constants.
+ Ok(Json(
+ data.body
+ .scope
+ .split()
+ .filter(|s| SCOPES_WITH_KEYS.contains(s))
+ .map(|scope| {
+ (
+ scope.to_string(),
+ ScopedKey {
+ identifier: scope.to_string(),
+ keyRotationSecret:
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ keyRotationTimestamp: 0,
+ },
+ )
+ })
+ .collect(),
+ ))
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct OauthDestroy {
+ client_id: String,
+ token: OauthToken,
+}
+
+#[post("/oauth/destroy", data = "<data>")]
+pub(crate) async fn destroy(db: &DbConn, data: Json<OauthDestroy>) -> auth::Result<()> {
+ // MISSING api spec allows an optional basic auth header, but what for?
+ // TODO fxa also checks the authorization header if present, but firefox doesn't send it
+ let client_id = if let Ok(t) = db.get_refresh_token(&data.token.hash()).await {
+ t.client_id
+ } else if let Ok(t) = db.get_access_token(&data.token.hash()).await {
+ t.client_id
+ } else {
+ return Err(auth::Error::InvalidParameter);
+ };
+ // fxa does constant-time checks for client_id, do that here too.
+ if client_id.as_bytes().ct_eq(data.client_id.as_bytes()).into() {
+ db.delete_oauth_token(&data.token.hash()).await?;
+ Ok(Json(()))
+ } else {
+ Err(auth::Error::InvalidParameter)
+ }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(tag = "grant_type")]
+enum TokenReqDetails {
+ // we can't use deny_unknown_fields when flatten is involved, and multiple
+ // flattens in the same struct cause problems if one of them is greedy (like map).
+ // flatten an extra map into every variant instead and check each of them.
+ #[serde(rename = "authorization_code")]
+ AuthCode {
+ code: OauthAuthorizationID,
+ code_verifier: String,
+ // NOTE only useful with redirect flows, which we kinda don't support at all
+ #[allow(dead_code)]
+ redirect_uri: Option<String>,
+ #[serde(flatten)]
+ extra: HashMap<String, Value>,
+ },
+ #[serde(rename = "refresh_token")]
+ RefreshToken {
+ refresh_token: OauthToken,
+ scope: ScopeSet,
+ #[serde(flatten)]
+ extra: HashMap<String, Value>,
+ },
+ #[serde(rename = "fxa-credentials")]
+ FxaCreds {
+ scope: ScopeSet,
+ access_type: Option<OauthAccessType>,
+ #[serde(flatten)]
+ extra: HashMap<String, Value>,
+ },
+}
+
+impl TokenReqDetails {
+ fn extra_is_empty(&self) -> bool {
+ match self {
+ TokenReqDetails::AuthCode { extra, .. } => extra.is_empty(),
+ TokenReqDetails::RefreshToken { extra, .. } => extra.is_empty(),
+ TokenReqDetails::FxaCreds { extra, .. } => extra.is_empty(),
+ }
+ }
+}
+
+// TODO log errors in all the places
+
+#[derive(Debug, Deserialize)]
+pub(crate) struct TokenReq {
+ client_id: String,
+ ttl: Option<u32>,
+ #[serde(flatten)]
+ details: TokenReqDetails,
+ // MISSING client_secret
+ // MISSING redirect_uri
+ // MISSING ttl
+ // MISSING ppid_seed
+ // MISSING resource
+}
+
+#[derive(Debug, Serialize)]
+pub(crate) enum TokenType {
+ #[serde(rename = "bearer")]
+ Bearer,
+}
+
+#[derive(Debug, Serialize)]
+pub(crate) struct TokenResp {
+ access_token: OauthToken,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ refresh_token: Option<OauthToken>,
+ // MISSING id_token
+ #[serde(skip_serializing_if = "Option::is_none")]
+ session_token: Option<String>,
+ scope: ScopeSet,
+ token_type: TokenType,
+ expires_in: u32,
+ #[serde(serialize_with = "serialize_dt")]
+ auth_at: DateTime<Utc>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ keys_jwe: Option<String>,
+}
+
+#[post("/oauth/token", data = "<req>", rank = 1)]
+pub(crate) async fn token_authenticated(
+ db: &DbConn,
+ req: Authenticated<TokenReq, WithVerifiedFxaLogin>,
+) -> auth::Result<TokenResp> {
+ match &req.body.details {
+ TokenReqDetails::FxaCreds { .. } => (),
+ _ => return Err(auth::Error::InvalidParameter),
+ }
+ token_impl(
+ db,
+ Some(req.context.uid),
+ Some(req.context.created_at),
+ req.body,
+ None,
+ Some(req.session.clone()),
+ )
+ .await
+}
+
+#[post("/oauth/token", data = "<req>", rank = 2)]
+pub(crate) async fn token_unauthenticated(
+ db: &DbConn,
+ req: Json<TokenReq>,
+) -> auth::Result<TokenResp> {
+ let (parent_refresh, auth_at) = match &req.details {
+ TokenReqDetails::RefreshToken { refresh_token, .. } => {
+ let session = db.use_session_from_refresh(&refresh_token.hash()).await?;
+ (Some(refresh_token.hash()), Some(session.1.created_at))
+ },
+ TokenReqDetails::AuthCode { .. } => (None, None),
+ _ => return Err(auth::Error::InvalidParameter),
+ };
+ token_impl(db, None, auth_at, req.into_inner(), parent_refresh, None).await
+}
+
+async fn token_impl(
+ db: &DbConn,
+ user_id: Option<UserID>,
+ auth_at: Option<DateTime<Utc>>,
+ req: TokenReq,
+ parent_refresh: Option<OauthTokenID>,
+ parent_session: Option<SessionID>,
+) -> auth::Result<TokenResp> {
+ if !req.details.extra_is_empty() {
+ return Err(auth::Error::InvalidParameter);
+ }
+ let ttl = req.ttl.unwrap_or(3600).clamp(0, 7 * 86400);
+
+ let (auth_at, scope, keys_jwe, user_id, access_type) = match req.details {
+ TokenReqDetails::AuthCode { code, code_verifier, .. } => {
+ let auth = match db.take_oauth_authorization(&code).await {
+ Ok(a) => a,
+ Err(_) => return Err(auth::Error::InvalidAuthToken),
+ };
+ if !bool::from(auth.client_id.as_bytes().ct_eq(req.client_id.as_bytes())) {
+ return Err(auth::Error::UnknownClientID);
+ }
+ let mut sha = sha2::Sha256::new();
+ sha.update(code_verifier.as_bytes());
+ let challenge = base64::encode_config(&sha.finalize(), base64::URL_SAFE_NO_PAD);
+ if !bool::from(challenge.as_bytes().ct_eq(auth.code_challenge.as_bytes())) {
+ return Err(auth::Error::InvalidParameter);
+ }
+ (auth.auth_at, auth.scope, auth.keys_jwe, auth.user_id, Some(auth.access_type))
+ },
+ TokenReqDetails::RefreshToken { refresh_token, scope, .. } => {
+ let auth_at =
+ auth_at.expect("oauth token requests with refresh token must set auth_at");
+ let base = db.get_refresh_token(&refresh_token.hash()).await?;
+ if !bool::from(base.client_id.as_bytes().ct_eq(req.client_id.as_bytes())) {
+ return Err(auth::Error::UnknownClientID);
+ }
+ check_client_and_scopes(&req.client_id, &scope)?;
+ if !base.scope.implies_all(&scope) {
+ return Err(auth::Error::ScopesNotAllowed);
+ }
+ (auth_at, scope, None, base.user_id, None)
+ },
+ TokenReqDetails::FxaCreds { scope, access_type, .. } => {
+ let user_id = user_id.expect("oauth token requests with fxa must set user_id");
+ let auth_at = auth_at.expect("oauth token requests with fxa must set auth_at");
+ check_client_and_scopes(&req.client_id, &scope)?;
+ (auth_at, scope, None, user_id, access_type)
+ },
+ };
+
+ let access_token = OauthToken::random();
+ db.add_access_token(
+ &access_token.hash(),
+ OauthAccessToken {
+ user_id: user_id.clone(),
+ client_id: req.client_id.clone(),
+ scope: scope.clone(),
+ parent_refresh,
+ parent_session,
+ expires_at: (Local::now() + Duration::seconds(ttl.into())).into(),
+ },
+ )
+ .await?;
+
+ let (refresh_token, session_token) = if access_type == Some(OauthAccessType::Offline) {
+ let (session_token, session_id) = if scope.implies(&SESSION_SCOPE) {
+ let session_token = SecretBytes::generate();
+ let session = SessionCredentials::derive(&session_token);
+ let session_id = SessionID(session.token_id.0);
+ db.add_session(session_id.clone(), &user_id, HawkKey(session.req_hmac_key), true, None)
+ .await?;
+ (Some(session_token.0), Some(SessionID(session.token_id.0)))
+ } else {
+ (None, None)
+ };
+
+ let refresh_token = OauthToken::random();
+ db.add_refresh_token(
+ &refresh_token.hash(),
+ OauthRefreshToken {
+ user_id,
+ client_id: req.client_id,
+ scope: scope.remove(&SESSION_SCOPE),
+ session_id,
+ },
+ )
+ .await?;
+ (Some(refresh_token), session_token)
+ } else {
+ (None, None)
+ };
+
+ Ok(Json(TokenResp {
+ access_token,
+ refresh_token,
+ session_token: session_token.map(hex::encode),
+ scope: scope.remove(&SESSION_SCOPE),
+ token_type: TokenType::Bearer,
+ expires_in: ttl,
+ auth_at,
+ keys_jwe,
+ }))
+}
diff --git a/src/api/auth/password.rs b/src/api/auth/password.rs
new file mode 100644
index 0000000..0eeab4f
--- /dev/null
+++ b/src/api/auth/password.rs
@@ -0,0 +1,260 @@
+use std::sync::Arc;
+
+use anyhow::Result;
+use password_hash::SaltString;
+use rocket::{request::FromRequest, serde::json::Json, Request, State};
+use serde::{Deserialize, Serialize};
+use validator::Validate;
+
+use crate::{
+ api::auth,
+ auth::{AuthSource, Authenticated},
+ crypto::{AccountResetReq, AuthPW, KeyBundle, KeyFetchReq, PasswordChangeReq, SecretBytes},
+ db::{Db, DbConn},
+ mailer::Mailer,
+ types::{
+ AccountResetID, HawkKey, KeyFetchID, OauthToken, PasswordChangeID, SecretKey, UserID,
+ VerifyHash,
+ },
+};
+
+// MISSING get /password/forgot/status
+// MISSING post /password/create
+// MISSING post /password/forgot/resend_code
+
+#[derive(Debug, Deserialize, Validate)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct ChangeStartReq {
+ #[validate(email, length(min = 3, max = 256))]
+ email: String,
+ oldAuthPW: AuthPW,
+}
+
+#[derive(Debug, Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct ChangeStartResp {
+ keyFetchToken: SecretBytes<32>,
+ passwordChangeToken: SecretBytes<32>,
+}
+
+#[post("/password/change/start", data = "<data>")]
+pub(crate) async fn change_start(
+ db: &DbConn,
+ data: Json<ChangeStartReq>,
+) -> auth::Result<ChangeStartResp> {
+ let data = data.into_inner();
+ data.validate().map_err(|_| auth::Error::InvalidParameter)?;
+
+ let (uid, user) = db.get_user(&data.email).await.map_err(|_| auth::Error::UnknownAccount)?;
+ if user.email != data.email {
+ return Err(auth::Error::IncorrectEmailCase);
+ }
+ if !user.verified {
+ return Err(auth::Error::UnverifiedAccount);
+ }
+
+ let stretched = data.oldAuthPW.stretch(user.auth_salt.as_salt())?;
+ if stretched.verify_hash() != user.verify_hash.0 {
+ return Err(auth::Error::IncorrectPassword);
+ }
+
+ let change_token = SecretBytes::generate();
+ let change_req = PasswordChangeReq::from_change_token(&change_token);
+ let key_fetch_token = SecretBytes::generate();
+ let key_req = KeyFetchReq::from_token(&key_fetch_token);
+ let wrapped = key_req.derive_resp().wrap_keys(&KeyBundle {
+ ka: user.ka.0.clone(),
+ wrap_kb: stretched.decrypt_wwkb(&user.wrapwrap_kb.0),
+ });
+ db.add_key_fetch(KeyFetchID(key_req.token_id.0), &HawkKey(key_req.req_hmac_key), &wrapped)
+ .await?;
+ db.add_password_change(
+ &uid,
+ &PasswordChangeID(change_req.token_id.0),
+ &HawkKey(change_req.req_hmac_key),
+ None,
+ )
+ .await?;
+
+ Ok(Json(ChangeStartResp { keyFetchToken: key_fetch_token, passwordChangeToken: change_token }))
+}
+
+// NOTE we use a plain bool here and in the db instead of an enum because
+// enums aren't usable in const generics in stable.
+#[derive(Debug)]
+pub(crate) struct WithChangeToken<const IS_FORGOT: bool>;
+
+#[async_trait]
+impl<const IS_FORGOT: bool> AuthSource for WithChangeToken<IS_FORGOT> {
+ type ID = PasswordChangeID;
+ type Context = (UserID, Option<String>);
+ async fn hawk(
+ r: &Request<'_>,
+ id: &PasswordChangeID,
+ ) -> Result<(SecretBytes<32>, Self::Context)> {
+ // unlike key fetch we'll use a separate transaction here since the body of the
+ // handler can fail.
+ let pool = <&Db as FromRequest>::from_request(r)
+ .await
+ .success_or_else(|| anyhow!("could not open db connection"))?;
+ let db = pool.begin().await?;
+ let result = db.finish_password_change(id, IS_FORGOT).await.map(|(h, ctx)| (h.0, ctx))?;
+ db.commit().await?;
+ Ok(result)
+ }
+ async fn bearer_token(
+ _: &Request<'_>,
+ _: &OauthToken,
+ ) -> Result<(PasswordChangeID, Self::Context)> {
+ bail!("invalid password change authentication")
+ }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct ChangeFinishReq {
+ authPW: AuthPW,
+ wrapKb: SecretBytes<32>,
+ // MISSING sessionToken
+}
+
+#[derive(Debug, Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct ChangeFinishResp {
+ // NOTE we intentionally deviate from mozilla here. mozilla creates a new
+ // session if sessionToken is set in the request, but we use the "legacy"
+ // password change mechanism that leaves the requesting session and its
+ // device and keys intact. as such this struct is intentionally empty.
+ //
+ // MISSING uid
+ // MISSING sessionToken
+ // MISSING verified
+ // MISSING authAt
+ // MISSING keyFetchToken
+}
+
+#[post("/password/change/finish", data = "<data>")]
+pub(crate) async fn change_finish(
+ db: &DbConn,
+ mailer: &State<Arc<Mailer>>,
+ data: Authenticated<ChangeFinishReq, WithChangeToken<false>>,
+) -> auth::Result<ChangeFinishResp> {
+ let user = db.get_user_by_id(&data.context.0).await?;
+
+ let auth_salt = SaltString::generate(rand::rngs::OsRng);
+ let stretched = data.body.authPW.stretch(auth_salt.as_salt())?;
+ let verify_hash = stretched.verify_hash();
+ let wrapwrap_kb = stretched.rewrap_wkb(&data.body.wrapKb);
+
+ db.change_user_auth(
+ &data.context.0,
+ auth_salt,
+ SecretKey(wrapwrap_kb),
+ VerifyHash(verify_hash),
+ )
+ .await?;
+
+ // NOTE password_changed/password_reset pushes seem to have no effect, so skip them.
+
+ mailer
+ .send_password_changed(&user.email)
+ .await
+ .map_err(|e| {
+ warn!("password change email send failed: {e}");
+ })
+ .ok();
+
+ Ok(Json(ChangeFinishResp {}))
+}
+
+#[derive(Debug, Deserialize, Validate)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct ForgotStartReq {
+ #[validate(email, length(min = 3, max = 256))]
+ email: String,
+}
+
+#[derive(Debug, Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct ForgotStartResp {
+ passwordForgotToken: SecretBytes<32>,
+ ttl: u32,
+ codeLength: u32,
+ tries: u32,
+}
+
+#[post("/password/forgot/send_code", data = "<data>")]
+pub(crate) async fn forgot_start(
+ db: &DbConn,
+ mailer: &State<Arc<Mailer>>,
+ data: Json<ForgotStartReq>,
+) -> auth::Result<ForgotStartResp> {
+ let data = data.into_inner();
+ data.validate().map_err(|_| auth::Error::InvalidParameter)?;
+
+ let (uid, user) = db.get_user(&data.email).await.map_err(|_| auth::Error::UnknownAccount)?;
+ if user.email != data.email {
+ return Err(auth::Error::IncorrectEmailCase);
+ }
+ if !user.verified {
+ return Err(auth::Error::UnverifiedAccount);
+ }
+
+ let forgot_code = hex::encode(SecretBytes::<16>::generate().0);
+ let forgot_token = SecretBytes::generate();
+ let forgot_req = PasswordChangeReq::from_forgot_token(&forgot_token);
+ db.add_password_change(
+ &uid,
+ &PasswordChangeID(forgot_req.token_id.0),
+ &HawkKey(forgot_req.req_hmac_key),
+ Some(&forgot_code),
+ )
+ .await?;
+
+ mailer.send_password_forgot(&user.email, &forgot_code).await?;
+
+ Ok(Json(ForgotStartResp {
+ passwordForgotToken: forgot_token,
+ ttl: 300,
+ codeLength: 16,
+ tries: 1,
+ }))
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct ForgotFinishReq {
+ code: String,
+ // MISSING accountResetWithRecoveryKey
+}
+
+#[derive(Debug, Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct ForgotFinishResp {
+ accountResetToken: SecretBytes<32>,
+}
+
+#[post("/password/forgot/verify_code", data = "<data>")]
+pub(crate) async fn forgot_finish(
+ db: &DbConn,
+ data: Authenticated<ForgotFinishReq, WithChangeToken<true>>,
+) -> auth::Result<ForgotFinishResp> {
+ if Some(data.body.code) != data.context.1 {
+ return Err(auth::Error::InvalidVerificationCode);
+ }
+
+ let reset_token = SecretBytes::generate();
+ let reset_req = AccountResetReq::from_token(&reset_token);
+ db.add_account_reset(
+ &data.context.0,
+ &AccountResetID(reset_req.token_id.0),
+ &HawkKey(reset_req.req_hmac_key),
+ )
+ .await?;
+
+ Ok(Json(ForgotFinishResp { accountResetToken: reset_token }))
+}
diff --git a/src/api/auth/session.rs b/src/api/auth/session.rs
new file mode 100644
index 0000000..5911b92
--- /dev/null
+++ b/src/api/auth/session.rs
@@ -0,0 +1,107 @@
+use std::sync::Arc;
+
+use rocket::serde::json::Json;
+use rocket::State;
+use serde::{Deserialize, Serialize};
+
+use crate::api::auth::WithFxaLogin;
+use crate::api::{auth, Empty, EMPTY};
+use crate::auth::Authenticated;
+use crate::db::Db;
+use crate::db::DbConn;
+use crate::mailer::Mailer;
+use crate::push::PushClient;
+use crate::types::{SessionID, UserID};
+use crate::utils::DeferAction;
+
+// MISSING post /session/duplicate
+// MISSING post /session/reauth
+// MISSING post /session/verify/send_push
+
+#[derive(Debug, Serialize)]
+pub(crate) struct StatusResp {
+ state: &'static str, // what does this *do*?
+ uid: UserID,
+}
+
+#[get("/session/status")]
+pub(crate) async fn status(req: Authenticated<(), WithFxaLogin>) -> auth::Result<StatusResp> {
+ Ok(Json(StatusResp { state: "", uid: req.context.uid }))
+}
+
+#[post("/session/resend_code", data = "<req>")]
+pub(crate) async fn resend_code(
+ db: &DbConn,
+ mailer: &State<Arc<Mailer>>,
+ req: Authenticated<Empty, WithFxaLogin>,
+) -> auth::Result<Empty> {
+ let code = match req.context.verify_code {
+ Some(code) => code,
+ _ => return Err(auth::Error::InvalidVerificationCode),
+ };
+
+ let user = db.get_user_by_id(&req.context.uid).await?;
+ mailer.send_session_verify(&user.email, &code).await.map_err(|e| {
+ error!("failed to send email: {e}");
+ auth::Error::EmailFailed
+ })?;
+ Ok(EMPTY)
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct VerifyReq {
+ code: String,
+ // MISSING service
+ // MISSING scopes
+ // MISSING marketingOptIn
+ // MISSING newsletters
+}
+
+#[post("/session/verify_code", data = "<req>")]
+pub(crate) async fn verify_code(
+ db: &DbConn,
+ req: Authenticated<VerifyReq, WithFxaLogin>,
+) -> auth::Result<Empty> {
+ if req.context.verify_code.as_ref() != Some(&req.body.code) {
+ return Err(auth::Error::InvalidVerificationCode);
+ }
+ db.set_session_verified(&req.session).await?;
+ Ok(EMPTY)
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct DestroyReq {
+ custom_session_id: Option<SessionID>,
+}
+
+#[post("/session/destroy", data = "<data>")]
+pub(crate) async fn destroy(
+ db: &DbConn,
+ db_pool: &Db,
+ defer: &DeferAction,
+ client: &State<Arc<PushClient>>,
+ data: Authenticated<DestroyReq, WithFxaLogin>,
+) -> auth::Result<Empty> {
+ if data.body.custom_session_id.is_some() && !data.context.verified {
+ return Err(auth::Error::UnverifiedSession);
+ }
+ let id = data.body.custom_session_id.as_ref().unwrap_or(&data.session);
+ db.delete_session(&data.context.uid, id).await.map_err(|_| auth::Error::UnknownDevice)?;
+ if let Some(id) = data.context.device_id {
+ match db.get_devices(&data.context.uid).await {
+ Err(e) => warn!("device_disconnected push failed: {e}"),
+ Ok(devs) => defer.spawn_after_success("api::auth/session/destroy(post)", {
+ let (client, db) = (Arc::clone(client), db_pool.clone());
+ async move {
+ let db = db.begin().await?;
+ client.device_disconnected(&db, &devs, &id).await;
+ db.commit().await?;
+ Ok(())
+ }
+ }),
+ };
+ }
+ Ok(EMPTY)
+}
diff --git a/src/api/mod.rs b/src/api/mod.rs
new file mode 100644
index 0000000..1831659
--- /dev/null
+++ b/src/api/mod.rs
@@ -0,0 +1,32 @@
+use chrono::{DateTime, TimeZone};
+use rocket::serde::json::Json;
+use serde::{Deserialize, Serialize, Serializer};
+
+pub(crate) mod auth;
+pub(crate) mod oauth;
+pub(crate) mod profile;
+
+pub fn serialize_dt<S, TZ>(dt: &DateTime<TZ>, ser: S) -> Result<S::Ok, S::Error>
+where
+ S: Serializer,
+ TZ: TimeZone,
+{
+ ser.serialize_i64(dt.timestamp())
+}
+
+pub fn serialize_dt_opt<S, TZ>(dt: &Option<DateTime<TZ>>, ser: S) -> Result<S::Ok, S::Error>
+where
+ S: Serializer,
+ TZ: TimeZone,
+{
+ match dt {
+ Some(dt) => serialize_dt(dt, ser),
+ None => ser.serialize_unit(),
+ }
+}
+
+#[derive(Clone, Copy, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Empty {}
+
+pub const EMPTY: Json<Empty> = Json(Empty {});
diff --git a/src/api/oauth.rs b/src/api/oauth.rs
new file mode 100644
index 0000000..0519125
--- /dev/null
+++ b/src/api/oauth.rs
@@ -0,0 +1,163 @@
+use rocket::{
+ http::Status,
+ response::{self, Responder},
+ serde::json::Json,
+ Request, Response,
+};
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+
+use crate::{
+ api::Empty,
+ types::{OauthToken, UserID},
+};
+use crate::{db::DbConn, types::oauth::Scope};
+
+use super::EMPTY;
+
+// we don't provide any additional fields. some we can't provide anyway (eg
+// invalid parameter `validation`), others are implied by the request body (eg
+// account exists `email`), and *our* client doesn't care about them anyway
+#[derive(Debug)]
+pub(crate) enum Error {
+ InvalidParameter,
+ Unauthorized,
+ PayloadTooLarge,
+
+ Other(anyhow::Error),
+ UnexpectedStatus(Status),
+}
+
+#[rustfmt::skip]
+impl<'r> Responder<'r, 'static> for Error {
+ fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> {
+ let (code, errno, msg) = match self {
+ Error::InvalidParameter => (Status::BadRequest, 109, "invalid request parameter"),
+ Error::Unauthorized => (Status::Forbidden, 111, "unauthorized"),
+ Error::PayloadTooLarge => (Status::PayloadTooLarge, 999, "payload too large"),
+ Error::Other(e) => {
+ error!("non-api error during request: {:?}", e);
+ (Status::InternalServerError, 999, "internal error")
+ },
+ Error::UnexpectedStatus(s) => (s, 999, ""),
+ };
+ let body = json!({
+ "code": code.code,
+ "errno": errno,
+ "error": code.reason_lossy(),
+ "message": msg
+ });
+ Response::build_from(Json(body).respond_to(request)?).status(code).ok()
+ }
+}
+
+impl From<sqlx::Error> for Error {
+ fn from(e: sqlx::Error) -> Self {
+ Error::Other(anyhow!(e))
+ }
+}
+
+impl From<anyhow::Error> for Error {
+ fn from(e: anyhow::Error) -> Self {
+ Error::Other(e)
+ }
+}
+
+pub(crate) type Result<T> = std::result::Result<Json<T>, Error>;
+
+#[catch(default)]
+pub(crate) fn catch_all(status: Status, _r: &Request<'_>) -> Error {
+ match status.code {
+ 401 => Error::Unauthorized,
+ // these three are caused by Json<T> errors
+ 400 => Error::InvalidParameter,
+ 413 => Error::PayloadTooLarge,
+ 422 => Error::InvalidParameter,
+ // generic unauthorized instead of 404 for eg wrong method or nonexistant endpoints
+ 404 => Error::Unauthorized,
+ _ => {
+ error!("caught unexpected error {status}");
+ Error::UnexpectedStatus(status)
+ },
+ }
+}
+
+fn map_error(e: sqlx::Error) -> Error {
+ match &e {
+ sqlx::Error::RowNotFound => Error::InvalidParameter,
+ _ => Error::Other(anyhow!(e)),
+ }
+}
+
+// MISSING GET /v1/authorization
+// MISSING POST /v1/authorization
+// MISSING POST /v1/authorized-clients
+// MISSING POST /v1/authorized-clients/destroy
+// MISSING GET /v1/client/:id
+// MISSING POST /v1/introspect
+// MISSING GET /v1/jwks
+// MISSING POST /v1/key-data
+// MISSING POST /v1/token
+// MISSING POST /v1/verify
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct DestroyReq {
+ access_token: Option<OauthToken>,
+ refresh_token: Option<OauthToken>,
+ // NOTE this field does not exist in the spec, but fenix sends it
+ token: Option<OauthToken>,
+ // MISSING client_id
+ // MISSING client_secret
+ // MISSING refresh_token_id
+}
+
+#[post("/destroy", data = "<req>")]
+pub(crate) async fn destroy(
+ db: &DbConn,
+ req: Json<DestroyReq>,
+) -> std::result::Result<Json<Empty>, Error> {
+ // MISSING spec says basic auth is allowed, but nothing seems to use it
+ if let Some(t) = req.0.access_token {
+ db.delete_oauth_token(&t.hash()).await?;
+ }
+ if let Some(t) = req.0.refresh_token {
+ db.delete_oauth_token(&t.hash()).await?;
+ }
+ if let Some(t) = req.0.token {
+ db.delete_oauth_token(&t.hash()).await?;
+ }
+ Ok(EMPTY)
+}
+
+#[get("/jwks")]
+pub(crate) async fn jwks() -> Json<Empty> {
+ // HACK we need to return *something* for /jwks, otherwise PyFxA fails.
+ // since syncstorage-rs uses PyFxA to check oauth tokens this is bad.
+ EMPTY
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct VerifyReq {
+ token: OauthToken,
+}
+
+#[derive(Debug, Serialize)]
+pub(crate) struct VerifyResp {
+ user: UserID,
+ client_id: String,
+ scope: Vec<Scope<'static>>,
+ // MISSING generation
+ // MISSING profile_changed_at
+}
+
+#[post("/verify", data = "<req>")]
+pub(crate) async fn verify(db: &DbConn, req: Json<VerifyReq>) -> Result<VerifyResp> {
+ let token = db.get_access_token(&req.token.hash()).await.map_err(map_error)?;
+ Ok(Json(VerifyResp {
+ user: token.user_id,
+ client_id: token.client_id,
+ scope: token.scope.split().map(|s| s.into_owned()).collect::<Vec<_>>(),
+ }))
+}
diff --git a/src/api/profile/mod.rs b/src/api/profile/mod.rs
new file mode 100644
index 0000000..28d1e03
--- /dev/null
+++ b/src/api/profile/mod.rs
@@ -0,0 +1,324 @@
+use std::sync::Arc;
+
+use either::Either;
+use rocket::{
+ data::ToByteUnit,
+ http::{uri::Absolute, ContentType, Status},
+ response::{self, Responder},
+ serde::json::Json,
+ Request, Response, State,
+};
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+use sha2::{Digest, Sha256};
+use Either::{Left, Right};
+
+use crate::{
+ api::Empty,
+ auth::{Authenticated, WithBearer, AuthenticatedRequest},
+ cache::Immutable,
+ db::Db,
+ types::{oauth::Scope, UserID},
+ utils::DeferAction,
+};
+use crate::{db::DbConn, types::AvatarID, Config};
+use crate::{push::PushClient, types::Avatar};
+
+use super::EMPTY;
+
+// we don't provide any additional fields. some we can't provide anyway (eg
+// invalid parameter `validation`), others are implied by the request body (eg
+// account exists `email`), and *our* client doesn't care about them anyway
+#[derive(Debug)]
+pub(crate) enum Error {
+ Unauthorized,
+ InvalidParameter,
+ PayloadTooLarge,
+ NotFound,
+
+ // this is actually a response from the auth api (not the profile api),
+ // but firefox needs the *exact response* of this auth error to refresh
+ // profile fetch oauth tokens for its ui. :(
+ InvalidAuthToken,
+
+ Other(anyhow::Error),
+ UnexpectedStatus(Status),
+}
+
+#[rustfmt::skip]
+impl<'r> Responder<'r, 'static> for Error {
+ fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> {
+ let (code, errno, msg) = match self {
+ Error::Unauthorized => (Status::Forbidden, 100, "unauthorized"),
+ Error::InvalidParameter => (Status::BadRequest, 101, "invalid parameter in request body"),
+ Error::PayloadTooLarge => (Status::PayloadTooLarge, 999, "payload too large"),
+ Error::NotFound => (Status::NotFound, 999, "not found"),
+
+ Error::InvalidAuthToken => (Status::Unauthorized, 110, "invalid authentication token"),
+
+ Error::Other(e) => {
+ error!("non-api error during request: {:?}", e);
+ (Status::InternalServerError, 999, "internal error")
+ },
+ Error::UnexpectedStatus(s) => (s, 999, ""),
+ };
+ let body = json!({
+ "code": code.code,
+ "errno": errno,
+ "error": code.reason_lossy(),
+ "message": msg
+ });
+ Response::build_from(Json(body).respond_to(request)?).status(code).ok()
+ }
+}
+
+impl From<sqlx::Error> for Error {
+ fn from(e: sqlx::Error) -> Self {
+ Error::Other(anyhow!(e))
+ }
+}
+
+impl From<anyhow::Error> for Error {
+ fn from(e: anyhow::Error) -> Self {
+ Error::Other(e)
+ }
+}
+
+pub(crate) type Result<T> = std::result::Result<Json<T>, Error>;
+
+#[catch(default)]
+pub(crate) fn catch_all(status: Status, r: &Request<'_>) -> Error {
+ match status.code {
+ // these three are caused by Json<T> errors
+ 400 | 422 => Error::InvalidParameter,
+ 413 => Error::PayloadTooLarge,
+ // translate forbidden-because-token to the auth api error for firefox
+ 401 if r.invalid_token_used() => Error::InvalidAuthToken,
+ // generic unauthorized instead of 404 for eg wrong method or nonexistant endpoints
+ 401 | 404 => Error::Unauthorized,
+ _ => {
+ error!("caught unexpected error {status}");
+ Error::UnexpectedStatus(status)
+ },
+ }
+}
+
+// MISSING GET /v1/email
+// MISSING GET /v1/subscriptions
+// MISSING GET /v1/uid
+// MISSING GET /v1/display_name
+// MISSING DELETE /v1/cache/:uid
+// MISSING send profile:change webchannel event an avatar/name changes
+
+#[derive(Debug, Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct ProfileResp {
+ uid: Option<UserID>,
+ email: Option<String>,
+ locale: Option<String>,
+ amrValues: Option<Vec<String>>,
+ twoFactorAuthentication: bool,
+ displayName: Option<String>,
+ // NOTE spec does not exist, fxa-profile-server schema says this field is optional,
+ // but fenix exceptions if it's null.
+ // NOTE it also *must* be a valid url, or fenix crashes entirely.
+ avatar: Absolute<'static>,
+ avatarDefault: bool,
+ subscriptions: Option<Vec<String>>,
+}
+
+#[get("/profile")]
+pub(crate) async fn profile(
+ db: &DbConn,
+ cfg: &State<Config>,
+ auth: Authenticated<(), WithBearer>,
+) -> Result<ProfileResp> {
+ let has_scope = |s| auth.context.implies(&Scope::borrowed(s));
+
+ let user = db.get_user_by_id(&auth.session).await?;
+ let (avatar, avatar_default) = if has_scope("profile:avatar") {
+ match db.get_user_avatar_id(&auth.session).await? {
+ Some(id) => (uri!(cfg.avatars_prefix(), avatar_get_img(id = id.to_string())), false),
+ None => (
+ uri!(cfg.avatars_prefix(), avatar_get_img("00000000000000000000000000000000")),
+ true,
+ ),
+ }
+ } else {
+ (uri!(cfg.avatars_prefix(), avatar_get_img("00000000000000000000000000000000")), true)
+ };
+ Ok(Json(ProfileResp {
+ uid: if has_scope("profile:uid") { Some(auth.session) } else { None },
+ email: if has_scope("profile:email") { Some(user.email) } else { None },
+ locale: None,
+ amrValues: None,
+ twoFactorAuthentication: false,
+ displayName: if has_scope("profile:display_name") { user.display_name } else { None },
+ avatar,
+ avatarDefault: avatar_default,
+ subscriptions: None,
+ }))
+}
+
+#[derive(Debug, Deserialize)]
+#[allow(non_snake_case)]
+pub(crate) struct DisplayNameReq {
+ displayName: String,
+}
+
+#[post("/display_name", data = "<req>")]
+pub(crate) async fn display_name_post(
+ db: &DbConn,
+ db_pool: &Db,
+ pc: &State<Arc<PushClient>>,
+ defer: &DeferAction,
+ req: Authenticated<DisplayNameReq, WithBearer>,
+) -> Result<Empty> {
+ if !req.context.implies(&Scope::borrowed("profile:display_name:write")) {
+ return Err(Error::Unauthorized);
+ }
+
+ db.set_user_name(&req.session, &req.body.displayName).await?;
+ match db.get_devices(&req.session).await {
+ Ok(devs) => defer.spawn_after_success("api::profile/display_name(post)", {
+ let (pc, db) = (Arc::clone(pc), db_pool.clone());
+ async move {
+ let db = db.begin().await?;
+ pc.profile_updated(&db, &devs).await;
+ db.commit().await?;
+ Ok(())
+ }
+ }),
+ Err(e) => warn!("profile_updated push failed: {e}"),
+ }
+ Ok(EMPTY)
+}
+
+#[derive(Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct AvatarResp {
+ id: AvatarID,
+ avatarDefault: bool,
+ avatar: Absolute<'static>,
+}
+
+#[get("/avatar")]
+pub(crate) async fn avatar_get(
+ db: &DbConn,
+ cfg: &State<Config>,
+ req: Authenticated<(), WithBearer>,
+) -> Result<AvatarResp> {
+ if !req.context.implies(&Scope::borrowed("profile:avatar")) {
+ return Err(Error::Unauthorized);
+ }
+
+ let resp = match db.get_user_avatar_id(&req.session).await? {
+ Some(id) => {
+ let url = uri!(cfg.avatars_prefix(), avatar_get_img(id = id.to_string()));
+ AvatarResp { id, avatarDefault: false, avatar: url }
+ },
+ None => {
+ let url =
+ uri!(cfg.avatars_prefix(), avatar_get_img("00000000000000000000000000000000"));
+ AvatarResp { id: AvatarID([0; 16]), avatarDefault: true, avatar: url }
+ },
+ };
+ Ok(Json(resp))
+}
+
+#[get("/<id>")]
+pub(crate) async fn avatar_get_img(
+ db: &DbConn,
+ id: &str,
+) -> std::result::Result<(ContentType, Immutable<Either<Vec<u8>, &'static [u8]>>), Error> {
+ let id = id.parse().map_err(|_| Error::NotFound)?;
+
+ if id == AvatarID([0; 16]) {
+ return Ok((
+ ContentType::SVG,
+ Immutable(Right(include_bytes!("../../../Raven-Silhouette.svg"))),
+ ));
+ }
+
+ match db.get_user_avatar(&id).await? {
+ Some(avatar) => {
+ let ct = avatar.content_type.parse().expect("invalid content type in db");
+ Ok((ct, Immutable(Left(avatar.data))))
+ },
+ None => Err(Error::NotFound),
+ }
+}
+
+#[derive(Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct AvatarUploadResp {
+ url: Absolute<'static>,
+}
+
+#[post("/avatar/upload", data = "<data>")]
+pub(crate) async fn avatar_upload(
+ db: &DbConn,
+ db_pool: &Db,
+ pc: &State<Arc<PushClient>>,
+ defer: &DeferAction,
+ cfg: &State<Config>,
+ ct: &ContentType,
+ req: Authenticated<(), WithBearer>,
+ data: Vec<u8>,
+) -> Result<AvatarUploadResp> {
+ if !req.context.implies(&Scope::borrowed("profile:avatar:write")) {
+ return Err(Error::Unauthorized);
+ }
+ if data.len() >= 128.kibibytes() {
+ return Err(Error::PayloadTooLarge);
+ }
+
+ if !ct.is_png()
+ && !ct.is_gif()
+ && !ct.is_bmp()
+ && !ct.is_jpeg()
+ && !ct.is_webp()
+ && !ct.is_avif()
+ && !ct.is_svg()
+ {
+ return Err(Error::InvalidParameter);
+ }
+
+ let mut sha = Sha256::new();
+ sha.update(&req.session.0);
+ sha.update(&data);
+ let id = AvatarID(sha.finalize()[0..16].try_into().unwrap());
+
+ db.set_user_avatar(&req.session, Avatar { id: id.clone(), data, content_type: ct.to_string() })
+ .await?;
+ match db.get_devices(&req.session).await {
+ Ok(devs) => defer.spawn_after_success("api::profile/avatar/upload(post)", {
+ let (pc, db) = (Arc::clone(pc), db_pool.clone());
+ async move {
+ let db = db.begin().await?;
+ pc.profile_updated(&db, &devs).await;
+ db.commit().await?;
+ Ok(())
+ }
+ }),
+ Err(e) => warn!("profile_updated push failed: {e}"),
+ }
+
+ let url = uri!(cfg.avatars_prefix(), avatar_get_img(id = id.to_string()));
+ Ok(Json(AvatarUploadResp { url }))
+}
+
+#[delete("/avatar/<id>")]
+pub(crate) async fn avatar_delete(
+ db: &DbConn,
+ id: &str,
+ req: Authenticated<(), WithBearer>,
+) -> Result<Empty> {
+ if !req.context.implies(&Scope::borrowed("profile:avatar:write")) {
+ return Err(Error::Unauthorized);
+ }
+ let id = id.parse().map_err(|_| Error::NotFound)?;
+
+ db.delete_user_avatar(&req.session, &id).await?;
+ Ok(EMPTY)
+}