Skip to content

Commit

Permalink
Merge pull request #243 from NetsBlox/229-magic-links
Browse files Browse the repository at this point in the history
Add support for magic links. Close #229
  • Loading branch information
brollb authored Feb 6, 2024
2 parents 1c27c5e + 593f059 commit f1aa357
Show file tree
Hide file tree
Showing 21 changed files with 1,050 additions and 195 deletions.
3 changes: 3 additions & 0 deletions crates/api-common/bindings/CreateMagicLinkData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export interface CreateMagicLinkData { email: string, redirectUri?: string, }
3 changes: 3 additions & 0 deletions crates/api-common/bindings/MagicLinkId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type MagicLinkId = string;
5 changes: 5 additions & 0 deletions crates/api-common/bindings/MagicLinkLoginData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ClientId } from "./ClientId";
import type { MagicLinkId } from "./MagicLinkId";

export interface MagicLinkLoginData { linkId: MagicLinkId, username: string, clientId?: ClientId, redirectUri?: string, }
10 changes: 8 additions & 2 deletions crates/api-common/src/bson.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::{
oauth, FriendInvite, FriendLinkState, GroupId, InvitationState, LinkedAccount, ProjectId,
PublishState, RoleMetadata, SaveState, ServiceHost, ServiceHostScope, UserRole,
oauth, FriendInvite, FriendLinkState, GroupId, InvitationState, LinkedAccount, MagicLinkId,
ProjectId, PublishState, RoleMetadata, SaveState, ServiceHost, ServiceHostScope, UserRole,
};
use bson::{doc, Bson, DateTime};

Expand Down Expand Up @@ -159,6 +159,12 @@ impl From<ServiceHostScope> for Bson {
}
}

impl From<MagicLinkId> for Bson {
fn from(id: MagicLinkId) -> Bson {
Bson::String(id.0)
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
35 changes: 35 additions & 0 deletions crates/api-common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,41 @@ pub enum SendMessageTarget {
},
}

#[derive(Serialize, Deserialize, Clone, Debug, TS)]
#[ts(export)]
pub struct MagicLinkId(String);

impl MagicLinkId {
pub fn new(id: String) -> Self {
Self(id)
}

pub fn as_str(&self) -> &str {
&self.0
}
}

#[derive(Serialize, Deserialize, Clone, Debug, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct MagicLinkLoginData {
pub link_id: MagicLinkId,
pub username: String,
#[ts(optional)]
pub client_id: Option<ClientId>,
#[ts(optional)]
pub redirect_uri: Option<String>,
}

