From c370344bf427d25c6dc77e91f2787a40312e293f Mon Sep 17 00:00:00 2001 From: avdb13 Date: Tue, 17 Dec 2024 20:14:34 +0000 Subject: [PATCH 01/22] split up authentication into flows --- crates/authifier/src/config/passwords.rs | 1 - crates/authifier/src/database/dummy.rs | 5 ++- crates/authifier/src/derive/rocket.rs | 1 + crates/authifier/src/impl/account.rs | 38 +++++++++++++------ crates/authifier/src/models/account.rs | 37 ++++++++++++++---- crates/authifier/src/result.rs | 2 + .../src/routes/account/change_password.rs | 10 +++-- .../src/routes/account/mod.rs | 2 +- .../src/routes/account/password_reset.rs | 11 ++++-- .../src/routes/mfa/create_ticket.rs | 6 ++- .../src/routes/mfa/fetch_recovery.rs | 10 +++-- .../src/routes/mfa/fetch_status.rs | 10 +++-- .../src/routes/mfa/generate_recovery.rs | 16 ++++++-- .../src/routes/mfa/get_mfa_methods.rs | 8 +++- .../src/routes/mfa/totp_disable.rs | 10 +++-- .../src/routes/mfa/totp_enable.rs | 10 +++-- .../src/routes/mfa/totp_generate_secret.rs | 10 +++-- .../src/routes/session/login.rs | 15 ++++++-- crates/rocket_authifier/src/test.rs | 7 +++- 19 files changed, 152 insertions(+), 57 deletions(-) diff --git a/crates/authifier/src/config/passwords.rs b/crates/authifier/src/config/passwords.rs index b8540a2..38ef0ea 100644 --- a/crates/authifier/src/config/passwords.rs +++ b/crates/authifier/src/config/passwords.rs @@ -18,7 +18,6 @@ pub enum PasswordScanning { HIBP { api_key: String }, } - #[cfg(feature = "pwned100k")] lazy_static! { /// Top 100k compromised passwords diff --git a/crates/authifier/src/database/dummy.rs b/crates/authifier/src/database/dummy.rs index 62d6d25..7b1e38f 100644 --- a/crates/authifier/src/database/dummy.rs +++ b/crates/authifier/src/database/dummy.rs @@ -1,5 +1,8 @@ use crate::{ - models::{Account, DeletionInfo, EmailVerification, Invite, MFATicket, Session}, + models::{ + Account, AuthFlow, DeletionInfo, EmailVerification, Invite, MFATicket, PasswordAuth, + Session, + }, Error, Result, Success, }; diff --git a/crates/authifier/src/derive/rocket.rs b/crates/authifier/src/derive/rocket.rs index 5c3c578..9ba64e7 100644 --- a/crates/authifier/src/derive/rocket.rs +++ b/crates/authifier/src/derive/rocket.rs @@ -46,6 +46,7 @@ impl<'r> Responder<'r, 'static> for Error { Error::LockedOut => Status::Forbidden, Error::TotpAlreadyEnabled => Status::BadRequest, Error::DisallowedMFAMethod => Status::BadRequest, + Error::NotAvailable => Status::NotFound, }; // Serialize the error data structure into JSON. diff --git a/crates/authifier/src/impl/account.rs b/crates/authifier/src/impl/account.rs index d78f709..b69c02f 100644 --- a/crates/authifier/src/impl/account.rs +++ b/crates/authifier/src/impl/account.rs @@ -4,8 +4,8 @@ use iso8601_timestamp::Timestamp; use crate::{ config::EmailVerificationConfig, models::{ - totp::Totp, Account, DeletionInfo, EmailVerification, MFAMethod, MFAResponse, MFATicket, - PasswordReset, Session, + totp::Totp, Account, AuthFlow, DeletionInfo, EmailVerification, MFAMethod, MFAResponse, + MFATicket, PasswordAuth, PasswordReset, Session, }, util::{hash_password, normalise_email}, Authifier, AuthifierEvent, Error, Result, Success, @@ -51,15 +51,17 @@ impl Account { email, email_normalised, - password, disabled: false, verification: EmailVerification::Verified, - password_reset: None, deletion: None, lockout: None, - mfa: Default::default(), + auth_flow: AuthFlow::Password(PasswordAuth { + password, + mfa: Default::default(), + password_reset: None, + }), }; // Send email verification @@ -206,7 +208,11 @@ impl Account { }), )?; - self.password_reset = Some(PasswordReset { + let AuthFlow::Password(auth) = &mut self.auth_flow else { + return Ok(()); + }; + + auth.password_reset = Some(PasswordReset { token, expiry: Timestamp::UNIX_EPOCH + iso8601_timestamp::Duration::milliseconds( @@ -260,7 +266,11 @@ impl Account { /// Verify a user's password is correct pub fn verify_password(&self, plaintext_password: &str) -> Success { - argon2::verify_encoded(&self.password, plaintext_password.as_bytes()) + let AuthFlow::Password(auth) = &self.auth_flow else { + return Ok(()); + }; + + argon2::verify_encoded(&auth.password, plaintext_password.as_bytes()) .map(|v| { if v { Ok(()) @@ -280,7 +290,11 @@ impl Account { response: MFAResponse, ticket: Option, ) -> Success { - let allowed_methods = self.mfa.get_methods(); + let AuthFlow::Password(auth) = &mut self.auth_flow else { + return Ok(()); + }; + + let allowed_methods = auth.mfa.get_methods(); match response { MFAResponse::Password { password } => { @@ -292,7 +306,7 @@ impl Account { } MFAResponse::Totp { totp_code } => { if allowed_methods.contains(&MFAMethod::Totp) { - if let Totp::Enabled { .. } = &self.mfa.totp_token { + if let Totp::Enabled { .. } = &auth.mfa.totp_token { // Use TOTP code at generation if applicable if let Some(ticket) = ticket { if let Some(code) = ticket.last_totp_code { @@ -303,7 +317,7 @@ impl Account { } // Otherwise read current TOTP token - if self.mfa.totp_token.generate_code()? == totp_code { + if auth.mfa.totp_token.generate_code()? == totp_code { Ok(()) } else { Err(Error::InvalidToken) @@ -317,13 +331,13 @@ impl Account { } MFAResponse::Recovery { recovery_code } => { if allowed_methods.contains(&MFAMethod::Recovery) { - if let Some(index) = self + if let Some(index) = auth .mfa .recovery_codes .iter() .position(|x| x == &recovery_code) { - self.mfa.recovery_codes.remove(index); + auth.mfa.recovery_codes.remove(index); self.save(authifier).await } else { Err(Error::InvalidToken) diff --git a/crates/authifier/src/models/account.rs b/crates/authifier/src/models/account.rs index 1c265ad..36a7ef5 100644 --- a/crates/authifier/src/models/account.rs +++ b/crates/authifier/src/models/account.rs @@ -48,6 +48,33 @@ pub struct Lockout { pub expiry: Option, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PasswordAuth { + /// Argon2 hashed password + pub password: String, + + /// Multi-factor authentication information + pub mfa: MultiFactorAuthentication, + + /// Password reset information + pub password_reset: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SSOAuth { + /// Auth Provider + pub idp_id: String, + + /// Subject ID + pub sub_id: serde_json::Value, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub enum AuthFlow { + Password(PasswordAuth), + SSO(SSOAuth), +} + /// Account model #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Account { @@ -63,9 +90,6 @@ pub struct Account { /// (see https://github.com/insertish/authifier/#how-does-authifier-work) pub email_normalised: String, - /// Argon2 hashed password - pub password: String, - /// Whether the account is disabled #[serde(default)] pub disabled: bool, @@ -73,15 +97,12 @@ pub struct Account { /// Email verification status pub verification: EmailVerification, - /// Password reset information - pub password_reset: Option, - /// Account deletion information pub deletion: Option, /// Account lockout pub lockout: Option, - /// Multi-factor authentication information - pub mfa: MultiFactorAuthentication, + /// Authentication flow + pub auth_flow: AuthFlow, } diff --git a/crates/authifier/src/result.rs b/crates/authifier/src/result.rs index d9ee1cf..060fec3 100644 --- a/crates/authifier/src/result.rs +++ b/crates/authifier/src/result.rs @@ -34,6 +34,8 @@ pub enum Error { TotpAlreadyEnabled, DisallowedMFAMethod, + + NotAvailable, } pub type Result = std::result::Result; diff --git a/crates/rocket_authifier/src/routes/account/change_password.rs b/crates/rocket_authifier/src/routes/account/change_password.rs index a8ddfcb..0469272 100644 --- a/crates/rocket_authifier/src/routes/account/change_password.rs +++ b/crates/rocket_authifier/src/routes/account/change_password.rs @@ -1,8 +1,8 @@ //! Change account password. //! PATCH /account/change/password -use authifier::models::Account; +use authifier::models::{Account, AuthFlow}; use authifier::util::hash_password; -use authifier::{Authifier, Result}; +use authifier::{Authifier, Error, Result}; use rocket::serde::json::Json; use rocket::State; use rocket_empty::EmptyResponse; @@ -38,8 +38,12 @@ pub async fn change_password( // Ensure given password is correct account.verify_password(&data.current_password)?; + let AuthFlow::Password(auth) = &mut account.auth_flow else { + return Err(Error::NotAvailable); + }; + // Hash and replace password - account.password = hash_password(data.password)?; + auth.password = hash_password(data.password)?; // Commit to database account.save(authifier).await.map(|_| EmptyResponse) diff --git a/crates/rocket_authifier/src/routes/account/mod.rs b/crates/rocket_authifier/src/routes/account/mod.rs index 19d1087..6c44e5e 100644 --- a/crates/rocket_authifier/src/routes/account/mod.rs +++ b/crates/rocket_authifier/src/routes/account/mod.rs @@ -1,5 +1,5 @@ -use rocket::Route; use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi; +use rocket::Route; pub mod change_email; pub mod change_password; diff --git a/crates/rocket_authifier/src/routes/account/password_reset.rs b/crates/rocket_authifier/src/routes/account/password_reset.rs index d21c4d3..2e72452 100644 --- a/crates/rocket_authifier/src/routes/account/password_reset.rs +++ b/crates/rocket_authifier/src/routes/account/password_reset.rs @@ -1,7 +1,8 @@ //! Confirm a password reset. //! PATCH /account/reset_password +use authifier::models::AuthFlow; use authifier::util::hash_password; -use authifier::{Authifier, Result}; +use authifier::{Authifier, Error, Result}; use rocket::serde::json::Json; use rocket::State; use rocket_empty::EmptyResponse; @@ -44,9 +45,13 @@ pub async fn password_reset( .assert_safe(&data.password) .await?; + let AuthFlow::Password(auth) = &mut account.auth_flow else { + return Err(Error::NotAvailable); + }; + // Update the account - account.password = hash_password(data.password)?; - account.password_reset = None; + auth.password = hash_password(data.password)?; + auth.password_reset = None; account.lockout = None; // Commit to database diff --git a/crates/rocket_authifier/src/routes/mfa/create_ticket.rs b/crates/rocket_authifier/src/routes/mfa/create_ticket.rs index dd10ab5..26fbaa4 100644 --- a/crates/rocket_authifier/src/routes/mfa/create_ticket.rs +++ b/crates/rocket_authifier/src/routes/mfa/create_ticket.rs @@ -1,6 +1,6 @@ //! Create a new MFA ticket or validate an existing one. //! PUT /mfa/ticket -use authifier::models::{Account, MFAResponse, MFATicket, UnvalidatedTicket}; +use authifier::models::{Account, AuthFlow, MFAResponse, MFATicket, UnvalidatedTicket}; use authifier::{Authifier, Error, Result}; use rocket::serde::json::Json; use rocket::State; @@ -27,6 +27,10 @@ pub async fn create_ticket( _ => return Err(Error::InvalidToken), }; + let AuthFlow::Password(_) = &account.auth_flow else { + return Err(Error::NotAvailable); + }; + // Validate the MFA response account .consume_mfa_response(authifier, data.into_inner(), None) diff --git a/crates/rocket_authifier/src/routes/mfa/fetch_recovery.rs b/crates/rocket_authifier/src/routes/mfa/fetch_recovery.rs index 675b0b0..de868eb 100644 --- a/crates/rocket_authifier/src/routes/mfa/fetch_recovery.rs +++ b/crates/rocket_authifier/src/routes/mfa/fetch_recovery.rs @@ -1,8 +1,8 @@ //! Fetch recovery codes for an account. //! POST /mfa/recovery use authifier::{ - models::{Account, ValidatedTicket}, - Result, + models::{Account, AuthFlow, ValidatedTicket}, + Error, Result, }; use rocket::serde::json::Json; @@ -15,7 +15,11 @@ pub async fn fetch_recovery( account: Account, _ticket: ValidatedTicket, ) -> Result>> { - Ok(Json(account.mfa.recovery_codes)) + let AuthFlow::Password(auth) = &account.auth_flow else { + return Err(Error::NotAvailable); + }; + + Ok(Json(auth.mfa.recovery_codes.clone())) } #[cfg(test)] diff --git a/crates/rocket_authifier/src/routes/mfa/fetch_status.rs b/crates/rocket_authifier/src/routes/mfa/fetch_status.rs index 68dca9b..aeed13e 100644 --- a/crates/rocket_authifier/src/routes/mfa/fetch_status.rs +++ b/crates/rocket_authifier/src/routes/mfa/fetch_status.rs @@ -1,8 +1,8 @@ //! Fetch MFA status of an account. //! GET /mfa use authifier::{ - models::{Account, MultiFactorAuthentication}, - Result, + models::{Account, AuthFlow, MultiFactorAuthentication}, + Error, Result, }; use rocket::serde::json::Json; @@ -36,7 +36,11 @@ impl From for MultiFactorStatus { #[openapi(tag = "MFA")] #[get("/")] pub async fn fetch_status(account: Account) -> Result> { - Ok(Json(account.mfa.into())) + let AuthFlow::Password(auth) = &account.auth_flow else { + return Err(Error::NotAvailable); + }; + + Ok(Json(auth.mfa.clone().into())) } #[cfg(test)] diff --git a/crates/rocket_authifier/src/routes/mfa/generate_recovery.rs b/crates/rocket_authifier/src/routes/mfa/generate_recovery.rs index e9fc213..0014075 100644 --- a/crates/rocket_authifier/src/routes/mfa/generate_recovery.rs +++ b/crates/rocket_authifier/src/routes/mfa/generate_recovery.rs @@ -1,7 +1,7 @@ //! Re-generate recovery codes for an account. //! PATCH /mfa/recovery -use authifier::models::{Account, ValidatedTicket}; -use authifier::{Authifier, Result}; +use authifier::models::{Account, AuthFlow, ValidatedTicket}; +use authifier::{Authifier, Error, Result}; use rocket::serde::json::Json; use rocket::State; @@ -15,14 +15,22 @@ pub async fn generate_recovery( mut account: Account, _ticket: ValidatedTicket, ) -> Result>> { + let AuthFlow::Password(auth) = &mut account.auth_flow else { + return Err(Error::NotAvailable); + }; + // Generate new codes - account.mfa.generate_recovery_codes(); + auth.mfa.generate_recovery_codes(); // Save account model account.save(authifier).await?; + let AuthFlow::Password(auth) = &account.auth_flow else { + return Err(Error::NotAvailable); + }; + // Return them to the user - Ok(Json(account.mfa.recovery_codes)) + Ok(Json(auth.mfa.recovery_codes.clone())) } #[cfg(test)] diff --git a/crates/rocket_authifier/src/routes/mfa/get_mfa_methods.rs b/crates/rocket_authifier/src/routes/mfa/get_mfa_methods.rs index c93009e..59ea59a 100644 --- a/crates/rocket_authifier/src/routes/mfa/get_mfa_methods.rs +++ b/crates/rocket_authifier/src/routes/mfa/get_mfa_methods.rs @@ -1,6 +1,6 @@ //! Fetch available MFA methods. //! GET /mfa/methods -use authifier::models::{Account, MFAMethod}; +use authifier::models::{Account, AuthFlow, MFAMethod}; use rocket::serde::json::Json; /// # Get MFA Methods @@ -9,7 +9,11 @@ use rocket::serde::json::Json; #[openapi(tag = "MFA")] #[get("/methods")] pub async fn get_mfa_methods(account: Account) -> Json> { - Json(account.mfa.get_methods()) + let AuthFlow::Password(auth) = &account.auth_flow else { + return Json(Vec::new()); + }; + + Json(auth.mfa.get_methods()) } #[cfg(test)] diff --git a/crates/rocket_authifier/src/routes/mfa/totp_disable.rs b/crates/rocket_authifier/src/routes/mfa/totp_disable.rs index 2e094ca..ff6e010 100644 --- a/crates/rocket_authifier/src/routes/mfa/totp_disable.rs +++ b/crates/rocket_authifier/src/routes/mfa/totp_disable.rs @@ -1,8 +1,8 @@ //! Disable TOTP 2FA. //! DELETE /mfa/totp use authifier::models::totp::Totp; -use authifier::models::{Account, ValidatedTicket}; -use authifier::{Authifier, Result}; +use authifier::models::{Account, AuthFlow, ValidatedTicket}; +use authifier::{Authifier, Error, Result}; use rocket::State; use rocket_empty::EmptyResponse; @@ -16,8 +16,12 @@ pub async fn totp_disable( mut account: Account, _ticket: ValidatedTicket, ) -> Result { + let AuthFlow::Password(auth) = &mut account.auth_flow else { + return Err(Error::NotAvailable); + }; + // Disable TOTP - account.mfa.totp_token = Totp::Disabled; + auth.mfa.totp_token = Totp::Disabled; // Save model to database account.save(authifier).await.map(|_| EmptyResponse) diff --git a/crates/rocket_authifier/src/routes/mfa/totp_enable.rs b/crates/rocket_authifier/src/routes/mfa/totp_enable.rs index 0c9e7f6..6b202fd 100644 --- a/crates/rocket_authifier/src/routes/mfa/totp_enable.rs +++ b/crates/rocket_authifier/src/routes/mfa/totp_enable.rs @@ -1,7 +1,7 @@ //! Generate a new secret for TOTP. //! POST /mfa/totp -use authifier::models::{Account, MFAResponse}; -use authifier::{Authifier, Result}; +use authifier::models::{Account, AuthFlow, MFAResponse}; +use authifier::{Authifier, Error, Result}; use rocket::serde::json::Json; use rocket::State; use rocket_empty::EmptyResponse; @@ -16,8 +16,12 @@ pub async fn totp_enable( mut account: Account, data: Json, ) -> Result { + let AuthFlow::Password(auth) = &mut account.auth_flow else { + return Err(Error::NotAvailable); + }; + // Enable TOTP 2FA - account.mfa.enable_totp(data.into_inner())?; + auth.mfa.enable_totp(data.into_inner())?; // Save model to database account.save(authifier).await.map(|_| EmptyResponse) diff --git a/crates/rocket_authifier/src/routes/mfa/totp_generate_secret.rs b/crates/rocket_authifier/src/routes/mfa/totp_generate_secret.rs index 0319e73..857bb56 100644 --- a/crates/rocket_authifier/src/routes/mfa/totp_generate_secret.rs +++ b/crates/rocket_authifier/src/routes/mfa/totp_generate_secret.rs @@ -1,7 +1,7 @@ //! Generate a new secret for TOTP. //! POST /mfa/totp -use authifier::models::{Account, ValidatedTicket}; -use authifier::{Authifier, Result}; +use authifier::models::{Account, AuthFlow, ValidatedTicket}; +use authifier::{Authifier, Error, Result}; use rocket::serde::json::Json; use rocket::State; @@ -21,8 +21,12 @@ pub async fn totp_generate_secret( mut account: Account, _ticket: ValidatedTicket, ) -> Result> { + let AuthFlow::Password(auth) = &mut account.auth_flow else { + return Err(Error::NotAvailable); + }; + // Generate a new secret - let secret = account.mfa.generate_new_totp_secret()?; + let secret = auth.mfa.generate_new_totp_secret()?; // Save model to database account.save(authifier).await?; diff --git a/crates/rocket_authifier/src/routes/session/login.rs b/crates/rocket_authifier/src/routes/session/login.rs index 82c7164..cb5092c 100644 --- a/crates/rocket_authifier/src/routes/session/login.rs +++ b/crates/rocket_authifier/src/routes/session/login.rs @@ -3,7 +3,9 @@ use std::ops::Add; use std::time::Duration; -use authifier::models::{EmailVerification, Lockout, MFAMethod, MFAResponse, MFATicket, Session}; +use authifier::models::{ + AuthFlow, EmailVerification, Lockout, MFAMethod, MFAResponse, MFATicket, Session, +}; use authifier::util::normalise_email; use authifier::{Authifier, Error, Result}; use iso8601_timestamp::Timestamp; @@ -73,6 +75,11 @@ pub async fn login( .find_account_by_normalised_email(&email_normalised) .await? { + // Make sure the account uses password authentication + let AuthFlow::Password(auth) = &account.auth_flow else { + return Err(Error::NotAvailable); + }; + // Make sure the account has been verified if let EmailVerification::Pending { .. } = account.verification { return Err(Error::UnverifiedAccount); @@ -140,16 +147,16 @@ pub async fn login( } // Check whether an MFA step is required - if account.mfa.is_active() { + if auth.mfa.is_active() { // Create a new ticket let mut ticket = MFATicket::new(account.id, false); - ticket.populate(&account.mfa).await; + ticket.populate(&auth.mfa).await; ticket.save(authifier).await?; // Return applicable methods return Ok(Json(ResponseLogin::MFA { ticket: ticket.token, - allowed_methods: account.mfa.get_methods(), + allowed_methods: auth.mfa.get_methods(), })); } diff --git a/crates/rocket_authifier/src/test.rs b/crates/rocket_authifier/src/test.rs index 8d275f5..25c6c64 100644 --- a/crates/rocket_authifier/src/test.rs +++ b/crates/rocket_authifier/src/test.rs @@ -1,6 +1,9 @@ pub use authifier::{ - config::*, database::{MongoDb, DummyDb}, models::totp::*, models::*, Authifier, AuthifierEvent, Config, - Database, Error, Migration, Result, + config::*, + database::{DummyDb, MongoDb}, + models::totp::*, + models::*, + Authifier, AuthifierEvent, Config, Database, Error, Migration, Result, }; pub use mongodb::Client; pub use rocket::http::{ContentType, Status}; From d2b099a13aa188f7bbe8062e7eca930dc1a0c443 Mon Sep 17 00:00:00 2001 From: avdb13 Date: Tue, 17 Dec 2024 20:18:15 +0000 Subject: [PATCH 02/22] introduce callback model --- crates/authifier/src/models/callback.rs | 20 ++++++++++++++++++++ crates/authifier/src/models/mod.rs | 2 ++ 2 files changed, 22 insertions(+) create mode 100644 crates/authifier/src/models/callback.rs diff --git a/crates/authifier/src/models/callback.rs b/crates/authifier/src/models/callback.rs new file mode 100644 index 0000000..ac3be94 --- /dev/null +++ b/crates/authifier/src/models/callback.rs @@ -0,0 +1,20 @@ +/// Single sign-on auth callback +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(JsonSchema))] +pub struct Callback { + /// Unique Id + #[serde(rename = "_id")] + pub id: String, + + /// The authorization provider ID. + pub idp_id: String, + + /// The URI where the end-user will be redirected after authorization. + pub redirect_uri: String, + + /// A string to mitigate replay attacks. + pub nonce: Option, + + /// A string to correlate the authorization request to the token request. + pub code_verifier: Option, +} diff --git a/crates/authifier/src/models/mod.rs b/crates/authifier/src/models/mod.rs index d0dd5c4..ea76e6a 100644 --- a/crates/authifier/src/models/mod.rs +++ b/crates/authifier/src/models/mod.rs @@ -1,10 +1,12 @@ mod account; +mod callback; mod invite; mod mfa; mod session; mod ticket; pub use account::*; +pub use callback::*; pub use invite::*; pub use mfa::*; pub use session::*; From b6b0dd50993c51c7921caeadae4f6e305d589da7 Mon Sep 17 00:00:00 2001 From: avdb13 Date: Tue, 17 Dec 2024 20:26:11 +0000 Subject: [PATCH 03/22] add methods to callback model --- crates/authifier/src/database/definition.rs | 11 +++- crates/authifier/src/database/dummy.rs | 28 +++++++- crates/authifier/src/database/mongo.rs | 72 ++++++++++++++++++++- crates/authifier/src/derive/rocket.rs | 1 + crates/authifier/src/impl/callback.rs | 43 ++++++++++++ crates/authifier/src/impl/mod.rs | 1 + crates/authifier/src/result.rs | 1 + 7 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 crates/authifier/src/impl/callback.rs diff --git a/crates/authifier/src/database/definition.rs b/crates/authifier/src/database/definition.rs index 4c0fe6e..b360ed2 100644 --- a/crates/authifier/src/database/definition.rs +++ b/crates/authifier/src/database/definition.rs @@ -1,5 +1,5 @@ use crate::{ - models::{Account, Invite, MFATicket, Session}, + models::{Account, Callback, Invite, MFATicket, Session}, Result, Success, }; @@ -31,6 +31,9 @@ pub trait AbstractDatabase: std::marker::Sync { /// Find accounts which are due to be deleted async fn find_accounts_due_for_deletion(&self) -> Result>; + /// Find callback by id + async fn find_callback(&self, id: &str) -> Result; + /// Find invite by id async fn find_invite(&self, id: &str) -> Result; @@ -52,6 +55,9 @@ pub trait AbstractDatabase: std::marker::Sync { // Save account async fn save_account(&self, account: &Account) -> Success; + // Save callback + async fn save_callback(&self, callback: &Callback) -> Success; + /// Save session async fn save_session(&self, session: &Session) -> Success; @@ -61,6 +67,9 @@ pub trait AbstractDatabase: std::marker::Sync { /// Save ticket async fn save_ticket(&self, ticket: &MFATicket) -> Success; + /// Delete callback + async fn delete_callback(&self, id: &str) -> Success; + /// Delete session async fn delete_session(&self, id: &str) -> Success; diff --git a/crates/authifier/src/database/dummy.rs b/crates/authifier/src/database/dummy.rs index 7b1e38f..15dd0b1 100644 --- a/crates/authifier/src/database/dummy.rs +++ b/crates/authifier/src/database/dummy.rs @@ -1,7 +1,7 @@ use crate::{ models::{ - Account, AuthFlow, DeletionInfo, EmailVerification, Invite, MFATicket, PasswordAuth, - Session, + Account, AuthFlow, Callback, DeletionInfo, EmailVerification, Invite, MFATicket, + PasswordAuth, Session, }, Error, Result, Success, }; @@ -15,6 +15,7 @@ use super::{definition::AbstractDatabase, Migration}; #[derive(Default, Clone)] pub struct DummyDb { pub accounts: Arc>>, + pub callbacks: Arc>>, pub invites: Arc>>, pub sessions: Arc>>, pub tickets: Arc>>, @@ -109,6 +110,12 @@ impl AbstractDatabase for DummyDb { .collect()) } + /// Find callback by id + async fn find_callback(&self, id: &str) -> Result { + let callbacks = self.callbacks.lock().await; + callbacks.get(id).cloned().ok_or(Error::InvalidState) + } + /// Find invite by id async fn find_invite(&self, id: &str) -> Result { let invites = self.invites.lock().await; @@ -166,6 +173,13 @@ impl AbstractDatabase for DummyDb { Ok(()) } + // Save callback + async fn save_callback(&self, callback: &Callback) -> Success { + let mut callbacks = self.callbacks.lock().await; + callbacks.insert(callback.id.to_string(), callback.clone()); + Ok(()) + } + /// Save session async fn save_session(&self, session: &Session) -> Success { let mut sessions = self.sessions.lock().await; @@ -187,6 +201,16 @@ impl AbstractDatabase for DummyDb { Ok(()) } + /// Delete callback + async fn delete_callback(&self, id: &str) -> Success { + let mut callbacks = self.callbacks.lock().await; + if callbacks.remove(id).is_some() { + Ok(()) + } else { + Err(Error::InvalidState) + } + } + /// Delete session async fn delete_session(&self, id: &str) -> Success { let mut sessions = self.sessions.lock().await; diff --git a/crates/authifier/src/database/mongo.rs b/crates/authifier/src/database/mongo.rs index d6293cf..24ec8a1 100644 --- a/crates/authifier/src/database/mongo.rs +++ b/crates/authifier/src/database/mongo.rs @@ -6,7 +6,7 @@ use std::{ops::Deref, str::FromStr}; use ulid::Ulid; use crate::{ - models::{Account, Invite, MFATicket, Session}, + models::{Account, Callback, Invite, MFATicket, Session}, Error, Result, Success, }; @@ -359,6 +359,36 @@ impl AbstractDatabase for MongoDb { }) } + /// Find callback + ///
+ /// Callback is only valid for 10 minutes + async fn find_callback(&self, id: &str) -> Result { + let callback: Callback = self + .collection("callbacks") + .find_one( + doc! { + "_id": id + }, + None, + ) + .await + .map_err(|_| Error::DatabaseError { + operation: "find_one", + with: "callback", + })? + .ok_or(Error::InvalidState)?; + + if let Ok(ulid) = Ulid::from_str(&callback.id) { + if (ulid.datetime() + Duration::minutes(10)) > Utc::now() { + Ok(callback) + } else { + Err(Error::InvalidState) + } + } else { + Err(Error::InvalidState) + } + } + /// Find invite by id async fn find_invite(&self, id: &str) -> Result { self.collection("invites") @@ -494,6 +524,29 @@ impl AbstractDatabase for MongoDb { .map(|_| ()) } + /// Save callback + async fn save_callback(&self, callback: &Callback) -> Success { + self.collection::("callbacks") + .update_one( + doc! { + "_id": &callback.id + }, + doc! { + "$set": to_document(callback).map_err(|_| Error::DatabaseError { + operation: "to_document", + with: "callback", + })?, + }, + UpdateOptions::builder().upsert(true).build(), + ) + .await + .map_err(|_| Error::DatabaseError { + operation: "upsert_one", + with: "callback", + }) + .map(|_| ()) + } + /// Save session async fn save_session(&self, session: &Session) -> Success { self.collection::("sessions") @@ -563,6 +616,23 @@ impl AbstractDatabase for MongoDb { .map(|_| ()) } + /// Delete callback + async fn delete_callback(&self, id: &str) -> Success { + self.collection::("callbacks") + .delete_one( + doc! { + "_id": id + }, + None, + ) + .await + .map_err(|_| Error::DatabaseError { + operation: "delete_one", + with: "callback", + }) + .map(|_| ()) + } + /// Delete session async fn delete_session(&self, id: &str) -> Success { self.collection::("sessions") diff --git a/crates/authifier/src/derive/rocket.rs b/crates/authifier/src/derive/rocket.rs index 9ba64e7..4346524 100644 --- a/crates/authifier/src/derive/rocket.rs +++ b/crates/authifier/src/derive/rocket.rs @@ -30,6 +30,7 @@ impl<'r> Responder<'r, 'static> for Error { Error::EmailFailed => Status::InternalServerError, Error::InvalidCredentials => Status::Unauthorized, Error::InvalidToken => Status::Unauthorized, + Error::InvalidState => Status::Unauthorized, Error::MissingInvite => Status::BadRequest, Error::InvalidInvite => Status::BadRequest, Error::CompromisedPassword => Status::BadRequest, diff --git a/crates/authifier/src/impl/callback.rs b/crates/authifier/src/impl/callback.rs new file mode 100644 index 0000000..6ad37df --- /dev/null +++ b/crates/authifier/src/impl/callback.rs @@ -0,0 +1,43 @@ +use chrono::{Duration, Utc}; + +use crate::{models::Callback, Authifier, Error, Success}; + +impl Callback { + /// Create a new SSO callback + pub fn new(idp_id: String, redirect_uri: reqwest::Url) -> Self { + Callback { + id: ulid::Ulid::new().to_string(), + idp_id, + redirect_uri: redirect_uri.to_string(), + nonce: None, + code_verifier: None, + } + } + + /// Save model + pub async fn save(&self, authifier: &Authifier) -> Success { + authifier.database.save_callback(self).await + } + + /// Check if this SSO callback has expired + pub fn is_expired(&self) -> bool { + let now = Utc::now(); + let datetime = ulid::Ulid::from_string(&self.id) + .expect("Valid `ulid`") + .datetime() + // SSO callbacks last 10 minutes + .checked_add_signed(Duration::minutes(10)) + .expect("checked add signed"); + + now > datetime + } + + /// Claim and remove this SSO callback + pub async fn claim(&self, authifier: &Authifier) -> Success { + if self.is_expired() { + return Err(Error::InvalidToken); + } + + authifier.database.delete_callback(&self.id).await + } +} diff --git a/crates/authifier/src/impl/mod.rs b/crates/authifier/src/impl/mod.rs index cd4daa1..e292c3e 100644 --- a/crates/authifier/src/impl/mod.rs +++ b/crates/authifier/src/impl/mod.rs @@ -1,4 +1,5 @@ mod account; +mod callback; mod invite; mod mfa; mod session; diff --git a/crates/authifier/src/result.rs b/crates/authifier/src/result.rs index 060fec3..f883156 100644 --- a/crates/authifier/src/result.rs +++ b/crates/authifier/src/result.rs @@ -23,6 +23,7 @@ pub enum Error { EmailFailed, InvalidToken, + InvalidState, MissingInvite, InvalidInvite, InvalidCredentials, From 5abc6cbab01444f40484b438da706f5dae1523b4 Mon Sep 17 00:00:00 2001 From: avdb13 Date: Tue, 17 Dec 2024 21:25:01 +0000 Subject: [PATCH 04/22] add SSO configuration --- crates/authifier/src/config/mod.rs | 5 ++ crates/authifier/src/config/sso.rs | 122 +++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 crates/authifier/src/config/sso.rs diff --git a/crates/authifier/src/config/mod.rs b/crates/authifier/src/config/mod.rs index ee83902..79b06a2 100644 --- a/crates/authifier/src/config/mod.rs +++ b/crates/authifier/src/config/mod.rs @@ -4,6 +4,7 @@ mod email_verification; mod ip_resolve; mod passwords; mod shield; +mod sso; pub use blocklists::*; pub use captcha::*; @@ -11,6 +12,7 @@ pub use email_verification::*; pub use ip_resolve::*; pub use passwords::*; pub use shield::*; +pub use sso::*; /// Authifier configuration #[derive(Default, Serialize, Deserialize, Clone)] @@ -38,4 +40,7 @@ pub struct Config { /// Whether this application is running behind Cloudflare pub resolve_ip: ResolveIp, + + /// Single sign-on + pub sso: SSO, } diff --git a/crates/authifier/src/config/sso.rs b/crates/authifier/src/config/sso.rs new file mode 100644 index 0000000..cc72899 --- /dev/null +++ b/crates/authifier/src/config/sso.rs @@ -0,0 +1,122 @@ +use std::{ + borrow::Borrow, + collections::{HashMap, HashSet}, + hash::{Hash, Hasher}, + ops::Deref, +}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase", tag = "type")] +pub enum Endpoints { + Discoverable, + Manual { + authorization: String, + token: String, + userinfo: String, + }, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase", tag = "type")] +pub enum Credentials { + None { + client_id: String, + }, + Basic { + client_id: String, + client_secret: String, + }, + Post { + client_id: String, + client_secret: String, + }, +} + +impl Credentials { + pub fn client_id(&self) -> &str { + match self { + Credentials::None { client_id } + | Credentials::Basic { client_id, .. } + | Credentials::Post { client_id, .. } => client_id, + } + } +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum Claim { + Id, + Username, + Picture, + Email, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct IdProvider { + pub id: String, + + pub issuer: String, + pub name: Option, + pub icon: Option, + + pub scopes: Vec, + pub endpoints: Endpoints, + pub credentials: Credentials, + pub claims: HashMap, + + pub code_challenge: bool, +} + +impl Borrow for IdProvider { + fn borrow(&self) -> &str { + &*self.id + } +} + +impl PartialEq for IdProvider { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +impl Eq for IdProvider {} + +impl Hash for IdProvider { + fn hash(&self, state: &mut H) + where + H: Hasher, + { + self.id.hash(state); + } +} + +#[derive(Default, Clone)] +pub struct SSO(HashSet); + +impl Serialize for SSO { + fn serialize(&self, _: S) -> Result + where + S: Serializer, + { + todo!() + } +} + +impl<'de> Deserialize<'de> for SSO { + fn deserialize(_: D) -> Result + where + D: Deserializer<'de>, + { + todo!() + } +} + +impl Deref for SSO { + type Target = HashSet; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} From c02841138346c097cecb701023123813497f7f1c Mon Sep 17 00:00:00 2001 From: avdb13 Date: Tue, 17 Dec 2024 21:41:07 +0000 Subject: [PATCH 05/22] introduce ID provider model --- crates/authifier/src/config/sso.rs | 6 +-- crates/authifier/src/models/id_provider.rs | 48 ++++++++++++++++++++++ crates/authifier/src/models/mod.rs | 2 + 3 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 crates/authifier/src/models/id_provider.rs diff --git a/crates/authifier/src/config/sso.rs b/crates/authifier/src/config/sso.rs index cc72899..3dff69a 100644 --- a/crates/authifier/src/config/sso.rs +++ b/crates/authifier/src/config/sso.rs @@ -7,7 +7,7 @@ use std::{ use serde::{Deserialize, Deserializer, Serialize, Serializer}; -#[derive(Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "lowercase", tag = "type")] pub enum Endpoints { Discoverable, @@ -18,7 +18,7 @@ pub enum Endpoints { }, } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "lowercase", tag = "type")] pub enum Credentials { None { @@ -44,7 +44,7 @@ impl Credentials { } } -#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[serde(rename_all = "lowercase")] pub enum Claim { Id, diff --git a/crates/authifier/src/models/id_provider.rs b/crates/authifier/src/models/id_provider.rs new file mode 100644 index 0000000..9b3fea5 --- /dev/null +++ b/crates/authifier/src/models/id_provider.rs @@ -0,0 +1,48 @@ +use std::{ + borrow::Borrow, + collections::HashMap, + hash::{Hash, Hasher}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::config::{Claim, Credentials, Endpoints}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct IdProvider { + pub id: String, + + pub issuer: reqwest::Url, + pub name: Option, + pub icon: Option, + + pub scopes: Vec, + pub endpoints: Endpoints, + pub credentials: Credentials, + pub claims: HashMap, + + pub code_challenge: bool, +} + +impl Borrow for IdProvider { + fn borrow(&self) -> &str { + &*self.id + } +} + +impl PartialEq for IdProvider { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +impl Eq for IdProvider {} + +impl Hash for IdProvider { + fn hash(&self, state: &mut H) + where + H: Hasher, + { + self.id.hash(state); + } +} diff --git a/crates/authifier/src/models/mod.rs b/crates/authifier/src/models/mod.rs index ea76e6a..9b5d30a 100644 --- a/crates/authifier/src/models/mod.rs +++ b/crates/authifier/src/models/mod.rs @@ -1,5 +1,6 @@ mod account; mod callback; +mod id_provider; mod invite; mod mfa; mod session; @@ -7,6 +8,7 @@ mod ticket; pub use account::*; pub use callback::*; +pub use id_provider::*; pub use invite::*; pub use mfa::*; pub use session::*; From 7a462a794ed6e1998ba98aa6743d6db47c50ee1a Mon Sep 17 00:00:00 2001 From: avdb13 Date: Tue, 17 Dec 2024 21:57:12 +0000 Subject: [PATCH 06/22] introduce secret model --- crates/authifier/src/database/definition.rs | 8 ++++- crates/authifier/src/database/dummy.rs | 16 +++++++++- crates/authifier/src/database/mongo.rs | 35 ++++++++++++++++++++- crates/authifier/src/impl/mod.rs | 1 + crates/authifier/src/impl/secret.rs | 30 ++++++++++++++++++ crates/authifier/src/models/mod.rs | 2 ++ crates/authifier/src/models/secret.rs | 17 ++++++++++ 7 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 crates/authifier/src/impl/secret.rs create mode 100644 crates/authifier/src/models/secret.rs diff --git a/crates/authifier/src/database/definition.rs b/crates/authifier/src/database/definition.rs index b360ed2..7c2d6e1 100644 --- a/crates/authifier/src/database/definition.rs +++ b/crates/authifier/src/database/definition.rs @@ -1,5 +1,5 @@ use crate::{ - models::{Account, Callback, Invite, MFATicket, Session}, + models::{Account, Callback, Invite, MFATicket, Secret, Session}, Result, Success, }; @@ -37,6 +37,9 @@ pub trait AbstractDatabase: std::marker::Sync { /// Find invite by id async fn find_invite(&self, id: &str) -> Result; + /// Find secret + async fn find_secret(&self) -> Result>; + /// Find session by id async fn find_session(&self, id: &str) -> Result; @@ -58,6 +61,9 @@ pub trait AbstractDatabase: std::marker::Sync { // Save callback async fn save_callback(&self, callback: &Callback) -> Success; + // Save secret + async fn save_secret(&self, secret: &Secret) -> Success; + /// Save session async fn save_session(&self, session: &Session) -> Success; diff --git a/crates/authifier/src/database/dummy.rs b/crates/authifier/src/database/dummy.rs index 15dd0b1..2d425e8 100644 --- a/crates/authifier/src/database/dummy.rs +++ b/crates/authifier/src/database/dummy.rs @@ -1,7 +1,7 @@ use crate::{ models::{ Account, AuthFlow, Callback, DeletionInfo, EmailVerification, Invite, MFATicket, - PasswordAuth, Session, + PasswordAuth, Secret, Session, }, Error, Result, Success, }; @@ -17,6 +17,7 @@ pub struct DummyDb { pub accounts: Arc>>, pub callbacks: Arc>>, pub invites: Arc>>, + pub secrets: Arc>>, pub sessions: Arc>>, pub tickets: Arc>>, } @@ -128,6 +129,12 @@ impl AbstractDatabase for DummyDb { sessions.get(id).cloned().ok_or(Error::UnknownUser) } + /// Find secret + async fn find_secret(&self) -> Result> { + let secrets = self.secrets.lock().await; + Ok(secrets.get(&()).cloned()) + } + /// Find sessions by user id async fn find_sessions(&self, user_id: &str) -> Result> { let sessions = self.sessions.lock().await; @@ -180,6 +187,13 @@ impl AbstractDatabase for DummyDb { Ok(()) } + /// Save secret + async fn save_secret(&self, secret: &Secret) -> Success { + let mut secrets = self.secrets.lock().await; + secrets.insert((), secret.clone()); + Ok(()) + } + /// Save session async fn save_session(&self, session: &Session) -> Success { let mut sessions = self.sessions.lock().await; diff --git a/crates/authifier/src/database/mongo.rs b/crates/authifier/src/database/mongo.rs index 24ec8a1..9521e57 100644 --- a/crates/authifier/src/database/mongo.rs +++ b/crates/authifier/src/database/mongo.rs @@ -6,7 +6,7 @@ use std::{ops::Deref, str::FromStr}; use ulid::Ulid; use crate::{ - models::{Account, Callback, Invite, MFATicket, Session}, + models::{Account, Callback, Invite, MFATicket, Secret, Session}, Error, Result, Success, }; @@ -417,6 +417,18 @@ impl AbstractDatabase for MongoDb { .ok_or(Error::UnknownUser) } + /// Find secret + async fn find_secret(&self) -> Result> { + Ok(self + .collection("secret") + .find_one(doc! {}, None) + .await + .map_err(|_| Error::DatabaseError { + operation: "find_one", + with: "secret", + })?) + } + /// Find sessions by user id async fn find_sessions(&self, user_id: &str) -> Result> { self.collection::("sessions") @@ -593,6 +605,27 @@ impl AbstractDatabase for MongoDb { .map(|_| ()) } + /// Save secret + async fn save_secret(&self, secret: &Secret) -> Success { + self.collection::("secret") + .update_one( + doc! {}, + doc! { + "$set": to_document(secret).map_err(|_| Error::DatabaseError { + operation: "to_document", + with: "secret", + })?, + }, + UpdateOptions::builder().upsert(true).build(), + ) + .await + .map_err(|_| Error::DatabaseError { + operation: "upsert_one", + with: "secret", + }) + .map(|_| ()) + } + /// Save ticket async fn save_ticket(&self, ticket: &MFATicket) -> Success { self.collection::("mfa_tickets") diff --git a/crates/authifier/src/impl/mod.rs b/crates/authifier/src/impl/mod.rs index e292c3e..aa64dd5 100644 --- a/crates/authifier/src/impl/mod.rs +++ b/crates/authifier/src/impl/mod.rs @@ -2,5 +2,6 @@ mod account; mod callback; mod invite; mod mfa; +mod secret; mod session; mod ticket; diff --git a/crates/authifier/src/impl/secret.rs b/crates/authifier/src/impl/secret.rs new file mode 100644 index 0000000..fb75201 --- /dev/null +++ b/crates/authifier/src/impl/secret.rs @@ -0,0 +1,30 @@ +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; +use serde::{de::DeserializeOwned, Serialize}; + +use crate::models::Secret; + +impl Secret { + /// Sign claims with secret + pub fn sign_claims(&self, claims: &T) -> String + where + T: Serialize, + { + let secret = self.expose().as_bytes(); + + let (header, key) = (Header::default(), EncodingKey::from_secret(secret)); + + jsonwebtoken::encode(&header, claims, &key).expect("JWT encoding should not fail") + } + + /// Validate claims with secret + pub fn validate_claims(&self, token: &str) -> Result + where + T: DeserializeOwned, + { + let secret = self.expose().as_bytes(); + + let (validation, key) = (Validation::default(), DecodingKey::from_secret(secret)); + + jsonwebtoken::decode(token, &key, &validation).map(|token| token.claims) + } +} diff --git a/crates/authifier/src/models/mod.rs b/crates/authifier/src/models/mod.rs index 9b5d30a..367a3b2 100644 --- a/crates/authifier/src/models/mod.rs +++ b/crates/authifier/src/models/mod.rs @@ -3,6 +3,7 @@ mod callback; mod id_provider; mod invite; mod mfa; +mod secret; mod session; mod ticket; @@ -11,5 +12,6 @@ pub use callback::*; pub use id_provider::*; pub use invite::*; pub use mfa::*; +pub use secret::*; pub use session::*; pub use ticket::*; diff --git a/crates/authifier/src/models/secret.rs b/crates/authifier/src/models/secret.rs new file mode 100644 index 0000000..095f407 --- /dev/null +++ b/crates/authifier/src/models/secret.rs @@ -0,0 +1,17 @@ +/// Secret model +#[derive(Serialize, Deserialize, Clone)] +pub struct Secret(String); + +impl Secret { + pub fn expose(&self) -> &str { + &*self.0 + } +} + +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let secret: String = std::iter::repeat_n('X', self.0.len()).collect(); + + f.debug_tuple("Secret").field(&secret).finish() + } +} From eac61115a1430b0f91bbda4d745a97d5aaef32b0 Mon Sep 17 00:00:00 2001 From: avdb13 Date: Wed, 18 Dec 2024 08:10:47 +0000 Subject: [PATCH 07/22] add methods to ID provider model --- crates/authifier/Cargo.toml | 9 + crates/authifier/src/database/definition.rs | 2 +- crates/authifier/src/database/dummy.rs | 12 +- crates/authifier/src/database/mongo.rs | 24 +- crates/authifier/src/impl/id_provider.rs | 319 ++++++++++++++++++++ crates/authifier/src/impl/mod.rs | 1 + crates/authifier/src/lib.rs | 1 + crates/authifier/src/models/secret.rs | 6 + crates/authifier/src/result.rs | 15 + crates/authifier/src/util.rs | 6 + 10 files changed, 384 insertions(+), 11 deletions(-) create mode 100644 crates/authifier/src/impl/id_provider.rs diff --git a/crates/authifier/Cargo.toml b/crates/authifier/Cargo.toml index 36ffe0b..200e616 100644 --- a/crates/authifier/Cargo.toml +++ b/crates/authifier/Cargo.toml @@ -81,3 +81,12 @@ rust-argon2 = "1.0.0" # Email lettre = "0.10.0-alpha.4" handlebars = "4.3.0" + +# SSO +sha2 = "0.10.8" +base64 = "0.22.1" +serde_urlencoded = "0.7.1" +jsonwebtoken = "9.3.0" +form_urlencoded = "1.2.1" +oauth2-types = "0.11.0" +mime = "0.3.17" diff --git a/crates/authifier/src/database/definition.rs b/crates/authifier/src/database/definition.rs index 7c2d6e1..e9da799 100644 --- a/crates/authifier/src/database/definition.rs +++ b/crates/authifier/src/database/definition.rs @@ -38,7 +38,7 @@ pub trait AbstractDatabase: std::marker::Sync { async fn find_invite(&self, id: &str) -> Result; /// Find secret - async fn find_secret(&self) -> Result>; + async fn find_secret(&self) -> Result; /// Find session by id async fn find_session(&self, id: &str) -> Result; diff --git a/crates/authifier/src/database/dummy.rs b/crates/authifier/src/database/dummy.rs index 2d425e8..3e04786 100644 --- a/crates/authifier/src/database/dummy.rs +++ b/crates/authifier/src/database/dummy.rs @@ -130,9 +130,17 @@ impl AbstractDatabase for DummyDb { } /// Find secret - async fn find_secret(&self) -> Result> { + async fn find_secret(&self) -> Result { let secrets = self.secrets.lock().await; - Ok(secrets.get(&()).cloned()) + + match secrets.get(&()) { + Some(secret) => Ok(secret.clone()), + None => { + let secret = Secret::new(); + + self.save_secret(&secret).await.map(|_| secret) + } + } } /// Find sessions by user id diff --git a/crates/authifier/src/database/mongo.rs b/crates/authifier/src/database/mongo.rs index 9521e57..6c47933 100644 --- a/crates/authifier/src/database/mongo.rs +++ b/crates/authifier/src/database/mongo.rs @@ -418,15 +418,23 @@ impl AbstractDatabase for MongoDb { } /// Find secret - async fn find_secret(&self) -> Result> { - Ok(self - .collection("secret") + async fn find_secret(&self) -> Result { + let res = self + .collection::("secret") .find_one(doc! {}, None) - .await - .map_err(|_| Error::DatabaseError { - operation: "find_one", - with: "secret", - })?) + .await; + + match res.map_err(|_| Error::DatabaseError { + operation: "find_one", + with: "secret", + })? { + Some(secret) => Ok(secret), + None => { + let secret = Secret::new(); + + self.save_secret(&secret).await.map(|_| secret) + } + } } /// Find sessions by user id diff --git a/crates/authifier/src/impl/id_provider.rs b/crates/authifier/src/impl/id_provider.rs new file mode 100644 index 0000000..1bafa21 --- /dev/null +++ b/crates/authifier/src/impl/id_provider.rs @@ -0,0 +1,319 @@ +use std::collections::HashMap; + +use base64::{ + alphabet::URL_SAFE, + engine::{general_purpose::NO_PAD, GeneralPurpose}, + Engine, +}; +use mime::{Mime, APPLICATION_JSON}; +use oauth2_types::{ + oidc::{ProviderMetadata, VerifiedProviderMetadata}, + requests::{AccessTokenRequest, AccessTokenResponse, AuthorizationCodeGrant}, +}; +use rand::Rng; +use reqwest::{ + header::{ACCEPT, CONTENT_TYPE, WWW_AUTHENTICATE}, + Url, +}; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::{ + config::{Credentials, Endpoints}, + models::{Callback, IdProvider}, + util::secure_random_str, + Authifier, Error, Result, +}; + +static OIDC_CONFIG_PATH: &str = "/.well-known/openid-configuration"; + +type IdToken = HashMap; + +#[derive(Deserialize)] +struct ErrorResponse { + error: String, +} + +impl IdProvider { + /// Create authorization URI + pub async fn create_authorization_uri<'res>( + &self, + authifier: &Authifier, + redirect_uri: &Url, + ) -> Result<(String, Url)> { + let state = ulid::Ulid::new().to_string(); + + let nonce = match &self.endpoints { + Endpoints::Discoverable => Some(secure_random_str(32)), + Endpoints::Manual { .. } => None, + }; + + let (code_verifier, code_challenge) = + self.code_challenge.then(create_code_challenge).unzip(); + + let mut authorization_uri = match &self.endpoints { + Endpoints::Discoverable => { + let metadata = self.discover(authifier).await?; + + metadata.authorization_endpoint().to_owned() + } + Endpoints::Manual { authorization, .. } => authorization.parse().unwrap(), + }; + + { + authorization_uri.query_pairs_mut().extend_pairs([ + ("client_id", self.credentials.client_id()), + ("redirect_uri", redirect_uri.as_ref()), + ("response_type", "code"), + ("scope", &*self.scopes.join(" ")), + ("state", &*state), + ]); + } + + if let Some(nonce) = nonce.as_deref() { + authorization_uri + .query_pairs_mut() + .extend_pairs([("nonce", nonce)]); + } + + if let Some(code_challenge) = code_challenge.as_deref() { + authorization_uri.query_pairs_mut().extend_pairs([ + ("code_challenge", code_challenge), + ("code_challenge_method", "S256"), + ]); + } + + let secret = authifier.database.find_secret().await?; + + // let builder = Cookie::build(("callback-state", secret.sign_claims(&state))) + // .secure(true) + // .http_only(true); + + // let (path, same_site, max_age) = + // ("/callback", SameSite::Strict, Duration::seconds(60 * 10)); + // let cookie = builder + // .path(path) + // .same_site(same_site) + // .max_age(max_age) + // .build(); + + // let location = Header::new("Location", authorization_uri.to_string()); + + let callback = Callback { + id: state.clone(), + nonce, + code_verifier, + ..Callback::new(self.id.clone(), redirect_uri.clone()) + }; + + authifier.database.save_callback(&callback).await?; + + Ok((secret.sign_claims(&state), authorization_uri)) + } + + /// Exchange authorization code for access token + pub async fn exchange_authorization_code( + &self, + authifier: &Authifier, + code: &str, + state: &str, + ) -> Result<(AccessTokenResponse, Option)> { + let callback = authifier.database.find_callback(state).await?; + + // validate state + if state != callback.id { + authifier.database.delete_callback(state).await?; + + return Err(Error::StateMismatch); + } + + let endpoint = match &self.endpoints { + Endpoints::Discoverable => { + let metadata = self.discover(authifier).await?; + + metadata.token_endpoint().to_owned() + } + Endpoints::Manual { token, .. } => token.parse().unwrap(), + }; + + let body = AccessTokenRequest::AuthorizationCode(AuthorizationCodeGrant { + code: code.to_owned(), + redirect_uri: Some(callback.redirect_uri.parse().unwrap()), + code_verifier: callback.code_verifier.clone(), + }); + + let builder = authifier.http_client.post(endpoint); + + match self.request(builder, body).await { + Ok(res) if res.status().is_success() => { + Ok((res.json().await.map_err(|_| Error::RequestFailed)?, None)) + } + Ok(res) => { + let ErrorResponse { error } = res.json().await.map_err(|_| Error::RequestFailed)?; + + Err(match &*error { + "invalid_request" => Error::InvalidRequest, + "invalid_client" => Error::InvalidClient, + "invalid_grant" => Error::InvalidGrant, + "unauthorized_client" => Error::UnauthorizedClient, + "unsupported_grant_type" => Error::UnsupportedGrantType, + "invalid_scope" => Error::InvalidScope, + _ => Error::RequestFailed, + }) + } + Err(_) => Err(Error::RequestFailed), + } + } + + pub async fn fetch_userinfo( + &self, + authifier: &Authifier, + access_token: &str, + ) -> Result> { + let Some(endpoint) = (match &self.endpoints { + Endpoints::Discoverable => { + let metadata = self.discover(authifier).await?; + + metadata.userinfo_endpoint.as_ref().cloned() + } + Endpoints::Manual { userinfo, .. } => Some(userinfo.parse().unwrap()), + }) else { + return Ok(None); + }; + + let builder = authifier.http_client.get(endpoint); + + let res = match self.request(builder.bearer_auth(access_token), ()).await { + Ok(res) if res.status().is_success() => { + let header = res.headers().get(CONTENT_TYPE); + + let Some(mime): Option = + header.and_then(|h| h.to_str().ok().map(str::parse).and_then(Result::ok)) + else { + return Err(Error::MissingHeaders); + }; + + if mime.essence_str() != APPLICATION_JSON { + return Err(Error::ContentTypeMismatch); + } + + res + } + Ok(res) => { + let header = res.headers().get(WWW_AUTHENTICATE); + + let Some((_, error)) = header.and_then(|h| { + h.to_str().ok().and_then(|s| { + let it = s.trim_matches("Bearer ").split(','); + + it.filter_map(|s| s.split_once('=')) + .find(|(k, v)| k == "error") + }) + }) else { + return Err(Error::MissingHeaders); + }; + + return Err(match error { + "invalid_request" => Error::InvalidRequest, + "unsupported_grant_type" => Error::UnsupportedGrantType, + "invalid_scope" => Error::InvalidScope, + _ => Error::RequestFailed, + }); + } + Err(_) => { + return Err(Error::RequestFailed); + } + }; + + // TODO: Subject identifier must always be the same + match res.json().await.map_err(|_| Error::RequestFailed)? { + serde_json::Value::Object(userinfo) => Ok(Some(userinfo.into_iter().collect())), + _ => { + return Err(Error::InvalidUserinfo); + } + } + } + + pub async fn request( + &self, + builder: reqwest::RequestBuilder, + body: T, + ) -> Result + where + T: Serialize, + { + /// A request with client credentials added to it. + #[derive(Clone, Serialize)] + struct Request<'c, T> { + #[serde(flatten)] + body: T, + client_id: &'c str, + #[serde(skip_serializing_if = "Option::is_none")] + client_secret: Option<&'c str>, + } + + let (client_id, client_secret) = ( + self.credentials.client_id(), + match &self.credentials { + Credentials::Basic { client_secret, .. } + | Credentials::Post { client_secret, .. } => Some(&**client_secret), + _ => None, + }, + ); + + let request = builder.form(&Request { + body, + client_id, + client_secret, + }); + + let request = request.header(ACCEPT, APPLICATION_JSON.as_ref()); + + if let Credentials::Basic { + client_id, + client_secret, + } = &self.credentials + { + let (username, password): (String, String) = ( + form_urlencoded::byte_serialize(client_id.as_bytes()).collect(), + form_urlencoded::byte_serialize(client_secret.as_bytes()).collect(), + ); + + request.basic_auth(username, Some(password)).send().await + } else { + request.send().await + } + } + + /// Fetch the provider metadata. + async fn discover(&self, authifier: &Authifier) -> Result { + let config_url = self + .issuer + .join(OIDC_CONFIG_PATH) + .map_err(|_| Error::InvalidEndpoints)?; + + let request = authifier.http_client.get(config_url); + let response = request.send().await.map_err(|_| Error::InvalidEndpoints)?; + + let metadata: ProviderMetadata = + response.json().await.map_err(|_| Error::InvalidEndpoints)?; + + metadata + .validate(self.issuer.as_ref()) + .map_err(|_| Error::InvalidEndpoints) + } +} + +#[inline(always)] +fn create_code_challenge() -> (String, String) { + let engine = GeneralPurpose::new(&URL_SAFE, NO_PAD); + let mut arr = [0u8; 32]; + + rand::thread_rng().fill(&mut arr); + let code_verifier = engine.encode(arr); + + let digest = Sha256::digest(&code_verifier); + let code_challenge = engine.encode(digest); + + (code_verifier, code_challenge) +} diff --git a/crates/authifier/src/impl/mod.rs b/crates/authifier/src/impl/mod.rs index aa64dd5..80025c8 100644 --- a/crates/authifier/src/impl/mod.rs +++ b/crates/authifier/src/impl/mod.rs @@ -1,5 +1,6 @@ mod account; mod callback; +mod id_provider; mod invite; mod mfa; mod secret; diff --git a/crates/authifier/src/lib.rs b/crates/authifier/src/lib.rs index 3eb5308..1b639c5 100644 --- a/crates/authifier/src/lib.rs +++ b/crates/authifier/src/lib.rs @@ -40,6 +40,7 @@ use async_std::channel::Sender; pub struct Authifier { pub config: Config, pub database: Database, + pub http_client: reqwest::Client, pub event_channel: Option>, } diff --git a/crates/authifier/src/models/secret.rs b/crates/authifier/src/models/secret.rs index 095f407..8b4e836 100644 --- a/crates/authifier/src/models/secret.rs +++ b/crates/authifier/src/models/secret.rs @@ -1,8 +1,14 @@ +use rand::distributions::{Alphanumeric, DistString}; + /// Secret model #[derive(Serialize, Deserialize, Clone)] pub struct Secret(String); impl Secret { + pub fn new() -> Self { + Self(Alphanumeric.sample_string(&mut rand::thread_rng(), 512)) + } + pub fn expose(&self) -> &str { &*self.0 } diff --git a/crates/authifier/src/result.rs b/crates/authifier/src/result.rs index f883156..dacb50c 100644 --- a/crates/authifier/src/result.rs +++ b/crates/authifier/src/result.rs @@ -37,6 +37,21 @@ pub enum Error { DisallowedMFAMethod, NotAvailable, + + InvalidEndpoints, + StateMismatch, + + RequestFailed, + InvalidRequest, + InvalidClient, + InvalidGrant, + UnauthorizedClient, + UnsupportedGrantType, + InvalidScope, + + ContentTypeMismatch, + InsufficientScope, + InvalidUserinfo, } pub type Result = std::result::Result; diff --git a/crates/authifier/src/util.rs b/crates/authifier/src/util.rs index 12b0150..e59824e 100644 --- a/crates/authifier/src/util.rs +++ b/crates/authifier/src/util.rs @@ -1,3 +1,4 @@ +use rand::distributions::{Alphanumeric, DistString}; use regex::Regex; use crate::{Error, Result}; @@ -31,3 +32,8 @@ pub fn hash_password(plaintext_password: String) -> Result { ) .map_err(|_| Error::InternalError) } + +/// Generate secure random string +pub fn secure_random_str(length: usize) -> String { + Alphanumeric.sample_string(&mut rand::thread_rng(), length) +} From e97a075406cf274add1c5b73feed0c5a0270c531 Mon Sep 17 00:00:00 2001 From: avdb13 Date: Wed, 18 Dec 2024 09:30:53 +0000 Subject: [PATCH 08/22] provide authorization handler --- crates/authifier/src/derive/rocket.rs | 19 +++++++++ crates/authifier/src/impl/id_provider.rs | 18 +-------- crates/authifier/src/models/id_provider.rs | 20 +++++++++- crates/authifier/src/result.rs | 4 ++ crates/rocket_authifier/src/routes/mod.rs | 1 + .../src/routes/sso/authorize.rs | 40 +++++++++++++++++++ crates/rocket_authifier/src/routes/sso/mod.rs | 1 + 7 files changed, 86 insertions(+), 17 deletions(-) create mode 100644 crates/rocket_authifier/src/routes/sso/authorize.rs create mode 100644 crates/rocket_authifier/src/routes/sso/mod.rs diff --git a/crates/authifier/src/derive/rocket.rs b/crates/authifier/src/derive/rocket.rs index 4346524..74c8748 100644 --- a/crates/authifier/src/derive/rocket.rs +++ b/crates/authifier/src/derive/rocket.rs @@ -48,6 +48,25 @@ impl<'r> Responder<'r, 'static> for Error { Error::TotpAlreadyEnabled => Status::BadRequest, Error::DisallowedMFAMethod => Status::BadRequest, Error::NotAvailable => Status::NotFound, + + Error::InvalidEndpoints => todo!(), + Error::StateMismatch => todo!(), + + Error::RequestFailed => todo!(), + Error::InvalidRequest => todo!(), + Error::InvalidClient => todo!(), + Error::InvalidGrant => todo!(), + Error::UnauthorizedClient => todo!(), + Error::UnsupportedGrantType => todo!(), + Error::InvalidScope => todo!(), + + Error::ContentTypeMismatch => todo!(), + Error::InsufficientScope => todo!(), + Error::InvalidUserinfo => todo!(), + + Error::InvalidRedirectUri => todo!(), + Error::InvalidIdpId => todo!(), + Error::InvalidIdpConfig => todo!(), }; // Serialize the error data structure into JSON. diff --git a/crates/authifier/src/impl/id_provider.rs b/crates/authifier/src/impl/id_provider.rs index 1bafa21..bc702cd 100644 --- a/crates/authifier/src/impl/id_provider.rs +++ b/crates/authifier/src/impl/id_provider.rs @@ -85,20 +85,6 @@ impl IdProvider { let secret = authifier.database.find_secret().await?; - // let builder = Cookie::build(("callback-state", secret.sign_claims(&state))) - // .secure(true) - // .http_only(true); - - // let (path, same_site, max_age) = - // ("/callback", SameSite::Strict, Duration::seconds(60 * 10)); - // let cookie = builder - // .path(path) - // .same_site(same_site) - // .max_age(max_age) - // .build(); - - // let location = Header::new("Location", authorization_uri.to_string()); - let callback = Callback { id: state.clone(), nonce, @@ -204,10 +190,10 @@ impl IdProvider { let Some((_, error)) = header.and_then(|h| { h.to_str().ok().and_then(|s| { - let it = s.trim_matches("Bearer ").split(','); + let it = s.trim_start_matches("Bearer ").split(','); it.filter_map(|s| s.split_once('=')) - .find(|(k, v)| k == "error") + .find(|(k, _)| *k == "error") }) }) else { return Err(Error::MissingHeaders); diff --git a/crates/authifier/src/models/id_provider.rs b/crates/authifier/src/models/id_provider.rs index 9b3fea5..0c733c3 100644 --- a/crates/authifier/src/models/id_provider.rs +++ b/crates/authifier/src/models/id_provider.rs @@ -6,7 +6,7 @@ use std::{ use serde::{Deserialize, Serialize}; -use crate::config::{Claim, Credentials, Endpoints}; +use crate::config::{Claim, Credentials, Endpoints, IdProvider as IdProviderConfig}; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct IdProvider { @@ -46,3 +46,21 @@ impl Hash for IdProvider { self.id.hash(state); } } + +impl TryFrom for IdProvider { + type Error = ::Err; + + fn try_from(config: IdProviderConfig) -> Result { + Ok(Self { + id: config.id, + issuer: config.issuer.parse()?, + name: config.name, + icon: config.icon.as_deref().map(str::parse).transpose()?, + scopes: config.scopes, + endpoints: config.endpoints, + credentials: config.credentials, + claims: config.claims, + code_challenge: config.code_challenge, + }) + } +} diff --git a/crates/authifier/src/result.rs b/crates/authifier/src/result.rs index dacb50c..4f1598d 100644 --- a/crates/authifier/src/result.rs +++ b/crates/authifier/src/result.rs @@ -52,6 +52,10 @@ pub enum Error { ContentTypeMismatch, InsufficientScope, InvalidUserinfo, + + InvalidRedirectUri, + InvalidIdpId, + InvalidIdpConfig, } pub type Result = std::result::Result; diff --git a/crates/rocket_authifier/src/routes/mod.rs b/crates/rocket_authifier/src/routes/mod.rs index 5a636da..c16ff13 100644 --- a/crates/rocket_authifier/src/routes/mod.rs +++ b/crates/rocket_authifier/src/routes/mod.rs @@ -1,3 +1,4 @@ pub mod account; pub mod mfa; pub mod session; +pub mod sso; diff --git a/crates/rocket_authifier/src/routes/sso/authorize.rs b/crates/rocket_authifier/src/routes/sso/authorize.rs new file mode 100644 index 0000000..e774737 --- /dev/null +++ b/crates/rocket_authifier/src/routes/sso/authorize.rs @@ -0,0 +1,40 @@ +//! Redirect to authorization interface +//! GET /sso/authorize +use authifier::models::IdProvider; +use authifier::{Authifier, Error, Result}; +use rocket::http::{Cookie, CookieJar}; +use rocket::response::Redirect; +use rocket::time::Duration; +use rocket::State; + +/// # Redirect to authorization interface +/// +/// Redirect to authorization interface. +#[openapi(tag = "SSO")] +#[get("/sso/authorize/?")] +pub async fn authorize( + authifier: &State, + idp_id: &str, + redirect_uri: &str, + cookies: &CookieJar<'_>, +) -> Result { + let Ok(redirect_uri) = redirect_uri.parse() else { + return Err(Error::InvalidRedirectUri); + }; + + let id_provider = match authifier.config.sso.get(idp_id).cloned() { + Some(config) => IdProvider::try_from(config).map_err(|_| Error::InvalidIdpConfig)?, + None => return Err(Error::InvalidIdpId), + }; + + let (state, uri) = id_provider + .create_authorization_uri(authifier, &redirect_uri) + .await?; + + let (path, max_age) = ("/sso/callback", Duration::seconds(60 * 10)); + let cookie = Cookie::build(("callback-id", state)).http_only(true); + + cookies.add(cookie.path(path).max_age(max_age)); + + Ok(Redirect::found(uri.to_string())) +} diff --git a/crates/rocket_authifier/src/routes/sso/mod.rs b/crates/rocket_authifier/src/routes/sso/mod.rs new file mode 100644 index 0000000..f12b002 --- /dev/null +++ b/crates/rocket_authifier/src/routes/sso/mod.rs @@ -0,0 +1 @@ +pub mod authorize; From 1062f9cbc29781226fc0d928ccf0ac6b5cd6b296 Mon Sep 17 00:00:00 2001 From: avdb13 Date: Wed, 18 Dec 2024 10:12:39 +0000 Subject: [PATCH 09/22] provide callback handler --- crates/authifier/src/derive/rocket.rs | 5 + crates/authifier/src/impl/id_provider.rs | 4 +- crates/authifier/src/result.rs | 5 + .../src/routes/sso/callback.rs | 95 +++++++++++++++++++ crates/rocket_authifier/src/routes/sso/mod.rs | 1 + 5 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 crates/rocket_authifier/src/routes/sso/callback.rs diff --git a/crates/authifier/src/derive/rocket.rs b/crates/authifier/src/derive/rocket.rs index 74c8748..9f04004 100644 --- a/crates/authifier/src/derive/rocket.rs +++ b/crates/authifier/src/derive/rocket.rs @@ -67,6 +67,11 @@ impl<'r> Responder<'r, 'static> for Error { Error::InvalidRedirectUri => todo!(), Error::InvalidIdpId => todo!(), Error::InvalidIdpConfig => todo!(), + + Error::MissingCallback => todo!(), + Error::InvalidCallback => todo!(), + Error::MissingAuthCode => todo!(), + Error::InvalidIdClaim => todo!(), }; // Serialize the error data structure into JSON. diff --git a/crates/authifier/src/impl/id_provider.rs b/crates/authifier/src/impl/id_provider.rs index bc702cd..5b8c552 100644 --- a/crates/authifier/src/impl/id_provider.rs +++ b/crates/authifier/src/impl/id_provider.rs @@ -83,8 +83,6 @@ impl IdProvider { ]); } - let secret = authifier.database.find_secret().await?; - let callback = Callback { id: state.clone(), nonce, @@ -94,7 +92,7 @@ impl IdProvider { authifier.database.save_callback(&callback).await?; - Ok((secret.sign_claims(&state), authorization_uri)) + Ok((state, authorization_uri)) } /// Exchange authorization code for access token diff --git a/crates/authifier/src/result.rs b/crates/authifier/src/result.rs index 4f1598d..e7fd957 100644 --- a/crates/authifier/src/result.rs +++ b/crates/authifier/src/result.rs @@ -56,6 +56,11 @@ pub enum Error { InvalidRedirectUri, InvalidIdpId, InvalidIdpConfig, + + MissingCallback, + InvalidCallback, + MissingAuthCode, + InvalidIdClaim, } pub type Result = std::result::Result; diff --git a/crates/rocket_authifier/src/routes/sso/callback.rs b/crates/rocket_authifier/src/routes/sso/callback.rs new file mode 100644 index 0000000..d277b6e --- /dev/null +++ b/crates/rocket_authifier/src/routes/sso/callback.rs @@ -0,0 +1,95 @@ +//! Handle the callback from the ID provider +//! GET /sso/authorize +use std::collections::HashMap; + +use authifier::config::Claim; +use authifier::models::IdProvider; +use authifier::{Authifier, Error, Result}; +use rocket::http::{Cookie, CookieJar}; +use rocket::{serde::json::Json, State}; + +#[derive(Serialize, Deserialize, JsonSchema, FromForm)] +pub struct DataCallback { + /// The authorization code generated by the authorization server. + pub code: Option, + /// The access token to access the requested scope. + pub access_token: Option, + /// ID Token value associated with the authenticated session. + pub id_token: Option, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct ResponseCallback { + login_token: String, + redirect_uri: String, +} + +/// # Handle the callback from the ID provider +/// +/// Handle the callback from the ID provider. +#[openapi(tag = "SSO")] +#[get("/callback?")] +pub async fn callback( + authifier: &State, + data: DataCallback, + cookies: &CookieJar<'_>, +) -> Result> { + let secret = authifier.database.find_secret().await?; + let cookie = cookies.get("callback-id").map(Cookie::value); + + let id: String = match cookie.map(|c| secret.validate_claims(c)).transpose() { + Ok(value) => value.ok_or(Error::MissingCallback)?, + Err(_) => { + return Err(Error::InvalidCallback); + } + }; + + let callback = authifier.database.find_callback(&id).await?; + { + authifier.database.delete_callback(&id).await?; + } + + let id_provider = match authifier.config.sso.get(&*callback.idp_id).cloned() { + Some(config) => IdProvider::try_from(config).map_err(|_| Error::InvalidIdpConfig)?, + None => return Err(Error::InvalidIdpId), + }; + + let Some(code) = data.code.as_deref() else { + return Err(Error::MissingAuthCode); + }; + + let (response, id_token) = id_provider + .exchange_authorization_code(authifier, code, &id) + .await?; + + let mut claims = HashMap::with_capacity(id_provider.claims.len()); + + if let Some(id_token) = id_token { + let values = id_provider.claims.iter().filter_map(|(claim, key)| { + let value = id_token.get(key).cloned()?; + + Some((claim.to_owned(), value)) + }); + + claims.extend(values); + } + + if let Some(userinfo) = id_provider + .fetch_userinfo(authifier, &response.access_token) + .await? + { + let values = id_provider.claims.iter().filter_map(|(claim, key)| { + let value = userinfo.get(key).cloned()?; + + Some((claim.to_owned(), value)) + }); + + claims.extend(values); + } + + let Some(_sub) = claims.get(&Claim::Id) else { + return Err(Error::InvalidIdClaim); + }; + + todo!() +} diff --git a/crates/rocket_authifier/src/routes/sso/mod.rs b/crates/rocket_authifier/src/routes/sso/mod.rs index f12b002..530ed7e 100644 --- a/crates/rocket_authifier/src/routes/sso/mod.rs +++ b/crates/rocket_authifier/src/routes/sso/mod.rs @@ -1 +1,2 @@ pub mod authorize; +pub mod callback; From 00bdd970019cfa255e287c4c6fbde7f83ab93fdb Mon Sep 17 00:00:00 2001 From: avdb13 Date: Wed, 18 Dec 2024 10:55:59 +0000 Subject: [PATCH 10/22] complete callback handler with account creation --- crates/authifier/src/database/definition.rs | 3 + crates/authifier/src/database/dummy.rs | 5 ++ crates/authifier/src/database/mongo.rs | 5 ++ crates/authifier/src/impl/account.rs | 36 +++++++- .../src/routes/sso/callback.rs | 85 ++++++++++++++++--- 5 files changed, 122 insertions(+), 12 deletions(-) diff --git a/crates/authifier/src/database/definition.rs b/crates/authifier/src/database/definition.rs index e9da799..1f6299c 100644 --- a/crates/authifier/src/database/definition.rs +++ b/crates/authifier/src/database/definition.rs @@ -19,6 +19,9 @@ pub trait AbstractDatabase: std::marker::Sync { normalised_email: &str, ) -> Result>; + /// Find account by SSO ID + async fn find_account_by_sso_id(&self, idp_id: &str, sub_id: &str) -> Result>; + /// Find account with active pending email verification async fn find_account_with_email_verification(&self, token: &str) -> Result; diff --git a/crates/authifier/src/database/dummy.rs b/crates/authifier/src/database/dummy.rs index 3e04786..2e6aa60 100644 --- a/crates/authifier/src/database/dummy.rs +++ b/crates/authifier/src/database/dummy.rs @@ -48,6 +48,11 @@ impl AbstractDatabase for DummyDb { .cloned()) } + /// Find account by SSO ID + async fn find_account_by_sso_id(&self, idp_id: &str, sub_id: &str) -> Result> { + todo!() + } + /// Find account with active pending email verification async fn find_account_with_email_verification(&self, token_to_match: &str) -> Result { let accounts = self.accounts.lock().await; diff --git a/crates/authifier/src/database/mongo.rs b/crates/authifier/src/database/mongo.rs index 6c47933..be8068d 100644 --- a/crates/authifier/src/database/mongo.rs +++ b/crates/authifier/src/database/mongo.rs @@ -286,6 +286,11 @@ impl AbstractDatabase for MongoDb { }) } + /// Find account by SSO ID + async fn find_account_by_sso_id(&self, idp_id: &str, sub_id: &str) -> Result> { + todo!() + } + /// Find account with active pending email verification async fn find_account_with_email_verification(&self, token: &str) -> Result { self.collection("accounts") diff --git a/crates/authifier/src/impl/account.rs b/crates/authifier/src/impl/account.rs index b69c02f..d3904ec 100644 --- a/crates/authifier/src/impl/account.rs +++ b/crates/authifier/src/impl/account.rs @@ -5,7 +5,7 @@ use crate::{ config::EmailVerificationConfig, models::{ totp::Totp, Account, AuthFlow, DeletionInfo, EmailVerification, MFAMethod, MFAResponse, - MFATicket, PasswordAuth, PasswordReset, Session, + MFATicket, PasswordAuth, PasswordReset, SSOAuth, Session, }, util::{hash_password, normalise_email}, Authifier, AuthifierEvent, Error, Result, Success, @@ -82,6 +82,40 @@ impl Account { } } + /// Create a new account from SSO claims + pub async fn from_claims( + authifier: &Authifier, + idp_id: String, + sub_id: serde_json::Value, + email: String, + ) -> Result { + // Create a new account + let account = Account { + id: ulid::Ulid::new().to_string(), + + email: email.clone(), + email_normalised: normalise_email(email), + + disabled: false, + verification: EmailVerification::Verified, + deletion: None, + lockout: None, + + auth_flow: AuthFlow::SSO(SSOAuth { idp_id, sub_id }), + }; + + account.save(authifier).await?; + + // Create and push event + authifier + .publish_event(AuthifierEvent::CreateAccount { + account: account.clone(), + }) + .await; + + Ok(account) + } + /// Create a new session pub async fn create_session(&self, authifier: &Authifier, name: String) -> Result { let session = Session { diff --git a/crates/rocket_authifier/src/routes/sso/callback.rs b/crates/rocket_authifier/src/routes/sso/callback.rs index d277b6e..99b592c 100644 --- a/crates/rocket_authifier/src/routes/sso/callback.rs +++ b/crates/rocket_authifier/src/routes/sso/callback.rs @@ -3,10 +3,22 @@ use std::collections::HashMap; use authifier::config::Claim; -use authifier::models::IdProvider; +use authifier::models::{Account, IdProvider}; +use authifier::util::{normalise_email, secure_random_str}; use authifier::{Authifier, Error, Result}; +use iso8601_timestamp::Timestamp; use rocket::http::{Cookie, CookieJar}; -use rocket::{serde::json::Json, State}; +use rocket::response::Redirect; +use rocket::time::Duration; +use rocket::State; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LoginToken { + pub iss: String, + pub aud: String, + pub exp: Timestamp, + pub sub: String, +} #[derive(Serialize, Deserialize, JsonSchema, FromForm)] pub struct DataCallback { @@ -18,12 +30,6 @@ pub struct DataCallback { pub id_token: Option, } -#[derive(Serialize, Deserialize, JsonSchema)] -pub struct ResponseCallback { - login_token: String, - redirect_uri: String, -} - /// # Handle the callback from the ID provider /// /// Handle the callback from the ID provider. @@ -33,8 +39,9 @@ pub async fn callback( authifier: &State, data: DataCallback, cookies: &CookieJar<'_>, -) -> Result> { +) -> Result { let secret = authifier.database.find_secret().await?; + let cookie = cookies.get("callback-id").map(Cookie::value); let id: String = match cookie.map(|c| secret.validate_claims(c)).transpose() { @@ -87,9 +94,65 @@ pub async fn callback( claims.extend(values); } - let Some(_sub) = claims.get(&Claim::Id) else { + let Some(sub_id) = claims.get(&Claim::Id) else { return Err(Error::InvalidIdClaim); }; - todo!() + let account = match authifier + .database + .find_account_by_sso_id(&callback.idp_id, &sub_id.to_string()) + .await? + { + Some(mut account) => { + if let Some(email) = claims.get(&Claim::Email).and_then(|value| value.as_str()) { + account.email = email.to_owned(); + account.email_normalised = normalise_email(email.to_owned()); + } + + account + } + None => { + let Some(email) = claims.get(&Claim::Email).and_then(|value| value.as_str()) else { + todo!() + }; + + // Get a normalised representation of the user's email + let email_normalised = normalise_email(email.to_owned()); + + // Try to find an existing account + if let Some(_account) = authifier + .database + .find_account_by_normalised_email(&email_normalised) + .await? + { + todo!() + } + + Account::from_claims( + authifier, + callback.idp_id.clone(), + sub_id.to_owned(), + email.to_owned(), + ) + .await? + } + }; + + let timestamp = Timestamp::now_utc().checked_add(Duration::seconds(60 * 2)); + + let login_token = LoginToken { + iss: callback.idp_id.clone(), + aud: account.id.clone(), + exp: timestamp.map(Into::into).expect("time overflow"), + sub: secure_random_str(64), + }; + + // TODO: will be overwritten? + cookies.add(Cookie::build(("callback-id", String::new()))); + + Ok(Redirect::found(format!( + "{}?redirect_uri={}", + callback.redirect_uri, + secret.sign_claims(&login_token), + ))) } From 6d02a5e464456318c6be4e4f424239b05cfc9b2b Mon Sep 17 00:00:00 2001 From: avdb13 Date: Tue, 24 Dec 2024 11:04:56 +0000 Subject: [PATCH 11/22] comment on authorization step --- crates/authifier/src/impl/id_provider.rs | 9 +++++++++ crates/rocket_authifier/src/routes/sso/authorize.rs | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/crates/authifier/src/impl/id_provider.rs b/crates/authifier/src/impl/id_provider.rs index 5b8c552..93b2d90 100644 --- a/crates/authifier/src/impl/id_provider.rs +++ b/crates/authifier/src/impl/id_provider.rs @@ -41,18 +41,22 @@ impl IdProvider { authifier: &Authifier, redirect_uri: &Url, ) -> Result<(String, Url)> { + // Generate random state, used as callback identifier let state = ulid::Ulid::new().to_string(); + // Generate random nonce, associates session with an ID Token let nonce = match &self.endpoints { Endpoints::Discoverable => Some(secure_random_str(32)), Endpoints::Manual { .. } => None, }; + // Generate PKCE challenge and verifier let (code_verifier, code_challenge) = self.code_challenge.then(create_code_challenge).unzip(); let mut authorization_uri = match &self.endpoints { Endpoints::Discoverable => { + // Fetch authorization endpoint for OIDC provider let metadata = self.discover(authifier).await?; metadata.authorization_endpoint().to_owned() @@ -60,6 +64,7 @@ impl IdProvider { Endpoints::Manual { authorization, .. } => authorization.parse().unwrap(), }; + // Append the client ID, redirect URI and state to the authorization URI { authorization_uri.query_pairs_mut().extend_pairs([ ("client_id", self.credentials.client_id()), @@ -70,12 +75,14 @@ impl IdProvider { ]); } + // Append the nonce if present if let Some(nonce) = nonce.as_deref() { authorization_uri .query_pairs_mut() .extend_pairs([("nonce", nonce)]); } + // Append the PKCE challenge if present if let Some(code_challenge) = code_challenge.as_deref() { authorization_uri.query_pairs_mut().extend_pairs([ ("code_challenge", code_challenge), @@ -90,6 +97,8 @@ impl IdProvider { ..Callback::new(self.id.clone(), redirect_uri.clone()) }; + // TODO: embed callback in cookie as JWT or save callback + // server-side using state as identifier? authifier.database.save_callback(&callback).await?; Ok((state, authorization_uri)) diff --git a/crates/rocket_authifier/src/routes/sso/authorize.rs b/crates/rocket_authifier/src/routes/sso/authorize.rs index e774737..13e3106 100644 --- a/crates/rocket_authifier/src/routes/sso/authorize.rs +++ b/crates/rocket_authifier/src/routes/sso/authorize.rs @@ -18,22 +18,27 @@ pub async fn authorize( redirect_uri: &str, cookies: &CookieJar<'_>, ) -> Result { + // Make sure the redirect URI is valid let Ok(redirect_uri) = redirect_uri.parse() else { return Err(Error::InvalidRedirectUri); }; + // Ensure given ID provider exists let id_provider = match authifier.config.sso.get(idp_id).cloned() { Some(config) => IdProvider::try_from(config).map_err(|_| Error::InvalidIdpConfig)?, None => return Err(Error::InvalidIdpId), }; + // Build authorization URI let (state, uri) = id_provider .create_authorization_uri(authifier, &redirect_uri) .await?; + // Build cookie that can be retrieved during callback let (path, max_age) = ("/sso/callback", Duration::seconds(60 * 10)); let cookie = Cookie::build(("callback-id", state)).http_only(true); + // Add the cookie to the response cookies.add(cookie.path(path).max_age(max_age)); Ok(Redirect::found(uri.to_string())) From d61b9b08d0dc83a1c48e2f7dc5a6491695afc44f Mon Sep 17 00:00:00 2001 From: avdb13 Date: Tue, 24 Dec 2024 11:19:39 +0000 Subject: [PATCH 12/22] comment on callback step --- crates/authifier/src/impl/id_provider.rs | 10 ++++++++-- .../src/routes/sso/callback.rs | 20 ++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/authifier/src/impl/id_provider.rs b/crates/authifier/src/impl/id_provider.rs index 93b2d90..34e7c3d 100644 --- a/crates/authifier/src/impl/id_provider.rs +++ b/crates/authifier/src/impl/id_provider.rs @@ -90,6 +90,7 @@ impl IdProvider { ]); } + // Save callback let callback = Callback { id: state.clone(), nonce, @@ -97,7 +98,7 @@ impl IdProvider { ..Callback::new(self.id.clone(), redirect_uri.clone()) }; - // TODO: embed callback in cookie as JWT or save callback + // TODO: embed callback in cookie as JWT or save callback // server-side using state as identifier? authifier.database.save_callback(&callback).await?; @@ -111,9 +112,10 @@ impl IdProvider { code: &str, state: &str, ) -> Result<(AccessTokenResponse, Option)> { + // Find callback let callback = authifier.database.find_callback(state).await?; - // validate state + // Compare provided state against stored state if state != callback.id { authifier.database.delete_callback(state).await?; @@ -122,6 +124,7 @@ impl IdProvider { let endpoint = match &self.endpoints { Endpoints::Discoverable => { + // Fetch token endpoint for OIDC provider let metadata = self.discover(authifier).await?; metadata.token_endpoint().to_owned() @@ -129,6 +132,7 @@ impl IdProvider { Endpoints::Manual { token, .. } => token.parse().unwrap(), }; + // Build request for access token with authorization code let body = AccessTokenRequest::AuthorizationCode(AuthorizationCodeGrant { code: code.to_owned(), redirect_uri: Some(callback.redirect_uri.parse().unwrap()), @@ -139,9 +143,11 @@ impl IdProvider { match self.request(builder, body).await { Ok(res) if res.status().is_success() => { + // TODO: ID token deserialization Ok((res.json().await.map_err(|_| Error::RequestFailed)?, None)) } Ok(res) => { + // TODO: improve error handling let ErrorResponse { error } = res.json().await.map_err(|_| Error::RequestFailed)?; Err(match &*error { diff --git a/crates/rocket_authifier/src/routes/sso/callback.rs b/crates/rocket_authifier/src/routes/sso/callback.rs index 99b592c..a05b17d 100644 --- a/crates/rocket_authifier/src/routes/sso/callback.rs +++ b/crates/rocket_authifier/src/routes/sso/callback.rs @@ -40,10 +40,13 @@ pub async fn callback( data: DataCallback, cookies: &CookieJar<'_>, ) -> Result { + // Retrieve encoding/decoding secret let secret = authifier.database.find_secret().await?; + // Retrieve cookie provided during authorization let cookie = cookies.get("callback-id").map(Cookie::value); + // Ensure presence and validate integrity let id: String = match cookie.map(|c| secret.validate_claims(c)).transpose() { Ok(value) => value.ok_or(Error::MissingCallback)?, Err(_) => { @@ -51,26 +54,31 @@ pub async fn callback( } }; + // Retrieve associated callback let callback = authifier.database.find_callback(&id).await?; { authifier.database.delete_callback(&id).await?; } + // Ensure given ID provider exists let id_provider = match authifier.config.sso.get(&*callback.idp_id).cloned() { Some(config) => IdProvider::try_from(config).map_err(|_| Error::InvalidIdpConfig)?, None => return Err(Error::InvalidIdpId), }; + // Ensure authorization code was provided let Some(code) = data.code.as_deref() else { return Err(Error::MissingAuthCode); }; + // Exchange authorization code for access token let (response, id_token) = id_provider .exchange_authorization_code(authifier, code, &id) .await?; let mut claims = HashMap::with_capacity(id_provider.claims.len()); + // Extract claims for ID token if let Some(id_token) = id_token { let values = id_provider.claims.iter().filter_map(|(claim, key)| { let value = id_token.get(key).cloned()?; @@ -81,6 +89,7 @@ pub async fn callback( claims.extend(values); } + // Extract claims for userinfo JWT if let Some(userinfo) = id_provider .fetch_userinfo(authifier, &response.access_token) .await? @@ -94,6 +103,7 @@ pub async fn callback( claims.extend(values); } + // Ensure either one contained the identifier claim let Some(sub_id) = claims.get(&Claim::Id) else { return Err(Error::InvalidIdClaim); }; @@ -103,8 +113,10 @@ pub async fn callback( .find_account_by_sso_id(&callback.idp_id, &sub_id.to_string()) .await? { + // Account was previously logged in with through SSO Some(mut account) => { if let Some(email) = claims.get(&Claim::Email).and_then(|value| value.as_str()) { + // Update email if present in claims account.email = email.to_owned(); account.email_normalised = normalise_email(email.to_owned()); } @@ -112,6 +124,7 @@ pub async fn callback( account } None => { + // TODO: no email present in claims? let Some(email) = claims.get(&Claim::Email).and_then(|value| value.as_str()) else { todo!() }; @@ -125,9 +138,12 @@ pub async fn callback( .find_account_by_normalised_email(&email_normalised) .await? { + // TODO: convert existing account to SSO? + todo!() } + // Create new account Account::from_claims( authifier, callback.idp_id.clone(), @@ -140,6 +156,7 @@ pub async fn callback( let timestamp = Timestamp::now_utc().checked_add(Duration::seconds(60 * 2)); + // Generate login token let login_token = LoginToken { iss: callback.idp_id.clone(), aud: account.id.clone(), @@ -147,9 +164,10 @@ pub async fn callback( sub: secure_random_str(64), }; - // TODO: will be overwritten? + // TODO: are we sure this will be overwritten? cookies.add(Cookie::build(("callback-id", String::new()))); + // TODO: URI encoding? Ok(Redirect::found(format!( "{}?redirect_uri={}", callback.redirect_uri, From adbc5680c138904effd7d71bc4ab0157674c85ef Mon Sep 17 00:00:00 2001 From: avdb13 Date: Tue, 24 Dec 2024 11:23:12 +0000 Subject: [PATCH 13/22] comment on login token data --- crates/rocket_authifier/src/routes/sso/callback.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/rocket_authifier/src/routes/sso/callback.rs b/crates/rocket_authifier/src/routes/sso/callback.rs index a05b17d..25dca40 100644 --- a/crates/rocket_authifier/src/routes/sso/callback.rs +++ b/crates/rocket_authifier/src/routes/sso/callback.rs @@ -14,9 +14,13 @@ use rocket::State; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct LoginToken { + // ID provider Id pub iss: String, + // Account Id pub aud: String, + // Expiry timestamp pub exp: Timestamp, + // Login token value pub sub: String, } From 7aae4b6014656b3212b7edf1d67dfd59436c8cb5 Mon Sep 17 00:00:00 2001 From: avdb13 Date: Sat, 22 Mar 2025 00:33:01 +0000 Subject: [PATCH 14/22] undo separation of auth flows --- crates/authifier/src/config/sso.rs | 2 +- crates/authifier/src/database/dummy.rs | 9 ++- crates/authifier/src/database/mongo.rs | 6 +- crates/authifier/src/impl/account.rs | 75 +++++++++---------- crates/authifier/src/impl/id_provider.rs | 4 +- crates/authifier/src/models/account.rs | 42 ++++------- crates/authifier/src/models/id_provider.rs | 2 +- crates/authifier/src/models/secret.rs | 8 +- .../src/routes/account/change_password.rs | 10 +-- .../src/routes/account/password_reset.rs | 11 +-- .../src/routes/mfa/create_ticket.rs | 6 +- .../src/routes/mfa/fetch_recovery.rs | 10 +-- .../src/routes/mfa/fetch_status.rs | 10 +-- .../src/routes/mfa/generate_recovery.rs | 16 +--- .../src/routes/mfa/get_mfa_methods.rs | 8 +- .../src/routes/mfa/totp_disable.rs | 10 +-- .../src/routes/mfa/totp_enable.rs | 10 +-- .../src/routes/mfa/totp_generate_secret.rs | 10 +-- .../src/routes/session/login.rs | 15 ++-- 19 files changed, 102 insertions(+), 162 deletions(-) diff --git a/crates/authifier/src/config/sso.rs b/crates/authifier/src/config/sso.rs index 3dff69a..0042807 100644 --- a/crates/authifier/src/config/sso.rs +++ b/crates/authifier/src/config/sso.rs @@ -71,7 +71,7 @@ pub struct IdProvider { impl Borrow for IdProvider { fn borrow(&self) -> &str { - &*self.id + &self.id } } diff --git a/crates/authifier/src/database/dummy.rs b/crates/authifier/src/database/dummy.rs index 2e6aa60..b7b7d17 100644 --- a/crates/authifier/src/database/dummy.rs +++ b/crates/authifier/src/database/dummy.rs @@ -1,7 +1,6 @@ use crate::{ models::{ - Account, AuthFlow, Callback, DeletionInfo, EmailVerification, Invite, MFATicket, - PasswordAuth, Secret, Session, + Account, Callback, DeletionInfo, EmailVerification, Invite, MFATicket, Secret, Session, }, Error, Result, Success, }; @@ -49,7 +48,11 @@ impl AbstractDatabase for DummyDb { } /// Find account by SSO ID - async fn find_account_by_sso_id(&self, idp_id: &str, sub_id: &str) -> Result> { + async fn find_account_by_sso_id( + &self, + _idp_id: &str, + _sub_id: &str, + ) -> Result> { todo!() } diff --git a/crates/authifier/src/database/mongo.rs b/crates/authifier/src/database/mongo.rs index be8068d..33e6685 100644 --- a/crates/authifier/src/database/mongo.rs +++ b/crates/authifier/src/database/mongo.rs @@ -287,7 +287,11 @@ impl AbstractDatabase for MongoDb { } /// Find account by SSO ID - async fn find_account_by_sso_id(&self, idp_id: &str, sub_id: &str) -> Result> { + async fn find_account_by_sso_id( + &self, + _idp_id: &str, + _sub_id: &str, + ) -> Result> { todo!() } diff --git a/crates/authifier/src/impl/account.rs b/crates/authifier/src/impl/account.rs index d3904ec..6fd0e68 100644 --- a/crates/authifier/src/impl/account.rs +++ b/crates/authifier/src/impl/account.rs @@ -4,8 +4,8 @@ use iso8601_timestamp::Timestamp; use crate::{ config::EmailVerificationConfig, models::{ - totp::Totp, Account, AuthFlow, DeletionInfo, EmailVerification, MFAMethod, MFAResponse, - MFATicket, PasswordAuth, PasswordReset, SSOAuth, Session, + totp::Totp, Account, DeletionInfo, EmailVerification, MFAMethod, MFAResponse, MFATicket, + PasswordReset, Session, }, util::{hash_password, normalise_email}, Authifier, AuthifierEvent, Error, Result, Success, @@ -51,17 +51,16 @@ impl Account { email, email_normalised, + password: Some(password), + id_providers: Default::default(), disabled: false, verification: EmailVerification::Verified, + password_reset: None, deletion: None, lockout: None, - auth_flow: AuthFlow::Password(PasswordAuth { - password, - mfa: Default::default(), - password_reset: None, - }), + mfa: Default::default(), }; // Send email verification @@ -82,11 +81,11 @@ impl Account { } } - /// Create a new account from SSO claims + /// Create a new account from ID provider claims pub async fn from_claims( authifier: &Authifier, - idp_id: String, - sub_id: serde_json::Value, + _idp_id: String, + _sub_id: serde_json::Value, email: String, ) -> Result { // Create a new account @@ -95,13 +94,16 @@ impl Account { email: email.clone(), email_normalised: normalise_email(email), + password: None, + id_providers: Default::default(), disabled: false, verification: EmailVerification::Verified, + password_reset: None, deletion: None, lockout: None, - auth_flow: AuthFlow::SSO(SSOAuth { idp_id, sub_id }), + mfa: Default::default(), }; account.save(authifier).await?; @@ -242,11 +244,7 @@ impl Account { }), )?; - let AuthFlow::Password(auth) = &mut self.auth_flow else { - return Ok(()); - }; - - auth.password_reset = Some(PasswordReset { + self.password_reset = Some(PasswordReset { token, expiry: Timestamp::UNIX_EPOCH + iso8601_timestamp::Duration::milliseconds( @@ -300,21 +298,22 @@ impl Account { /// Verify a user's password is correct pub fn verify_password(&self, plaintext_password: &str) -> Success { - let AuthFlow::Password(auth) = &self.auth_flow else { - return Ok(()); - }; - - argon2::verify_encoded(&auth.password, plaintext_password.as_bytes()) - .map(|v| { - if v { - Ok(()) - } else { - Err(Error::InvalidCredentials) - } - }) - // To prevent user enumeration, we should ignore - // the error and pretend the password is wrong. - .map_err(|_| Error::InvalidCredentials)? + argon2::verify_encoded( + self.password + .as_ref() + .expect("account should have password"), + plaintext_password.as_bytes(), + ) + .map(|v| { + if v { + Ok(()) + } else { + Err(Error::InvalidCredentials) + } + }) + // To prevent user enumeration, we should ignore + // the error and pretend the password is wrong. + .map_err(|_| Error::InvalidCredentials)? } /// Validate an MFA response @@ -324,11 +323,7 @@ impl Account { response: MFAResponse, ticket: Option, ) -> Success { - let AuthFlow::Password(auth) = &mut self.auth_flow else { - return Ok(()); - }; - - let allowed_methods = auth.mfa.get_methods(); + let allowed_methods = self.mfa.get_methods(); match response { MFAResponse::Password { password } => { @@ -340,7 +335,7 @@ impl Account { } MFAResponse::Totp { totp_code } => { if allowed_methods.contains(&MFAMethod::Totp) { - if let Totp::Enabled { .. } = &auth.mfa.totp_token { + if let Totp::Enabled { .. } = &self.mfa.totp_token { // Use TOTP code at generation if applicable if let Some(ticket) = ticket { if let Some(code) = ticket.last_totp_code { @@ -351,7 +346,7 @@ impl Account { } // Otherwise read current TOTP token - if auth.mfa.totp_token.generate_code()? == totp_code { + if self.mfa.totp_token.generate_code()? == totp_code { Ok(()) } else { Err(Error::InvalidToken) @@ -365,13 +360,13 @@ impl Account { } MFAResponse::Recovery { recovery_code } => { if allowed_methods.contains(&MFAMethod::Recovery) { - if let Some(index) = auth + if let Some(index) = self .mfa .recovery_codes .iter() .position(|x| x == &recovery_code) { - auth.mfa.recovery_codes.remove(index); + self.mfa.recovery_codes.remove(index); self.save(authifier).await } else { Err(Error::InvalidToken) diff --git a/crates/authifier/src/impl/id_provider.rs b/crates/authifier/src/impl/id_provider.rs index 34e7c3d..bebc746 100644 --- a/crates/authifier/src/impl/id_provider.rs +++ b/crates/authifier/src/impl/id_provider.rs @@ -227,9 +227,7 @@ impl IdProvider { // TODO: Subject identifier must always be the same match res.json().await.map_err(|_| Error::RequestFailed)? { serde_json::Value::Object(userinfo) => Ok(Some(userinfo.into_iter().collect())), - _ => { - return Err(Error::InvalidUserinfo); - } + _ => Err(Error::InvalidUserinfo), } } diff --git a/crates/authifier/src/models/account.rs b/crates/authifier/src/models/account.rs index 36a7ef5..0022d98 100644 --- a/crates/authifier/src/models/account.rs +++ b/crates/authifier/src/models/account.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use iso8601_timestamp::Timestamp; use super::MultiFactorAuthentication; @@ -48,33 +50,6 @@ pub struct Lockout { pub expiry: Option, } -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct PasswordAuth { - /// Argon2 hashed password - pub password: String, - - /// Multi-factor authentication information - pub mfa: MultiFactorAuthentication, - - /// Password reset information - pub password_reset: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct SSOAuth { - /// Auth Provider - pub idp_id: String, - - /// Subject ID - pub sub_id: serde_json::Value, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub enum AuthFlow { - Password(PasswordAuth), - SSO(SSOAuth), -} - /// Account model #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Account { @@ -90,6 +65,12 @@ pub struct Account { /// (see https://github.com/insertish/authifier/#how-does-authifier-work) pub email_normalised: String, + /// Argon2 hashed password + pub password: Option, + + /// Mapping of ID provider to subject ID + pub id_providers: HashMap, + /// Whether the account is disabled #[serde(default)] pub disabled: bool, @@ -97,12 +78,15 @@ pub struct Account { /// Email verification status pub verification: EmailVerification, + /// Password reset information + pub password_reset: Option, + /// Account deletion information pub deletion: Option, /// Account lockout pub lockout: Option, - /// Authentication flow - pub auth_flow: AuthFlow, + /// Multi-factor authentication information + pub mfa: MultiFactorAuthentication, } diff --git a/crates/authifier/src/models/id_provider.rs b/crates/authifier/src/models/id_provider.rs index 0c733c3..27e9af0 100644 --- a/crates/authifier/src/models/id_provider.rs +++ b/crates/authifier/src/models/id_provider.rs @@ -26,7 +26,7 @@ pub struct IdProvider { impl Borrow for IdProvider { fn borrow(&self) -> &str { - &*self.id + &self.id } } diff --git a/crates/authifier/src/models/secret.rs b/crates/authifier/src/models/secret.rs index 8b4e836..c728125 100644 --- a/crates/authifier/src/models/secret.rs +++ b/crates/authifier/src/models/secret.rs @@ -10,7 +10,13 @@ impl Secret { } pub fn expose(&self) -> &str { - &*self.0 + &self.0 + } +} + +impl Default for Secret { + fn default() -> Self { + Self::new() } } diff --git a/crates/rocket_authifier/src/routes/account/change_password.rs b/crates/rocket_authifier/src/routes/account/change_password.rs index 0469272..a032935 100644 --- a/crates/rocket_authifier/src/routes/account/change_password.rs +++ b/crates/rocket_authifier/src/routes/account/change_password.rs @@ -1,8 +1,8 @@ //! Change account password. //! PATCH /account/change/password -use authifier::models::{Account, AuthFlow}; +use authifier::models::Account; use authifier::util::hash_password; -use authifier::{Authifier, Error, Result}; +use authifier::{Authifier, Result}; use rocket::serde::json::Json; use rocket::State; use rocket_empty::EmptyResponse; @@ -38,12 +38,8 @@ pub async fn change_password( // Ensure given password is correct account.verify_password(&data.current_password)?; - let AuthFlow::Password(auth) = &mut account.auth_flow else { - return Err(Error::NotAvailable); - }; - // Hash and replace password - auth.password = hash_password(data.password)?; + account.password = hash_password(data.password).map(Some)?; // Commit to database account.save(authifier).await.map(|_| EmptyResponse) diff --git a/crates/rocket_authifier/src/routes/account/password_reset.rs b/crates/rocket_authifier/src/routes/account/password_reset.rs index 2e72452..c58631c 100644 --- a/crates/rocket_authifier/src/routes/account/password_reset.rs +++ b/crates/rocket_authifier/src/routes/account/password_reset.rs @@ -1,8 +1,7 @@ //! Confirm a password reset. //! PATCH /account/reset_password -use authifier::models::AuthFlow; use authifier::util::hash_password; -use authifier::{Authifier, Error, Result}; +use authifier::{Authifier, Result}; use rocket::serde::json::Json; use rocket::State; use rocket_empty::EmptyResponse; @@ -45,13 +44,9 @@ pub async fn password_reset( .assert_safe(&data.password) .await?; - let AuthFlow::Password(auth) = &mut account.auth_flow else { - return Err(Error::NotAvailable); - }; - // Update the account - auth.password = hash_password(data.password)?; - auth.password_reset = None; + account.password = hash_password(data.password).map(Some)?; + account.password_reset = None; account.lockout = None; // Commit to database diff --git a/crates/rocket_authifier/src/routes/mfa/create_ticket.rs b/crates/rocket_authifier/src/routes/mfa/create_ticket.rs index 26fbaa4..dd10ab5 100644 --- a/crates/rocket_authifier/src/routes/mfa/create_ticket.rs +++ b/crates/rocket_authifier/src/routes/mfa/create_ticket.rs @@ -1,6 +1,6 @@ //! Create a new MFA ticket or validate an existing one. //! PUT /mfa/ticket -use authifier::models::{Account, AuthFlow, MFAResponse, MFATicket, UnvalidatedTicket}; +use authifier::models::{Account, MFAResponse, MFATicket, UnvalidatedTicket}; use authifier::{Authifier, Error, Result}; use rocket::serde::json::Json; use rocket::State; @@ -27,10 +27,6 @@ pub async fn create_ticket( _ => return Err(Error::InvalidToken), }; - let AuthFlow::Password(_) = &account.auth_flow else { - return Err(Error::NotAvailable); - }; - // Validate the MFA response account .consume_mfa_response(authifier, data.into_inner(), None) diff --git a/crates/rocket_authifier/src/routes/mfa/fetch_recovery.rs b/crates/rocket_authifier/src/routes/mfa/fetch_recovery.rs index de868eb..675fd50 100644 --- a/crates/rocket_authifier/src/routes/mfa/fetch_recovery.rs +++ b/crates/rocket_authifier/src/routes/mfa/fetch_recovery.rs @@ -1,8 +1,8 @@ //! Fetch recovery codes for an account. //! POST /mfa/recovery use authifier::{ - models::{Account, AuthFlow, ValidatedTicket}, - Error, Result, + models::{Account, ValidatedTicket}, + Result, }; use rocket::serde::json::Json; @@ -15,11 +15,7 @@ pub async fn fetch_recovery( account: Account, _ticket: ValidatedTicket, ) -> Result>> { - let AuthFlow::Password(auth) = &account.auth_flow else { - return Err(Error::NotAvailable); - }; - - Ok(Json(auth.mfa.recovery_codes.clone())) + Ok(Json(account.mfa.recovery_codes.clone())) } #[cfg(test)] diff --git a/crates/rocket_authifier/src/routes/mfa/fetch_status.rs b/crates/rocket_authifier/src/routes/mfa/fetch_status.rs index aeed13e..7c7cca3 100644 --- a/crates/rocket_authifier/src/routes/mfa/fetch_status.rs +++ b/crates/rocket_authifier/src/routes/mfa/fetch_status.rs @@ -1,8 +1,8 @@ //! Fetch MFA status of an account. //! GET /mfa use authifier::{ - models::{Account, AuthFlow, MultiFactorAuthentication}, - Error, Result, + models::{Account, MultiFactorAuthentication}, + Result, }; use rocket::serde::json::Json; @@ -36,11 +36,7 @@ impl From for MultiFactorStatus { #[openapi(tag = "MFA")] #[get("/")] pub async fn fetch_status(account: Account) -> Result> { - let AuthFlow::Password(auth) = &account.auth_flow else { - return Err(Error::NotAvailable); - }; - - Ok(Json(auth.mfa.clone().into())) + Ok(Json(account.mfa.clone().into())) } #[cfg(test)] diff --git a/crates/rocket_authifier/src/routes/mfa/generate_recovery.rs b/crates/rocket_authifier/src/routes/mfa/generate_recovery.rs index 0014075..a0b5ceb 100644 --- a/crates/rocket_authifier/src/routes/mfa/generate_recovery.rs +++ b/crates/rocket_authifier/src/routes/mfa/generate_recovery.rs @@ -1,7 +1,7 @@ //! Re-generate recovery codes for an account. //! PATCH /mfa/recovery -use authifier::models::{Account, AuthFlow, ValidatedTicket}; -use authifier::{Authifier, Error, Result}; +use authifier::models::{Account, ValidatedTicket}; +use authifier::{Authifier, Result}; use rocket::serde::json::Json; use rocket::State; @@ -15,22 +15,14 @@ pub async fn generate_recovery( mut account: Account, _ticket: ValidatedTicket, ) -> Result>> { - let AuthFlow::Password(auth) = &mut account.auth_flow else { - return Err(Error::NotAvailable); - }; - // Generate new codes - auth.mfa.generate_recovery_codes(); + account.mfa.generate_recovery_codes(); // Save account model account.save(authifier).await?; - let AuthFlow::Password(auth) = &account.auth_flow else { - return Err(Error::NotAvailable); - }; - // Return them to the user - Ok(Json(auth.mfa.recovery_codes.clone())) + Ok(Json(account.mfa.recovery_codes.clone())) } #[cfg(test)] diff --git a/crates/rocket_authifier/src/routes/mfa/get_mfa_methods.rs b/crates/rocket_authifier/src/routes/mfa/get_mfa_methods.rs index 59ea59a..c93009e 100644 --- a/crates/rocket_authifier/src/routes/mfa/get_mfa_methods.rs +++ b/crates/rocket_authifier/src/routes/mfa/get_mfa_methods.rs @@ -1,6 +1,6 @@ //! Fetch available MFA methods. //! GET /mfa/methods -use authifier::models::{Account, AuthFlow, MFAMethod}; +use authifier::models::{Account, MFAMethod}; use rocket::serde::json::Json; /// # Get MFA Methods @@ -9,11 +9,7 @@ use rocket::serde::json::Json; #[openapi(tag = "MFA")] #[get("/methods")] pub async fn get_mfa_methods(account: Account) -> Json> { - let AuthFlow::Password(auth) = &account.auth_flow else { - return Json(Vec::new()); - }; - - Json(auth.mfa.get_methods()) + Json(account.mfa.get_methods()) } #[cfg(test)] diff --git a/crates/rocket_authifier/src/routes/mfa/totp_disable.rs b/crates/rocket_authifier/src/routes/mfa/totp_disable.rs index ff6e010..2e094ca 100644 --- a/crates/rocket_authifier/src/routes/mfa/totp_disable.rs +++ b/crates/rocket_authifier/src/routes/mfa/totp_disable.rs @@ -1,8 +1,8 @@ //! Disable TOTP 2FA. //! DELETE /mfa/totp use authifier::models::totp::Totp; -use authifier::models::{Account, AuthFlow, ValidatedTicket}; -use authifier::{Authifier, Error, Result}; +use authifier::models::{Account, ValidatedTicket}; +use authifier::{Authifier, Result}; use rocket::State; use rocket_empty::EmptyResponse; @@ -16,12 +16,8 @@ pub async fn totp_disable( mut account: Account, _ticket: ValidatedTicket, ) -> Result { - let AuthFlow::Password(auth) = &mut account.auth_flow else { - return Err(Error::NotAvailable); - }; - // Disable TOTP - auth.mfa.totp_token = Totp::Disabled; + account.mfa.totp_token = Totp::Disabled; // Save model to database account.save(authifier).await.map(|_| EmptyResponse) diff --git a/crates/rocket_authifier/src/routes/mfa/totp_enable.rs b/crates/rocket_authifier/src/routes/mfa/totp_enable.rs index 6b202fd..0c9e7f6 100644 --- a/crates/rocket_authifier/src/routes/mfa/totp_enable.rs +++ b/crates/rocket_authifier/src/routes/mfa/totp_enable.rs @@ -1,7 +1,7 @@ //! Generate a new secret for TOTP. //! POST /mfa/totp -use authifier::models::{Account, AuthFlow, MFAResponse}; -use authifier::{Authifier, Error, Result}; +use authifier::models::{Account, MFAResponse}; +use authifier::{Authifier, Result}; use rocket::serde::json::Json; use rocket::State; use rocket_empty::EmptyResponse; @@ -16,12 +16,8 @@ pub async fn totp_enable( mut account: Account, data: Json, ) -> Result { - let AuthFlow::Password(auth) = &mut account.auth_flow else { - return Err(Error::NotAvailable); - }; - // Enable TOTP 2FA - auth.mfa.enable_totp(data.into_inner())?; + account.mfa.enable_totp(data.into_inner())?; // Save model to database account.save(authifier).await.map(|_| EmptyResponse) diff --git a/crates/rocket_authifier/src/routes/mfa/totp_generate_secret.rs b/crates/rocket_authifier/src/routes/mfa/totp_generate_secret.rs index 857bb56..0319e73 100644 --- a/crates/rocket_authifier/src/routes/mfa/totp_generate_secret.rs +++ b/crates/rocket_authifier/src/routes/mfa/totp_generate_secret.rs @@ -1,7 +1,7 @@ //! Generate a new secret for TOTP. //! POST /mfa/totp -use authifier::models::{Account, AuthFlow, ValidatedTicket}; -use authifier::{Authifier, Error, Result}; +use authifier::models::{Account, ValidatedTicket}; +use authifier::{Authifier, Result}; use rocket::serde::json::Json; use rocket::State; @@ -21,12 +21,8 @@ pub async fn totp_generate_secret( mut account: Account, _ticket: ValidatedTicket, ) -> Result> { - let AuthFlow::Password(auth) = &mut account.auth_flow else { - return Err(Error::NotAvailable); - }; - // Generate a new secret - let secret = auth.mfa.generate_new_totp_secret()?; + let secret = account.mfa.generate_new_totp_secret()?; // Save model to database account.save(authifier).await?; diff --git a/crates/rocket_authifier/src/routes/session/login.rs b/crates/rocket_authifier/src/routes/session/login.rs index cb5092c..804c0dc 100644 --- a/crates/rocket_authifier/src/routes/session/login.rs +++ b/crates/rocket_authifier/src/routes/session/login.rs @@ -3,9 +3,7 @@ use std::ops::Add; use std::time::Duration; -use authifier::models::{ - AuthFlow, EmailVerification, Lockout, MFAMethod, MFAResponse, MFATicket, Session, -}; +use authifier::models::{EmailVerification, Lockout, MFAMethod, MFAResponse, MFATicket, Session}; use authifier::util::normalise_email; use authifier::{Authifier, Error, Result}; use iso8601_timestamp::Timestamp; @@ -75,10 +73,7 @@ pub async fn login( .find_account_by_normalised_email(&email_normalised) .await? { - // Make sure the account uses password authentication - let AuthFlow::Password(auth) = &account.auth_flow else { - return Err(Error::NotAvailable); - }; + // TODO: Make sure the account uses password authentication // Make sure the account has been verified if let EmailVerification::Pending { .. } = account.verification { @@ -147,16 +142,16 @@ pub async fn login( } // Check whether an MFA step is required - if auth.mfa.is_active() { + if account.mfa.is_active() { // Create a new ticket let mut ticket = MFATicket::new(account.id, false); - ticket.populate(&auth.mfa).await; + ticket.populate(&account.mfa).await; ticket.save(authifier).await?; // Return applicable methods return Ok(Json(ResponseLogin::MFA { ticket: ticket.token, - allowed_methods: auth.mfa.get_methods(), + allowed_methods: account.mfa.get_methods(), })); } From 5a258206f490ae64668f6d6c59625537027fce25 Mon Sep 17 00:00:00 2001 From: avdb13 Date: Tue, 25 Mar 2025 00:55:56 +0000 Subject: [PATCH 15/22] remove ID provider model & make configuration deserializable --- crates/authifier/src/config/sso.rs | 40 +++++++++-- crates/authifier/src/impl/id_provider.rs | 4 +- crates/authifier/src/models/id_provider.rs | 66 ------------------- crates/authifier/src/models/mod.rs | 2 - .../src/routes/sso/authorize.rs | 10 +-- .../src/routes/sso/callback.rs | 11 ++-- 6 files changed, 48 insertions(+), 85 deletions(-) delete mode 100644 crates/authifier/src/models/id_provider.rs diff --git a/crates/authifier/src/config/sso.rs b/crates/authifier/src/config/sso.rs index 0042807..b7429b2 100644 --- a/crates/authifier/src/config/sso.rs +++ b/crates/authifier/src/config/sso.rs @@ -53,13 +53,13 @@ pub enum Claim { Email, } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Clone)] pub struct IdProvider { pub id: String, - pub issuer: String, + pub issuer: reqwest::Url, pub name: Option, - pub icon: Option, + pub icon: Option, pub scopes: Vec, pub endpoints: Endpoints, @@ -105,11 +105,41 @@ impl Serialize for SSO { } impl<'de> Deserialize<'de> for SSO { - fn deserialize(_: D) -> Result + fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { - todo!() + #[derive(Deserialize)] + pub struct Mock { + pub issuer: reqwest::Url, + pub name: Option, + pub icon: Option, + + pub scopes: Vec, + pub endpoints: Endpoints, + pub credentials: Credentials, + pub claims: HashMap, + + pub code_challenge: bool, + } + + let map: HashMap = + HashMap::deserialize(deserializer).map_err(serde::de::Error::custom)?; + + Ok(SSO(map + .into_iter() + .map(|(id, mock)| IdProvider { + id, + issuer: mock.issuer, + name: mock.name, + icon: mock.icon, + scopes: mock.scopes, + endpoints: mock.endpoints, + credentials: mock.credentials, + claims: mock.claims, + code_challenge: mock.code_challenge, + }) + .collect())) } } diff --git a/crates/authifier/src/impl/id_provider.rs b/crates/authifier/src/impl/id_provider.rs index bebc746..606e976 100644 --- a/crates/authifier/src/impl/id_provider.rs +++ b/crates/authifier/src/impl/id_provider.rs @@ -19,8 +19,8 @@ use serde::Serialize; use sha2::{Digest, Sha256}; use crate::{ - config::{Credentials, Endpoints}, - models::{Callback, IdProvider}, + config::{Credentials, Endpoints, IdProvider}, + models::Callback, util::secure_random_str, Authifier, Error, Result, }; diff --git a/crates/authifier/src/models/id_provider.rs b/crates/authifier/src/models/id_provider.rs deleted file mode 100644 index 27e9af0..0000000 --- a/crates/authifier/src/models/id_provider.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::{ - borrow::Borrow, - collections::HashMap, - hash::{Hash, Hasher}, -}; - -use serde::{Deserialize, Serialize}; - -use crate::config::{Claim, Credentials, Endpoints, IdProvider as IdProviderConfig}; - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct IdProvider { - pub id: String, - - pub issuer: reqwest::Url, - pub name: Option, - pub icon: Option, - - pub scopes: Vec, - pub endpoints: Endpoints, - pub credentials: Credentials, - pub claims: HashMap, - - pub code_challenge: bool, -} - -impl Borrow for IdProvider { - fn borrow(&self) -> &str { - &self.id - } -} - -impl PartialEq for IdProvider { - fn eq(&self, other: &Self) -> bool { - self.id == other.id - } -} - -impl Eq for IdProvider {} - -impl Hash for IdProvider { - fn hash(&self, state: &mut H) - where - H: Hasher, - { - self.id.hash(state); - } -} - -impl TryFrom for IdProvider { - type Error = ::Err; - - fn try_from(config: IdProviderConfig) -> Result { - Ok(Self { - id: config.id, - issuer: config.issuer.parse()?, - name: config.name, - icon: config.icon.as_deref().map(str::parse).transpose()?, - scopes: config.scopes, - endpoints: config.endpoints, - credentials: config.credentials, - claims: config.claims, - code_challenge: config.code_challenge, - }) - } -} diff --git a/crates/authifier/src/models/mod.rs b/crates/authifier/src/models/mod.rs index 367a3b2..fedd8a5 100644 --- a/crates/authifier/src/models/mod.rs +++ b/crates/authifier/src/models/mod.rs @@ -1,6 +1,5 @@ mod account; mod callback; -mod id_provider; mod invite; mod mfa; mod secret; @@ -9,7 +8,6 @@ mod ticket; pub use account::*; pub use callback::*; -pub use id_provider::*; pub use invite::*; pub use mfa::*; pub use secret::*; diff --git a/crates/rocket_authifier/src/routes/sso/authorize.rs b/crates/rocket_authifier/src/routes/sso/authorize.rs index 13e3106..05464d7 100644 --- a/crates/rocket_authifier/src/routes/sso/authorize.rs +++ b/crates/rocket_authifier/src/routes/sso/authorize.rs @@ -1,6 +1,5 @@ //! Redirect to authorization interface //! GET /sso/authorize -use authifier::models::IdProvider; use authifier::{Authifier, Error, Result}; use rocket::http::{Cookie, CookieJar}; use rocket::response::Redirect; @@ -24,10 +23,11 @@ pub async fn authorize( }; // Ensure given ID provider exists - let id_provider = match authifier.config.sso.get(idp_id).cloned() { - Some(config) => IdProvider::try_from(config).map_err(|_| Error::InvalidIdpConfig)?, - None => return Err(Error::InvalidIdpId), - }; + let id_provider = authifier + .config + .sso + .get(idp_id) + .ok_or(Error::InvalidIdpId)?; // Build authorization URI let (state, uri) = id_provider diff --git a/crates/rocket_authifier/src/routes/sso/callback.rs b/crates/rocket_authifier/src/routes/sso/callback.rs index 25dca40..9ecf18a 100644 --- a/crates/rocket_authifier/src/routes/sso/callback.rs +++ b/crates/rocket_authifier/src/routes/sso/callback.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use authifier::config::Claim; -use authifier::models::{Account, IdProvider}; +use authifier::models::Account; use authifier::util::{normalise_email, secure_random_str}; use authifier::{Authifier, Error, Result}; use iso8601_timestamp::Timestamp; @@ -65,10 +65,11 @@ pub async fn callback( } // Ensure given ID provider exists - let id_provider = match authifier.config.sso.get(&*callback.idp_id).cloned() { - Some(config) => IdProvider::try_from(config).map_err(|_| Error::InvalidIdpConfig)?, - None => return Err(Error::InvalidIdpId), - }; + let id_provider = authifier + .config + .sso + .get(&*callback.idp_id) + .ok_or(Error::InvalidIdpId)?; // Ensure authorization code was provided let Some(code) = data.code.as_deref() else { From 1d7b41cf1a011c8e63a8830f7c3a9a5637ac5522 Mon Sep 17 00:00:00 2001 From: avdb13 Date: Tue, 25 Mar 2025 01:16:19 +0000 Subject: [PATCH 16/22] test configuration and small fixes --- crates/authifier/src/config/sso.rs | 83 +++++++++++++++++++++--- crates/authifier/src/impl/id_provider.rs | 6 +- 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/crates/authifier/src/config/sso.rs b/crates/authifier/src/config/sso.rs index b7429b2..e446465 100644 --- a/crates/authifier/src/config/sso.rs +++ b/crates/authifier/src/config/sso.rs @@ -5,6 +5,7 @@ use std::{ ops::Deref, }; +use reqwest::Url; use serde::{Deserialize, Deserializer, Serialize, Serializer}; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -12,9 +13,9 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; pub enum Endpoints { Discoverable, Manual { - authorization: String, - token: String, - userinfo: String, + authorization: Url, + token: Url, + userinfo: Url, }, } @@ -53,13 +54,13 @@ pub enum Claim { Email, } -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct IdProvider { pub id: String, - pub issuer: reqwest::Url, + pub issuer: Url, pub name: Option, - pub icon: Option, + pub icon: Option, pub scopes: Vec, pub endpoints: Endpoints, @@ -92,7 +93,7 @@ impl Hash for IdProvider { } } -#[derive(Default, Clone)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct SSO(HashSet); impl Serialize for SSO { @@ -111,9 +112,9 @@ impl<'de> Deserialize<'de> for SSO { { #[derive(Deserialize)] pub struct Mock { - pub issuer: reqwest::Url, + pub issuer: Url, pub name: Option, - pub icon: Option, + pub icon: Option, pub scopes: Vec, pub endpoints: Endpoints, @@ -150,3 +151,67 @@ impl Deref for SSO { &self.0 } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_sso_config() { + let value = serde_json::json!( + { + "Gitlab": { + "issuer": "https://gitlab.com", + "scopes": ["openid"], + + "endpoints": { + "type": "discoverable" + }, + "credentials": { + "type": "post", + "client_id": "foobar", + "client_secret": "baz" + }, + "claims": { + "id": "sub", + "email": "preferred_email" + }, + + "code_challenge": false, + } + } + ); + + let result: SSO = serde_json::from_value(value).expect("config deserializes successfully"); + + assert_eq!( + result, + SSO([IdProvider { + id: "Gitlab".to_owned(), + + issuer: "https://gitlab.com" + .parse() + .expect("issuer should be valid"), + name: None, + icon: None, + + scopes: vec!["openid".to_owned()], + endpoints: Endpoints::Discoverable, + credentials: Credentials::Post { + client_id: "foobar".to_owned(), + client_secret: "baz".to_owned(), + }, + claims: [ + (Claim::Id, "sub".to_owned()), + (Claim::Email, "preferred_email".to_owned()) + ] + .into_iter() + .collect(), + + code_challenge: false, + }] + .into_iter() + .collect()) + ); + } +} diff --git a/crates/authifier/src/impl/id_provider.rs b/crates/authifier/src/impl/id_provider.rs index 606e976..2416c3b 100644 --- a/crates/authifier/src/impl/id_provider.rs +++ b/crates/authifier/src/impl/id_provider.rs @@ -61,7 +61,7 @@ impl IdProvider { metadata.authorization_endpoint().to_owned() } - Endpoints::Manual { authorization, .. } => authorization.parse().unwrap(), + Endpoints::Manual { authorization, .. } => authorization.clone(), }; // Append the client ID, redirect URI and state to the authorization URI @@ -129,7 +129,7 @@ impl IdProvider { metadata.token_endpoint().to_owned() } - Endpoints::Manual { token, .. } => token.parse().unwrap(), + Endpoints::Manual { token, .. } => token.clone(), }; // Build request for access token with authorization code @@ -175,7 +175,7 @@ impl IdProvider { metadata.userinfo_endpoint.as_ref().cloned() } - Endpoints::Manual { userinfo, .. } => Some(userinfo.parse().unwrap()), + Endpoints::Manual { userinfo, .. } => Some(userinfo.clone()), }) else { return Ok(None); }; From d3b5e6f2b3835bec7ab165c2dd08148d4b07d86f Mon Sep 17 00:00:00 2001 From: avdb13 Date: Tue, 1 Apr 2025 16:04:27 +0000 Subject: [PATCH 17/22] fix clippy warning --- crates/authifier/src/config/sso.rs | 6 +++--- crates/authifier/src/impl/id_provider.rs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/authifier/src/config/sso.rs b/crates/authifier/src/config/sso.rs index e446465..14fcb0f 100644 --- a/crates/authifier/src/config/sso.rs +++ b/crates/authifier/src/config/sso.rs @@ -13,9 +13,9 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; pub enum Endpoints { Discoverable, Manual { - authorization: Url, - token: Url, - userinfo: Url, + authorization: Box, + token: Box, + userinfo: Box, }, } diff --git a/crates/authifier/src/impl/id_provider.rs b/crates/authifier/src/impl/id_provider.rs index 2416c3b..89c1103 100644 --- a/crates/authifier/src/impl/id_provider.rs +++ b/crates/authifier/src/impl/id_provider.rs @@ -61,7 +61,7 @@ impl IdProvider { metadata.authorization_endpoint().to_owned() } - Endpoints::Manual { authorization, .. } => authorization.clone(), + Endpoints::Manual { authorization, .. } => *authorization.clone(), }; // Append the client ID, redirect URI and state to the authorization URI @@ -129,7 +129,7 @@ impl IdProvider { metadata.token_endpoint().to_owned() } - Endpoints::Manual { token, .. } => token.clone(), + Endpoints::Manual { token, .. } => *token.clone(), }; // Build request for access token with authorization code @@ -175,7 +175,7 @@ impl IdProvider { metadata.userinfo_endpoint.as_ref().cloned() } - Endpoints::Manual { userinfo, .. } => Some(userinfo.clone()), + Endpoints::Manual { userinfo, .. } => Some(*userinfo.clone()), }) else { return Ok(None); }; From 0240d4e2f198cc90bee160a4fa4d438160e6bba3 Mon Sep 17 00:00:00 2001 From: avdb13 Date: Sat, 26 Apr 2025 04:34:00 +0000 Subject: [PATCH 18/22] rebase-induced errors --- crates/authifier/src/database/definition.rs | 9 +++++---- crates/authifier/src/database/mongo.rs | 17 ++++------------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/crates/authifier/src/database/definition.rs b/crates/authifier/src/database/definition.rs index 1f6299c..bf826cc 100644 --- a/crates/authifier/src/database/definition.rs +++ b/crates/authifier/src/database/definition.rs @@ -20,7 +20,8 @@ pub trait AbstractDatabase: std::marker::Sync { ) -> Result>; /// Find account by SSO ID - async fn find_account_by_sso_id(&self, idp_id: &str, sub_id: &str) -> Result>; + async fn find_account_by_sso_id(&self, idp_id: &str, sub_id: &str) + -> Result>; /// Find account with active pending email verification async fn find_account_with_email_verification(&self, token: &str) -> Result; @@ -64,9 +65,6 @@ pub trait AbstractDatabase: std::marker::Sync { // Save callback async fn save_callback(&self, callback: &Callback) -> Success; - // Save secret - async fn save_secret(&self, secret: &Secret) -> Success; - /// Save session async fn save_session(&self, session: &Session) -> Success; @@ -76,6 +74,9 @@ pub trait AbstractDatabase: std::marker::Sync { /// Save ticket async fn save_ticket(&self, ticket: &MFATicket) -> Success; + /// Save secret + async fn save_secret(&self, secret: &Secret) -> Success; + /// Delete callback async fn delete_callback(&self, id: &str) -> Success; diff --git a/crates/authifier/src/database/mongo.rs b/crates/authifier/src/database/mongo.rs index 33e6685..6910cea 100644 --- a/crates/authifier/src/database/mongo.rs +++ b/crates/authifier/src/database/mongo.rs @@ -374,12 +374,9 @@ impl AbstractDatabase for MongoDb { async fn find_callback(&self, id: &str) -> Result { let callback: Callback = self .collection("callbacks") - .find_one( - doc! { - "_id": id - }, - None, - ) + .find_one(doc! { + "_id": id + }) .await .map_err(|_| Error::DatabaseError { operation: "find_one", @@ -428,10 +425,7 @@ impl AbstractDatabase for MongoDb { /// Find secret async fn find_secret(&self) -> Result { - let res = self - .collection::("secret") - .find_one(doc! {}, None) - .await; + let res = self.collection::("secret").find_one(doc! {}).await; match res.map_err(|_| Error::DatabaseError { operation: "find_one", @@ -566,7 +560,6 @@ impl AbstractDatabase for MongoDb { with: "callback", })?, }, - UpdateOptions::builder().upsert(true).build(), ) .await .map_err(|_| Error::DatabaseError { @@ -633,7 +626,6 @@ impl AbstractDatabase for MongoDb { with: "secret", })?, }, - UpdateOptions::builder().upsert(true).build(), ) .await .map_err(|_| Error::DatabaseError { @@ -673,7 +665,6 @@ impl AbstractDatabase for MongoDb { doc! { "_id": id }, - None, ) .await .map_err(|_| Error::DatabaseError { From 6c9d53ca964bc23d4ddfca4177a50231723bf8b1 Mon Sep 17 00:00:00 2001 From: avdb13 Date: Sat, 26 Apr 2025 04:41:45 +0000 Subject: [PATCH 19/22] provide serialize for SSO --- crates/authifier/src/config/sso.rs | 35 ++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/authifier/src/config/sso.rs b/crates/authifier/src/config/sso.rs index 14fcb0f..3916119 100644 --- a/crates/authifier/src/config/sso.rs +++ b/crates/authifier/src/config/sso.rs @@ -97,11 +97,42 @@ impl Hash for IdProvider { pub struct SSO(HashSet); impl Serialize for SSO { - fn serialize(&self, _: S) -> Result + fn serialize(&self, serializer: S) -> Result where S: Serializer, { - todo!() + #[derive(Serialize)] + struct Mock { + pub issuer: Url, + pub name: Option, + pub icon: Option, + pub scopes: Vec, + pub endpoints: Endpoints, + pub credentials: Credentials, + pub claims: HashMap, + pub code_challenge: bool, + } + + let map: HashMap = self + .iter() + .map(|provider| { + ( + provider.id.clone(), + Mock { + issuer: provider.issuer.clone(), + name: provider.name.clone(), + icon: provider.icon.clone(), + scopes: provider.scopes.clone(), + endpoints: provider.endpoints.clone(), + credentials: provider.credentials.clone(), + claims: provider.claims.clone(), + code_challenge: provider.code_challenge, + }, + ) + }) + .collect(); + + map.serialize(serializer) } } From 73a3c42ce685a945702b26cfa4937b96b52e4f7a Mon Sep 17 00:00:00 2001 From: avdb13 Date: Sat, 26 Apr 2025 05:04:45 +0000 Subject: [PATCH 20/22] provide SSO routes --- crates/rocket_authifier/src/routes/sso/mod.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/rocket_authifier/src/routes/sso/mod.rs b/crates/rocket_authifier/src/routes/sso/mod.rs index 530ed7e..cecc80d 100644 --- a/crates/rocket_authifier/src/routes/sso/mod.rs +++ b/crates/rocket_authifier/src/routes/sso/mod.rs @@ -1,2 +1,9 @@ +use revolt_okapi::openapi3::OpenApi; +use rocket::Route; + pub mod authorize; pub mod callback; + +pub fn routes() -> (Vec, OpenApi) { + openapi_get_routes_spec![authorize::authorize, callback::callback] +} From cf1dedf7d12c5d4f807248fcbf289c7e90e57cf8 Mon Sep 17 00:00:00 2001 From: avdb13 Date: Sat, 26 Apr 2025 10:40:58 +0000 Subject: [PATCH 21/22] working --- config.toml | 16 ++ crates/authifier/src/config/mod.rs | 13 ++ crates/authifier/src/config/sso.rs | 32 +++- crates/authifier/src/database/definition.rs | 3 +- crates/authifier/src/database/dummy.rs | 13 +- crates/authifier/src/database/mongo.rs | 34 ++-- crates/authifier/src/derive/rocket.rs | 48 +++--- crates/authifier/src/impl/account.rs | 14 +- crates/authifier/src/impl/id_provider.rs | 51 ++++-- crates/authifier/src/impl/secret.rs | 11 +- crates/authifier/src/result.rs | 12 +- crates/rocket_authifier/Cargo.toml | 7 +- .../rocket_authifier/examples/rocket_sso.rs | 70 ++++++++ .../src/routes/account/change_password.rs | 6 +- .../src/routes/session/login.rs | 55 +++++++ .../src/routes/session/mod.rs | 1 + .../src/routes/sso/authorize.rs | 16 +- .../src/routes/sso/callback.rs | 153 +++++++++--------- rocket.toml | 4 + 19 files changed, 398 insertions(+), 161 deletions(-) create mode 100644 config.toml create mode 100644 crates/rocket_authifier/examples/rocket_sso.rs create mode 100644 rocket.toml diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..e3624fb --- /dev/null +++ b/config.toml @@ -0,0 +1,16 @@ +server_url = "https://inv.kurosaki.cx" + +[sso.gitlab] +issuer = "https://gitlab.computer.surgery" +scopes = ["openid", "email"] +code_challenge = false + +[sso.gitlab.endpoints] +type = "discoverable" + +[sso.gitlab.credentials] +type = "post" +client_id = "xxx" +client_secret = "xxx" + +[sso.gitlab.claims] diff --git a/crates/authifier/src/config/mod.rs b/crates/authifier/src/config/mod.rs index 79b06a2..69cb0a5 100644 --- a/crates/authifier/src/config/mod.rs +++ b/crates/authifier/src/config/mod.rs @@ -11,6 +11,7 @@ pub use captcha::*; pub use email_verification::*; pub use ip_resolve::*; pub use passwords::*; +use reqwest::Url; pub use shield::*; pub use sso::*; @@ -18,29 +19,41 @@ pub use sso::*; #[derive(Default, Serialize, Deserialize, Clone)] pub struct Config { /// Check if passwords are compromised + #[serde(default)] pub password_scanning: PasswordScanning, /// Email block list /// /// Use to block common disposable mail providers. /// Enabled by default. + #[serde(default)] pub email_block_list: EmailBlockList, /// Captcha options + #[serde(default)] pub captcha: Captcha, /// Authifier Shield settings + #[serde(default)] pub shield: Shield, /// Email verification + #[serde(default)] pub email_verification: EmailVerificationConfig, /// Whether to only allow registrations if the user has an invite code + #[serde(default)] pub invite_only: bool, /// Whether this application is running behind Cloudflare + #[serde(default)] pub resolve_ip: ResolveIp, /// Single sign-on + #[serde(default)] pub sso: SSO, + + /// Public server URL + #[serde(default)] + pub server_url: Option, } diff --git a/crates/authifier/src/config/sso.rs b/crates/authifier/src/config/sso.rs index 3916119..9f4ce2c 100644 --- a/crates/authifier/src/config/sso.rs +++ b/crates/authifier/src/config/sso.rs @@ -3,10 +3,11 @@ use std::{ collections::{HashMap, HashSet}, hash::{Hash, Hasher}, ops::Deref, + str::FromStr, }; use reqwest::Url; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "lowercase", tag = "type")] @@ -45,13 +46,14 @@ impl Credentials { } } -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Serialize, Clone, PartialEq, Eq, Hash)] #[serde(rename_all = "lowercase")] pub enum Claim { Id, Username, Picture, Email, + Custom(String), } #[derive(Clone, Debug)] @@ -156,7 +158,7 @@ impl<'de> Deserialize<'de> for SSO { } let map: HashMap = - HashMap::deserialize(deserializer).map_err(serde::de::Error::custom)?; + HashMap::deserialize(deserializer).map_err(de::Error::custom)?; Ok(SSO(map .into_iter() @@ -183,6 +185,30 @@ impl Deref for SSO { } } +impl<'de> Deserialize<'de> for Claim { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Deserialize::deserialize(deserializer) + .and_then(|s| str::parse(s).map_err(de::Error::custom)) + } +} + +impl FromStr for Claim { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(match s.to_lowercase().as_str() { + "sub" | "id" => Claim::Id, + "username" | "preferred_username" => Claim::Username, + "picture" => Claim::Picture, + "email" => Claim::Email, + other => Claim::Custom(other.to_string()), + }) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/authifier/src/database/definition.rs b/crates/authifier/src/database/definition.rs index bf826cc..0228868 100644 --- a/crates/authifier/src/database/definition.rs +++ b/crates/authifier/src/database/definition.rs @@ -20,8 +20,7 @@ pub trait AbstractDatabase: std::marker::Sync { ) -> Result>; /// Find account by SSO ID - async fn find_account_by_sso_id(&self, idp_id: &str, sub_id: &str) - -> Result>; + async fn find_account_by_sso_id(&self, idp_id: &str, sub_id: &str) -> Result>; /// Find account with active pending email verification async fn find_account_with_email_verification(&self, token: &str) -> Result; diff --git a/crates/authifier/src/database/dummy.rs b/crates/authifier/src/database/dummy.rs index b7b7d17..f2a6195 100644 --- a/crates/authifier/src/database/dummy.rs +++ b/crates/authifier/src/database/dummy.rs @@ -48,12 +48,13 @@ impl AbstractDatabase for DummyDb { } /// Find account by SSO ID - async fn find_account_by_sso_id( - &self, - _idp_id: &str, - _sub_id: &str, - ) -> Result> { - todo!() + async fn find_account_by_sso_id(&self, idp_id: &str, sub_id: &str) -> Result> { + let accounts = self.accounts.lock().await; + let sub_id = serde_json::from_str(sub_id).map_err(|_| Error::InvalidIdClaim)?; + Ok(accounts + .values() + .find(|account| account.id_providers.get(idp_id) == Some(&sub_id)) + .cloned()) } /// Find account with active pending email verification diff --git a/crates/authifier/src/database/mongo.rs b/crates/authifier/src/database/mongo.rs index 6910cea..b32ccd0 100644 --- a/crates/authifier/src/database/mongo.rs +++ b/crates/authifier/src/database/mongo.rs @@ -287,12 +287,26 @@ impl AbstractDatabase for MongoDb { } /// Find account by SSO ID - async fn find_account_by_sso_id( - &self, - _idp_id: &str, - _sub_id: &str, - ) -> Result> { - todo!() + async fn find_account_by_sso_id(&self, idp_id: &str, sub_id: &str) -> Result> { + let sub_id: serde_json::Value = + serde_json::from_str(sub_id).map_err(|_| Error::InvalidIdClaim)?; + + let sub_id = bson::to_bson(&sub_id).map_err(|_| Error::DatabaseError { + operation: "find_one", + with: "account", + })?; + + self.collection("accounts") + .find_one(doc! { + "id_providers": { + idp_id: sub_id, + }, + }) + .await + .map_err(|_| Error::DatabaseError { + operation: "find_one", + with: "account", + }) } /// Find account with active pending email verification @@ -661,11 +675,9 @@ impl AbstractDatabase for MongoDb { /// Delete callback async fn delete_callback(&self, id: &str) -> Success { self.collection::("callbacks") - .delete_one( - doc! { - "_id": id - }, - ) + .delete_one(doc! { + "_id": id + }) .await .map_err(|_| Error::DatabaseError { operation: "delete_one", diff --git a/crates/authifier/src/derive/rocket.rs b/crates/authifier/src/derive/rocket.rs index 9f04004..4d0d2c5 100644 --- a/crates/authifier/src/derive/rocket.rs +++ b/crates/authifier/src/derive/rocket.rs @@ -49,29 +49,31 @@ impl<'r> Responder<'r, 'static> for Error { Error::DisallowedMFAMethod => Status::BadRequest, Error::NotAvailable => Status::NotFound, - Error::InvalidEndpoints => todo!(), - Error::StateMismatch => todo!(), - - Error::RequestFailed => todo!(), - Error::InvalidRequest => todo!(), - Error::InvalidClient => todo!(), - Error::InvalidGrant => todo!(), - Error::UnauthorizedClient => todo!(), - Error::UnsupportedGrantType => todo!(), - Error::InvalidScope => todo!(), - - Error::ContentTypeMismatch => todo!(), - Error::InsufficientScope => todo!(), - Error::InvalidUserinfo => todo!(), - - Error::InvalidRedirectUri => todo!(), - Error::InvalidIdpId => todo!(), - Error::InvalidIdpConfig => todo!(), - - Error::MissingCallback => todo!(), - Error::InvalidCallback => todo!(), - Error::MissingAuthCode => todo!(), - Error::InvalidIdClaim => todo!(), + Error::InvalidEndpoints => Status::BadRequest, + Error::StateMismatch => Status::BadRequest, + + Error::RequestFailed => Status::BadRequest, + Error::InvalidRequest => Status::BadRequest, + Error::InvalidClient => Status::BadRequest, + Error::InvalidGrant => Status::BadRequest, + Error::UnauthorizedClient => Status::BadRequest, + Error::UnsupportedGrantType => Status::BadRequest, + Error::InvalidScope => Status::BadRequest, + + Error::ContentTypeMismatch => Status::BadRequest, + Error::InsufficientScope => Status::BadRequest, + Error::InvalidUserinfo => Status::BadRequest, + + Error::InvalidRedirectUri => Status::BadRequest, + Error::InvalidIdpId => Status::BadRequest, + Error::InvalidIdpConfig => Status::BadRequest, + + Error::MissingCallback => Status::BadRequest, + Error::InvalidCallback => Status::BadRequest, + Error::MissingAuthCode => Status::BadRequest, + Error::InvalidIdClaim => Status::BadRequest, + + Error::PasswordDisabled => Status::BadRequest, }; // Serialize the error data structure into JSON. diff --git a/crates/authifier/src/impl/account.rs b/crates/authifier/src/impl/account.rs index 6fd0e68..a71e056 100644 --- a/crates/authifier/src/impl/account.rs +++ b/crates/authifier/src/impl/account.rs @@ -84,18 +84,20 @@ impl Account { /// Create a new account from ID provider claims pub async fn from_claims( authifier: &Authifier, - _idp_id: String, - _sub_id: serde_json::Value, - email: String, + idp_id: &str, + sub_id: &serde_json::Value, + email: &str, ) -> Result { // Create a new account let account = Account { id: ulid::Ulid::new().to_string(), - email: email.clone(), - email_normalised: normalise_email(email), + email: email.to_owned(), + email_normalised: normalise_email(email.to_owned()), password: None, - id_providers: Default::default(), + id_providers: [(idp_id.to_owned(), sub_id.to_owned())] + .into_iter() + .collect(), disabled: false, verification: EmailVerification::Verified, diff --git a/crates/authifier/src/impl/id_provider.rs b/crates/authifier/src/impl/id_provider.rs index 89c1103..b50737d 100644 --- a/crates/authifier/src/impl/id_provider.rs +++ b/crates/authifier/src/impl/id_provider.rs @@ -64,11 +64,22 @@ impl IdProvider { Endpoints::Manual { authorization, .. } => *authorization.clone(), }; + let server_url = authifier + .config + .server_url + .as_ref() + .expect("server must have a URL"); + + let callback_url = format!( + "https://{}/auth/sso/callback", + server_url.domain().expect("server must have a valid URL") + ); + // Append the client ID, redirect URI and state to the authorization URI { authorization_uri.query_pairs_mut().extend_pairs([ ("client_id", self.credentials.client_id()), - ("redirect_uri", redirect_uri.as_ref()), + ("redirect_uri", &callback_url), ("response_type", "code"), ("scope", &*self.scopes.join(" ")), ("state", &*state), @@ -98,8 +109,8 @@ impl IdProvider { ..Callback::new(self.id.clone(), redirect_uri.clone()) }; - // TODO: embed callback in cookie as JWT or save callback - // server-side using state as identifier? + eprintln!("{callback:?}"); + authifier.database.save_callback(&callback).await?; Ok((state, authorization_uri)) @@ -109,19 +120,9 @@ impl IdProvider { pub async fn exchange_authorization_code( &self, authifier: &Authifier, + callback: &Callback, code: &str, - state: &str, ) -> Result<(AccessTokenResponse, Option)> { - // Find callback - let callback = authifier.database.find_callback(state).await?; - - // Compare provided state against stored state - if state != callback.id { - authifier.database.delete_callback(state).await?; - - return Err(Error::StateMismatch); - } - let endpoint = match &self.endpoints { Endpoints::Discoverable => { // Fetch token endpoint for OIDC provider @@ -132,10 +133,21 @@ impl IdProvider { Endpoints::Manual { token, .. } => *token.clone(), }; + let server_url = authifier + .config + .server_url + .as_ref() + .expect("server must have a URL"); + + let callback_url = format!( + "https://{}/auth/sso/callback", + server_url.domain().expect("server must have a valid URL") + ); + // Build request for access token with authorization code let body = AccessTokenRequest::AuthorizationCode(AuthorizationCodeGrant { code: code.to_owned(), - redirect_uri: Some(callback.redirect_uri.parse().unwrap()), + redirect_uri: Some(callback_url.parse().unwrap()), code_verifier: callback.code_verifier.clone(), }); @@ -296,8 +308,13 @@ impl IdProvider { response.json().await.map_err(|_| Error::InvalidEndpoints)?; metadata - .validate(self.issuer.as_ref()) - .map_err(|_| Error::InvalidEndpoints) + .clone() + .validate(self.issuer.as_ref().trim_end_matches("/")) + .map_err(|e| { + eprintln!("{e} {:?} {:?}", self.issuer.as_str(), &metadata.issuer); + + Error::InvalidEndpoints + }) } } diff --git a/crates/authifier/src/impl/secret.rs b/crates/authifier/src/impl/secret.rs index fb75201..789f97b 100644 --- a/crates/authifier/src/impl/secret.rs +++ b/crates/authifier/src/impl/secret.rs @@ -1,4 +1,4 @@ -use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; use serde::{de::DeserializeOwned, Serialize}; use crate::models::Secret; @@ -23,7 +23,14 @@ impl Secret { { let secret = self.expose().as_bytes(); - let (validation, key) = (Validation::default(), DecodingKey::from_secret(secret)); + let (mut validation, key) = ( + Validation::new(Algorithm::HS256), + DecodingKey::from_secret(secret), + ); + + validation.set_required_spec_claims(&["sub", "exp", "aud", "iss"]); + validation.validate_aud = false; + validation.validate_nbf = false; jsonwebtoken::decode(token, &key, &validation).map(|token| token.claims) } diff --git a/crates/authifier/src/result.rs b/crates/authifier/src/result.rs index e7fd957..199a160 100644 --- a/crates/authifier/src/result.rs +++ b/crates/authifier/src/result.rs @@ -36,6 +36,13 @@ pub enum Error { TotpAlreadyEnabled, DisallowedMFAMethod, + PasswordDisabled, + + InvalidRedirectUri, + InvalidIdpId, + InvalidIdpConfig, + + // --- NotAvailable, InvalidEndpoints, @@ -52,10 +59,7 @@ pub enum Error { ContentTypeMismatch, InsufficientScope, InvalidUserinfo, - - InvalidRedirectUri, - InvalidIdpId, - InvalidIdpConfig, + // --- MissingCallback, InvalidCallback, diff --git a/crates/rocket_authifier/Cargo.toml b/crates/rocket_authifier/Cargo.toml index f7bbbb9..46a09e2 100644 --- a/crates/rocket_authifier/Cargo.toml +++ b/crates/rocket_authifier/Cargo.toml @@ -18,7 +18,8 @@ example = [ "mongodb", ] -default = [] +# default = [] +default = ["example"] [dependencies] # Authifier @@ -52,3 +53,7 @@ async-std = { version = "1.9.0", features = [ "tokio1", "attributes", ], optional = true } + +[[example]] +name = "sso" +path = "crates/rocket_authifier/examples/rocket_sso.rs" diff --git a/crates/rocket_authifier/examples/rocket_sso.rs b/crates/rocket_authifier/examples/rocket_sso.rs new file mode 100644 index 0000000..7691127 --- /dev/null +++ b/crates/rocket_authifier/examples/rocket_sso.rs @@ -0,0 +1,70 @@ +//! Run example with `cargo run --example rocket_mongodb --features example` + +use revolt_okapi::openapi3::OpenApi; + +#[macro_use] +extern crate rocket; + +#[cfg(feature = "example")] +#[launch] +async fn rocket() -> _ { + use authifier::database::DummyDb; + use authifier::models::Secret; + use authifier::Migration; + use revolt_rocket_okapi::{mount_endpoints_and_merged_docs, settings::OpenApiSettings}; + use rocket::figment::providers::{Format as _, Toml}; + use rocket::figment::Figment; + + let database = authifier::database::Database::Dummy(DummyDb::default()); + + database.save_secret(&Secret::new()).await.unwrap(); + + for migration in [Migration::WipeAll, Migration::M2022_06_03EnsureUpToSpec] { + database.run_migration(migration).await.unwrap(); + } + + let config = Figment::new() + .merge(Toml::file("config.toml")) + .extract() + .unwrap(); + + let authifier = authifier::Authifier { + database, + config, + ..Default::default() + }; + + let mut rocket = rocket::build(); + let settings = OpenApiSettings::default(); + + mount_endpoints_and_merged_docs! { + rocket, "/".to_owned(), settings, + "/" => (vec![], custom_openapi_spec()), + "/auth/account" => rocket_authifier::routes::account::routes(), + "/auth/session" => rocket_authifier::routes::session::routes(), + "/auth/mfa" => rocket_authifier::routes::mfa::routes(), + "/auth/sso" => rocket_authifier::routes::sso::routes(), + }; + + rocket.manage(authifier).mount( + "/swagger/", + revolt_rocket_okapi::swagger_ui::make_swagger_ui( + &revolt_rocket_okapi::swagger_ui::SwaggerUIConfig { + url: "../openapi.json".to_owned(), + ..Default::default() + }, + ), + ) +} + +#[cfg(not(feature = "example"))] +fn main() { + panic!("Enable `example` feature to run this example!"); +} + +fn custom_openapi_spec() -> OpenApi { + OpenApi { + openapi: OpenApi::default_version(), + ..Default::default() + } +} diff --git a/crates/rocket_authifier/src/routes/account/change_password.rs b/crates/rocket_authifier/src/routes/account/change_password.rs index a032935..4aefa39 100644 --- a/crates/rocket_authifier/src/routes/account/change_password.rs +++ b/crates/rocket_authifier/src/routes/account/change_password.rs @@ -2,7 +2,7 @@ //! PATCH /account/change/password use authifier::models::Account; use authifier::util::hash_password; -use authifier::{Authifier, Result}; +use authifier::{Authifier, Error, Result}; use rocket::serde::json::Json; use rocket::State; use rocket_empty::EmptyResponse; @@ -26,6 +26,10 @@ pub async fn change_password( mut account: Account, data: Json, ) -> Result { + if !account.id_providers.is_empty() && account.password.is_none() { + return Err(Error::PasswordDisabled); + } + let data = data.into_inner(); // Verify password can be used diff --git a/crates/rocket_authifier/src/routes/session/login.rs b/crates/rocket_authifier/src/routes/session/login.rs index 804c0dc..8ed0332 100644 --- a/crates/rocket_authifier/src/routes/session/login.rs +++ b/crates/rocket_authifier/src/routes/session/login.rs @@ -10,6 +10,8 @@ use iso8601_timestamp::Timestamp; use rocket::serde::json::Json; use rocket::State; +use crate::routes::sso::callback::LoginToken; + /// # Login Data #[derive(Serialize, Deserialize, JsonSchema)] #[serde(untagged)] @@ -36,6 +38,11 @@ pub enum DataLogin { }, } +#[derive(Serialize, Deserialize, JsonSchema, FromForm)] +pub struct DataToken { + login_token: String, +} + #[derive(Serialize, Deserialize, JsonSchema)] #[serde(tag = "result")] pub enum ResponseLogin { @@ -204,6 +211,54 @@ pub async fn login( ))) } +/// # Token Login +/// +/// Login to an account with a token. +#[openapi(tag = "Session")] +#[get("/login?")] +pub async fn token_login( + authifier: &State, + data: DataToken, +) -> Result> { + let secret = authifier.database.find_secret().await?; + + let login_token: LoginToken = secret + .validate_claims(&data.login_token) + .map_err(|_| Error::InvalidToken)?; + + // Lookup the email in database + let record = authifier + .database + .find_account_by_sso_id(&login_token.iss, &login_token.sub) + .await?; + + eprintln!("{record:?}"); + + let account = record.ok_or(Error::InvalidToken)?; + + // Make sure the account has been verified + if let EmailVerification::Pending { .. } = account.verification { + return Err(Error::UnverifiedAccount); + } + + // Generate a session name + let name = login_token + .username + .unwrap_or_else(|| "Unknown".to_string()); + + // Prevent disabled accounts from logging in + if account.disabled { + return Ok(Json(ResponseLogin::Disabled { + user_id: account.id, + })); + } + + // Create and return a new session + Ok(Json(ResponseLogin::Success( + account.create_session(authifier, name).await?, + ))) +} + #[cfg(test)] #[cfg(feature = "test")] mod tests { diff --git a/crates/rocket_authifier/src/routes/session/mod.rs b/crates/rocket_authifier/src/routes/session/mod.rs index 9e11561..3f6f513 100644 --- a/crates/rocket_authifier/src/routes/session/mod.rs +++ b/crates/rocket_authifier/src/routes/session/mod.rs @@ -11,6 +11,7 @@ pub mod revoke_all; pub fn routes() -> (Vec, OpenApi) { openapi_get_routes_spec![ login::login, + login::token_login, logout::logout, fetch_all::fetch_all, revoke::revoke, diff --git a/crates/rocket_authifier/src/routes/sso/authorize.rs b/crates/rocket_authifier/src/routes/sso/authorize.rs index 05464d7..4a4b64b 100644 --- a/crates/rocket_authifier/src/routes/sso/authorize.rs +++ b/crates/rocket_authifier/src/routes/sso/authorize.rs @@ -1,7 +1,7 @@ //! Redirect to authorization interface //! GET /sso/authorize use authifier::{Authifier, Error, Result}; -use rocket::http::{Cookie, CookieJar}; +use rocket::http::{Cookie, CookieJar, SameSite}; use rocket::response::Redirect; use rocket::time::Duration; use rocket::State; @@ -10,7 +10,7 @@ use rocket::State; /// /// Redirect to authorization interface. #[openapi(tag = "SSO")] -#[get("/sso/authorize/?")] +#[get("/authorize/?")] pub async fn authorize( authifier: &State, idp_id: &str, @@ -34,9 +34,17 @@ pub async fn authorize( .create_authorization_uri(authifier, &redirect_uri) .await?; + if let Some(cookie) = cookies.get("callback-id").cloned() { + cookies.remove(cookie); + } + // Build cookie that can be retrieved during callback - let (path, max_age) = ("/sso/callback", Duration::seconds(60 * 10)); - let cookie = Cookie::build(("callback-id", state)).http_only(true); + let (path, max_age) = ("/auth/sso/callback", Duration::seconds(60 * 5)); + + let cookie = Cookie::build(("callback-id", state)) + .same_site(SameSite::Lax) + .http_only(true) + .secure(true); // Add the cookie to the response cookies.add(cookie.path(path).max_age(max_age)); diff --git a/crates/rocket_authifier/src/routes/sso/callback.rs b/crates/rocket_authifier/src/routes/sso/callback.rs index 9ecf18a..082544a 100644 --- a/crates/rocket_authifier/src/routes/sso/callback.rs +++ b/crates/rocket_authifier/src/routes/sso/callback.rs @@ -1,27 +1,28 @@ //! Handle the callback from the ID provider //! GET /sso/authorize use std::collections::HashMap; +use std::str::FromStr; use authifier::config::Claim; use authifier::models::Account; -use authifier::util::{normalise_email, secure_random_str}; +use authifier::util::normalise_email; use authifier::{Authifier, Error, Result}; -use iso8601_timestamp::Timestamp; -use rocket::http::{Cookie, CookieJar}; +use rocket::http::hyper::Uri; +use rocket::http::{Cookie, CookieJar, RawStr}; use rocket::response::Redirect; -use rocket::time::Duration; +use rocket::time::{Duration, OffsetDateTime}; use rocket::State; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct LoginToken { // ID provider Id pub iss: String, - // Account Id - pub aud: String, // Expiry timestamp - pub exp: Timestamp, + pub exp: i64, // Login token value pub sub: String, + + pub username: Option, } #[derive(Serialize, Deserialize, JsonSchema, FromForm)] @@ -44,138 +45,128 @@ pub async fn callback( data: DataCallback, cookies: &CookieJar<'_>, ) -> Result { - // Retrieve encoding/decoding secret - let secret = authifier.database.find_secret().await?; - - // Retrieve cookie provided during authorization - let cookie = cookies.get("callback-id").map(Cookie::value); - - // Ensure presence and validate integrity - let id: String = match cookie.map(|c| secret.validate_claims(c)).transpose() { - Ok(value) => value.ok_or(Error::MissingCallback)?, - Err(_) => { - return Err(Error::InvalidCallback); - } - }; - - // Retrieve associated callback - let callback = authifier.database.find_callback(&id).await?; + // Verify callback state using stored cookie + let id = cookies + .get("callback-id") + .map(Cookie::value) + .ok_or(Error::MissingCallback)?; + + // Retrieve and immediately delete the stored callback state + let callback = authifier.database.find_callback(id).await?; { - authifier.database.delete_callback(&id).await?; + authifier.database.delete_callback(id).await?; } - // Ensure given ID provider exists + // Validate the identity provider let id_provider = authifier .config .sso .get(&*callback.idp_id) .ok_or(Error::InvalidIdpId)?; - // Ensure authorization code was provided let Some(code) = data.code.as_deref() else { return Err(Error::MissingAuthCode); }; - // Exchange authorization code for access token + // Exchange authorization code for tokens let (response, id_token) = id_provider - .exchange_authorization_code(authifier, code, &id) + .exchange_authorization_code(authifier, &callback, code) .await?; - let mut claims = HashMap::with_capacity(id_provider.claims.len()); - - // Extract claims for ID token - if let Some(id_token) = id_token { - let values = id_provider.claims.iter().filter_map(|(claim, key)| { - let value = id_token.get(key).cloned()?; - - Some((claim.to_owned(), value)) - }); + let mut id_token = id_token.unwrap_or_default(); - claims.extend(values); - } - - // Extract claims for userinfo JWT + // Fetch additional userinfo if possible if let Some(userinfo) = id_provider .fetch_userinfo(authifier, &response.access_token) .await? { - let values = id_provider.claims.iter().filter_map(|(claim, key)| { - let value = userinfo.get(key).cloned()?; + id_token.extend(userinfo); + } - Some((claim.to_owned(), value)) - }); + let claims: HashMap<_, _> = id_token + .iter() + .map(|(key, value)| match Claim::from_str(key) { + Ok(claim) => (claim, value.to_owned()), + Err(_) => unreachable!("infallible"), + }) + .collect(); - claims.extend(values); - } + eprintln!("{:?}", &claims); - // Ensure either one contained the identifier claim + // Ensure that we received the mandatory subject ID let Some(sub_id) = claims.get(&Claim::Id) else { - return Err(Error::InvalidIdClaim); + return Err(Error::InvalidIdClaim); // Required for account mapping }; - let account = match authifier + // Create new account or update existing one + match authifier .database .find_account_by_sso_id(&callback.idp_id, &sub_id.to_string()) .await? { - // Account was previously logged in with through SSO Some(mut account) => { + // Update email if provided in claims if let Some(email) = claims.get(&Claim::Email).and_then(|value| value.as_str()) { - // Update email if present in claims account.email = email.to_owned(); account.email_normalised = normalise_email(email.to_owned()); } - - account } None => { - // TODO: no email present in claims? let Some(email) = claims.get(&Claim::Email).and_then(|value| value.as_str()) else { + // TODO: Should handle missing email case properly todo!() }; - // Get a normalised representation of the user's email let email_normalised = normalise_email(email.to_owned()); - // Try to find an existing account - if let Some(_account) = authifier + // Check for existing account by email + if let Some(mut account) = authifier .database .find_account_by_normalised_email(&email_normalised) .await? { - // TODO: convert existing account to SSO? - - todo!() + account + .id_providers + .insert(callback.idp_id.clone(), sub_id.to_owned()); + } else { + Account::from_claims(authifier, &callback.idp_id, sub_id, email).await?; } - - // Create new account - Account::from_claims( - authifier, - callback.idp_id.clone(), - sub_id.to_owned(), - email.to_owned(), - ) - .await? } }; - let timestamp = Timestamp::now_utc().checked_add(Duration::seconds(60 * 2)); + // Generate short-lived login token + let exp = OffsetDateTime::now_utc() + .checked_add(Duration::minutes(2)) + .expect("time overflow"); - // Generate login token let login_token = LoginToken { iss: callback.idp_id.clone(), - aud: account.id.clone(), - exp: timestamp.map(Into::into).expect("time overflow"), - sub: secure_random_str(64), + sub: sub_id.to_string(), + exp: exp.unix_timestamp(), + + username: claims + .get(&Claim::Username) + .and_then(|s| s.as_str().map(str::to_owned)), }; - // TODO: are we sure this will be overwritten? - cookies.add(Cookie::build(("callback-id", String::new()))); + eprintln!("{login_token:?}"); + + if let Some(cookie) = cookies.get("callback-id").cloned() { + cookies.remove(cookie); + } + + let base_uri = callback + .redirect_uri + .parse::() + .map_err(|_| Error::InvalidRedirectUri)?; + + let secret = authifier.database.find_secret().await?; + + let claims = secret.sign_claims(&login_token); - // TODO: URI encoding? Ok(Redirect::found(format!( - "{}?redirect_uri={}", - callback.redirect_uri, - secret.sign_claims(&login_token), + "{base_uri}?login_token={}", + // Percent-encode the JWT to prevent injection + RawStr::new(&claims).percent_encode() ))) } diff --git a/rocket.toml b/rocket.toml new file mode 100644 index 0000000..499fee5 --- /dev/null +++ b/rocket.toml @@ -0,0 +1,4 @@ +[default] +address = "0.0.0.0" +port = 4444 +secret_key = "1234" # Required for cookies From 815cc867dd1eb371aba7a571e784e5dec30a3d3a Mon Sep 17 00:00:00 2001 From: avdb13 Date: Sun, 29 Jun 2025 17:51:21 +0000 Subject: [PATCH 22/22] small changes --- crates/authifier/src/impl/secret.rs | 3 ++- crates/rocket_authifier/Cargo.toml | 4 ---- crates/rocket_authifier/src/routes/session/login.rs | 11 +++++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/authifier/src/impl/secret.rs b/crates/authifier/src/impl/secret.rs index 789f97b..2772992 100644 --- a/crates/authifier/src/impl/secret.rs +++ b/crates/authifier/src/impl/secret.rs @@ -28,7 +28,8 @@ impl Secret { DecodingKey::from_secret(secret), ); - validation.set_required_spec_claims(&["sub", "exp", "aud", "iss"]); + validation.set_required_spec_claims(&["sub", "exp", "iss"]); + validation.validate_aud = false; validation.validate_nbf = false; diff --git a/crates/rocket_authifier/Cargo.toml b/crates/rocket_authifier/Cargo.toml index a8b6e0e..fb5c3c7 100644 --- a/crates/rocket_authifier/Cargo.toml +++ b/crates/rocket_authifier/Cargo.toml @@ -52,7 +52,3 @@ async-std = { version = "1.9.0", features = [ "tokio1", "attributes", ], optional = true } - -[[example]] -name = "sso" -path = "crates/rocket_authifier/examples/rocket_sso.rs" \ No newline at end of file diff --git a/crates/rocket_authifier/src/routes/session/login.rs b/crates/rocket_authifier/src/routes/session/login.rs index 8ed0332..86af19f 100644 --- a/crates/rocket_authifier/src/routes/session/login.rs +++ b/crates/rocket_authifier/src/routes/session/login.rs @@ -222,11 +222,14 @@ pub async fn token_login( ) -> Result> { let secret = authifier.database.find_secret().await?; - let login_token: LoginToken = secret - .validate_claims(&data.login_token) - .map_err(|_| Error::InvalidToken)?; + eprint!("{:?}", &data.login_token); + + let login_token: LoginToken = secret.validate_claims(&data.login_token).map_err(|e| { + eprint!("{e}"); + + Error::InvalidToken + })?; - // Lookup the email in database let record = authifier .database .find_account_by_sso_id(&login_token.iss, &login_token.sub)