#[derive(Serialize, Deserialize, Clone, Debug, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct CreateMagicLinkData {
pub email: String,
#[ts(optional)]
pub redirect_uri: Option<String>,
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
8 changes: 4 additions & 4 deletions crates/api/src/common.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
pub use netsblox_api_common::{
oauth, AppId, AuthorizedServiceHost, BannedAccount, ClientConfig, ClientId, ClientInfo,
ClientState, ClientStateData, CollaborationInvite, CreateLibraryData, CreateProjectData,
Credentials, ExternalClient, ExternalClientState, Group, GroupId, InvitationId,
InvitationState, LibraryMetadata, LinkedAccount, LoginRequest, NewUser, Project, ProjectId,
PublishState, RoleData, RoleId, RoomState, SaveState, ServiceHost, ServiceHostScope,
ClientState, ClientStateData, CollaborationInvite, CreateLibraryData, CreateMagicLinkData,
CreateProjectData, Credentials, ExternalClient, ExternalClientState, Group, GroupId,
InvitationId, InvitationState, LibraryMetadata, LinkedAccount, LoginRequest, NewUser, Project,
ProjectId, PublishState, RoleData, RoleId, RoomState, SaveState, ServiceHost, ServiceHostScope,
ServiceSettings, UpdateProjectData, UpdateRoleData, UserRole,
};
pub use netsblox_api_common::{
Expand Down
18 changes: 17 additions & 1 deletion crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ pub mod error;

use crate::common::*;
use futures_util::SinkExt;
use netsblox_api_common::{CreateGroupData, ServiceHostScope, UpdateGroupData};
use netsblox_api_common::{
CreateGroupData, CreateMagicLinkData, ServiceHostScope, UpdateGroupData,
};
use reqwest::{self, Method, RequestBuilder, Response};
use serde::{Deserialize, Serialize};
pub use serde_json;
Expand Down Expand Up @@ -249,6 +251,20 @@ impl Client {
Ok(response.json::<BannedAccount>().await.unwrap())
}

/// Send a magic link to the given email address. Usable for any user associated with the
/// address.
pub async fn send_magic_link(&self, data: &CreateMagicLinkData) -> Result<(), error::Error> {
let response = self
.request(Method::POST, "/magic-links/")
.json(data)
.send()
.await
.map_err(error::Error::RequestError)?;

check_response(response).await?;
Ok(())
}

// Project management
pub async fn create_project(
&self,
Expand Down
40 changes: 37 additions & 3 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ use clap::{Parser, Subcommand};
use futures_util::StreamExt;
use inquire::{Confirm, Password, PasswordDisplayMode};
use netsblox_api::common::{
oauth, ClientId, CreateProjectData, Credentials, FriendLinkState, InvitationState,
LinkedAccount, ProjectId, PublishState, RoleData, SaveState, ServiceHost, ServiceHostScope,
UserRole,
oauth, ClientId, CreateMagicLinkData, CreateProjectData, Credentials, FriendLinkState,
InvitationState, LinkedAccount, ProjectId, PublishState, RoleData, SaveState, ServiceHost,
ServiceHostScope, UserRole,
};
use netsblox_api::{self, serde_json, Client};
use std::path::Path;
Expand Down Expand Up @@ -97,6 +97,19 @@ enum Users {
},
}

/// Send "magic links" for password-less sign in
#[derive(Subcommand, Debug)]
enum MagicLinks {
/// Send a magic link to the given email. Allows login by any user associated with the email address.
Send {
/// Email to send the magic link to.
email: String,
/// Redirect the user to this URL after login
#[clap(short, long)]
url: Option<String>,
},
}

/// Manage projects (or roles)
#[derive(Subcommand, Debug)]
enum Projects {
Expand Down Expand Up @@ -573,6 +586,12 @@ struct UserCommand {
subcmd: Users,
}

#[derive(Parser, Debug)]
struct MagicLinkCommand {
#[clap(subcommand)]
subcmd: MagicLinks,
}

#[derive(Parser, Debug)]
struct ProjectCommand {
#[clap(subcommand)]
Expand Down Expand Up @@ -636,6 +655,8 @@ enum Command {
Logout,
#[clap(alias = "user")]
Users(UserCommand),
#[clap(alias = "magic-link")]
MagicLinks(MagicLinkCommand),
#[clap(alias = "project")]
Projects(ProjectCommand),
Network(NetworkCommand),
Expand Down Expand Up @@ -708,6 +729,9 @@ async fn do_command(mut cfg: Config, args: Cli) -> Result<(), error::Error> {
let login_required = match &args.cmd {
Command::Login => true,
Command::Logout => false,
Command::MagicLinks(cmd) => match &cmd.subcmd {
MagicLinks::Send { .. } => false,
},
Command::Users(cmd) => match &cmd.subcmd {
Users::Create { .. } => false,
_ => !is_logged_in,
Expand Down Expand Up @@ -837,6 +861,16 @@ async fn do_command(mut cfg: Config, args: Cli) -> Result<(), error::Error> {
client.unban_user(username).await?;
}
},
Command::MagicLinks(cmd) => match &cmd.subcmd {
MagicLinks::Send { email, url } => {
let data = CreateMagicLinkData {
email: email.clone(),
redirect_uri: url.to_owned(),
};
client.send_magic_link(&data).await?;
println!("Magic link sent to {}!", email);
}
},
Command::Projects(cmd) => match &cmd.subcmd {
Projects::Import {
filename,
Expand Down
31 changes: 31 additions & 0 deletions crates/cloud-common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,37 @@ pub(crate) fn sha512(text: &str) -> String {
hex::encode(hash)
}

/// A magic link is used for password-less login. It has no
/// api version since exposing it via the api would be a pretty
/// serious security vulnerability.
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct MagicLink {
pub id: api::MagicLinkId,
pub email: String,
pub created_at: DateTime,
}

impl MagicLink {
pub fn new(email: String) -> Self {
Self {
id: api::MagicLinkId::new(Uuid::new_v4().to_string()),
email,
created_at: DateTime::now(),
}
}
}

impl From<MagicLink> for Bson {
fn from(link: MagicLink) -> Bson {
Bson::Document(doc! {
"id": link.id,
"email": link.email,
"createdAt": link.created_at,
})
}
}

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

0 comments on commit f1aa357

Please sign in to comment.