From 10ce7ea3a95a83c353516aa53c87b73d6fa906a4 Mon Sep 17 00:00:00 2001 From: avdb13 Date: Thu, 11 Jul 2024 21:55:52 +0200 Subject: [PATCH] initial commit --- src/api/client_server/sso.rs | 648 +++++++++++++++++++++++++++++++++++ src/service/sso/data.rs | 9 + src/service/sso/mod.rs | 299 ++++++++++++++++ src/service/sso/templates.rs | 34 ++ 4 files changed, 990 insertions(+) create mode 100644 src/api/client_server/sso.rs create mode 100644 src/service/sso/data.rs create mode 100644 src/service/sso/mod.rs create mode 100644 src/service/sso/templates.rs diff --git a/src/api/client_server/sso.rs b/src/api/client_server/sso.rs new file mode 100644 index 00000000..c6d26d27 --- /dev/null +++ b/src/api/client_server/sso.rs @@ -0,0 +1,648 @@ +use std::{borrow::Borrow, collections::HashMap, iter::Iterator, time::SystemTime}; + +use crate::{ + config::{ + sso::{Registration, Template}, + IdpConfig, + }, + service::sso::{ + templates, LoginToken, RegistrationInfo, RegistrationToken, ValidationData, + REGISTRATION_EXPIRATION_SECS, SESSION_EXPIRATION_SECS, SSO_AUTH_EXPIRATION_SECS, + SSO_SESSION_COOKIE, + }, + services, utils, Error, Result, Ruma, +}; +use axum::{ + extract::RawQuery, + response::{AppendHeaders, IntoResponse, Redirect}, + RequestExt, +}; +use axum_extra::{ + headers::{self, HeaderMapExt}, + TypedHeader, +}; +use http::header; +use mas_oidc_client::{ + requests::{ + authorization_code::{self, AuthorizationRequestData, AuthorizationValidationData}, + jose::{self, JwtVerificationData}, + userinfo, + }, + types::{ + client_credentials::ClientCredentials, + errors::ClientError, + iana::jose::JsonWebSignatureAlg, + requests::{AccessTokenResponse, AuthorizationResponse}, + }, +}; +use rand::{rngs::StdRng, SeedableRng}; +use ruma::{ + api::client::{ + error::ErrorKind, + session::{self, sso_login, sso_login_with_provider}, + }, + events::GlobalAccountDataEventType, + push, OwnedMxcUri, UserId, +}; +use serde_json::Number; +use tracing::error; +use url::Url; + +pub const CALLBACK_PATH: &str = "_matrix/client/unstable/sso/callback"; + +/// # `GET /_matrix/client/v3/login/sso/redirect` +/// +/// Redirect the user to the SSO interface. +/// TODO: this should be removed once Ruma supports trailing slashes. +pub async fn get_sso_redirect( + body: Ruma, +) -> Result { + let sso_login_with_provider::v3::Response { location, cookie } = + get_sso_redirect_with_provider( + Ruma { + body: sso_login_with_provider::v3::Request::new( + Default::default(), + body.redirect_url.clone(), + ), + ..body + } + .into(), + ) + .await?; + + Ok(sso_login::v3::Response { location, cookie }) +} + +/// # `GET /_matrix/client/v3/login/sso/redirect/{idpId}` +/// +/// Redirects the user to the SSO interface. +pub async fn get_sso_redirect_with_provider( + body: Ruma, +) -> Result { + let idp_ids: Vec<&str> = services() + .globals + .config + .idps + .iter() + .map(Borrow::borrow) + .collect(); + + let provider = match &*idp_ids { + [] => { + return Err(Error::BadRequest( + ErrorKind::forbidden(), + "Single Sign-On is disabled.", + )); + } + [idp_id] => services().sso.get(idp_id).expect("we know it exists"), + [_, ..] => services().sso.get(&body.idp_id).ok_or_else(|| { + Error::BadRequest(ErrorKind::InvalidParam, "Unknown identity provider.") + })?, + }; + + let redirect_url = body + .redirect_url + .parse::() + .map_err(|_| Error::BadRequest(ErrorKind::InvalidParam, "Invalid redirect_url."))?; + + let mut callback = services() + .globals + .well_known_client() + .parse::() + .map_err(|_| Error::bad_config("Invalid well_known_client url."))?; + callback.set_path(CALLBACK_PATH); + + let (auth_url, validation_data) = authorization_code::build_authorization_url( + provider.metadata.authorization_endpoint().clone(), + AuthorizationRequestData::new( + provider.config.client_id.clone(), + provider.config.scopes.clone(), + redirect_url, + ), + &mut StdRng::from_entropy(), + ) + .map_err(|_| Error::BadRequest(ErrorKind::Unknown, "Failed to build authorization_url."))?; + + let signed = services().globals.sign_claims(&ValidationData::new( + provider.borrow().to_string(), + validation_data, + )); + + Ok(sso_login_with_provider::v3::Response { + location: auth_url.to_string(), + cookie: Some( + utils::build_cookie( + SSO_SESSION_COOKIE, + &signed, + "/_conduit/client/sso/callback", + Some(SSO_AUTH_EXPIRATION_SECS), + ) + .to_string(), + ), + }) +} + +/// # `GET /_conduit/client/sso/callback` +/// +/// Validate the authorization response received from the identity provider. +/// On success, generate a login token, add it to `redirectUrl` as a query and perform the redirect. +/// If this is the first login, register the user, possibly interactively through a fallback page. +pub async fn get_sso_callback(req: axum::extract::Request) -> Result { + let query = req.uri().query().ok_or_else(|| { + Error::BadRequest(ErrorKind::MissingParam, "Empty authorization callback.") + })?; + + let AuthorizationResponse { + code, + access_token, + token_type, + id_token, + expires_in, + } = serde_html_form::from_str::(query).map_err(|_| { + serde_html_form::from_str::(query).unwrap_or_else(|_| { + error!("Failed to deserialize authorization callback: {}", callback); + + Error::BadRequest( + ErrorKind::Unknown, + "Failed to deserialize authorization callback.", + ) + }) + })?; + + let cookie = req + .extract::>>() + .await + .map_err(|_| Error::BadRequest(ErrorKind::InvalidParam, "Invalid session cookie."))? + .ok_or_else(|_| Error::BadRequest(ErrorKind::MissingParam, "Missing session cookie."))?; + + let ValidationData { + provider, + inner: validation_data, + } = services() + .globals + .validate_claims( + cookie.get(SSO_SESSION_COOKIE).ok_or_else(|| { + Error::BadRequest(ErrorKind::MissingParam, "Missing value for session cookie.") + })?, + None, + ) + .map_err(|e| { + Error::BadRequest(ErrorKind::InvalidParam, "Invalid value for session cookie.") + })?; + + let provider = services().sso.get(&provider).ok_or_else(|e| { + Error::BadRequest( + ErrorKind::InvalidParam, + "Unknown provider for session cookie.", + ) + })?; + + let IdpConfig { + client_id, + client_secret, + auth_method, + .. + } = provider.config.clone(); + + let credentials = match &auth_method { + "basic" => ClientCredentials::ClientSecretBasic { + client_id, + client_secret, + }, + "post" => ClientCredentials::ClientSecretPost { + client_id, + client_secret, + }, + _ => todo!(), + }; + let ( + AccessTokenResponse { + access_token, + refresh_token, + token_type, + expires_in, + scope, + .. + }, + Some(id_token), + ) = authorization_code::access_token_with_authorization_code( + services().sso.service(), + method, + provider.metadata.token_endpoint(), + code, + validation_data, + jwt_verification_data, + SystemTime::now().into(), + &mut StdRng::from_entropy(), + ) + .await + .map_err(|e| Error::bad_config("Failed to fetch access token."))? + else { + unreachable!("ID token should never be empty") + }; + + // let userinfo = provider.fetch_userinfo(&access_token, &id_token).await?; + + let mut userinfo = HashMap::default(); + if let Some(endpoint) = &provider.metadata.userinfo_endpoint { + let ref jwks = jose::fetch_jwks(services().sso.service(), provider.metadata.jwks_uri()) + .await + .map_err(|e| Error::bad_config("Failed to fetch signing keys for token endpoint."))?; + let jwt_verification_data = Some(JwtVerificationData { + jwks, + issuer: &provider.config.issuer, + client_id: credentials.client_id(), + signing_algorithm: &JsonWebSignatureAlg::Rs256, + }); + + userinfo = userinfo::fetch_userinfo( + services().sso.service(), + endpoint, + &access_token, + jwt_verification_data, + &id_token, + ) + .await + .map_err(|e| Error::bad_config("Failed to fetch claims for userinfo endpoint."))?; + }; + + let (_, mut claims) = id_token.into_parts(); + + let subject = claims.get("sub").ok_or_else(|| { + error!("Unique \"sub\" claim is missing from ID token: {claims:?}"); + + Error::bad_config("Unique \"sub\" claim is missing from ID token.") + })?; + + let subject = &subject + .as_str() + .map(str::to_owned) + .or_else(|| subject.as_number().map(Number::to_string)) + .expect("unique claim should be a string or number"); + + let redirect_uri = &validation_data.redirect_uri; + + if let Some(user_id) = services() + .sso + .user_from_claim(&validation_data.provider_id, subject)? + { + let login_token = LoginToken::new(validation_data.provider_id.to_owned(), user_id); + + let redirect_uri = redirect_with_login_token(redirect_uri.to_owned(), &login_token); + + return Ok(( + AppendHeaders(vec![( + header::SET_COOKIE, + utils::reset_cookie("sso-session").to_string(), + )]), + Redirect::temporary(redirect_uri.as_str()), + ) + .into_response()); + } + + match provider.config.registration { + Registration::Disabled => { + return Err(Error::BadRequest( + ErrorKind::forbidden(), + "Single Sign-On registration is disabled.", + )) + } + Registration::Automated => todo!(), + Registration::Interactive => {} + }; + + let Template { + username, + displayname, + avatar_url, + email, + } = &provider.config.template; + let registration_info = + RegistrationInfo::new(&claims, username, displayname, avatar_url, email); + + let signed = services() + .globals + .sign_macaroon(&RegistrationToken::new( + validation_data.provider_id.clone(), + subject.to_owned(), + redirect_uri.to_owned(), + registration_info, + )) + .expect("signing macaroons always works"); + + let cookie = utils::build_cookie( + "sso-registration", + &signed, + "/_conduit/client/sso/register", + REGISTRATION_EXPIRATION_SECS, + ); + + Ok(( + AppendHeaders(vec![ + (header::SET_COOKIE, cookie.to_string()), + ( + header::SET_COOKIE, + utils::reset_cookie("sso-session").to_string(), + ), + ]), + Redirect::temporary("/_conduit/client/sso/register"), + ) + .into_response()) +} + +/// # `GET /_conduit/client/sso/pick_idp` +pub async fn pick_idp(RawQuery(query): RawQuery) -> impl IntoResponse { + let providers: Vec<_> = services() + .globals + .config + .sso + .iter() + .map(|p| p.inner.to_owned()) + .collect(); + + let body = maud::html! { + header { + h1 { "Log in to " (services().globals.server_name()) } + p { "Choose an identity provider to continue" } + } + main { + ul .providers { + @for provider in providers { + li { + a href={ "/_matrix/client/v3/login/sso/redirect/" (provider.id) "?" (query.as_deref().unwrap_or_default()) } { + @if let Some(url) = provider.icon.as_deref().and_then(utils::mxc_to_http) { + img src=(url); + } + } + span { + (provider.name) + } + } + } + } + } + }; + + ( + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + maud::html! { + (templates::base("Pick Identity Provider", body)) + + (templates::footer()) + }, + ) +} + +/// # `GET /_conduit/client/sso/register` +/// +/// Serve a registration form with defaults based on the retrieved claims. +/// This endpoint is only available when interactive registration is enabled. +pub async fn get_sso_registration( + cookie: TypedHeader, +) -> Result { + let token = cookie.get("sso-registration").ok_or_else(|| { + Error::BadRequest( + ErrorKind::MissingParam, + "Missing registration token cookie.", + ) + })?; + + let registration_token: RegistrationToken = services() + .globals + .validate_macaroon(token, None) + .map_err(|_| { + Error::BadRequest( + ErrorKind::InvalidParam, + "Invalid registration token cookie.", + ) + })?; + + let provider = services() + .sso + .get(®istration_token.provider_id) + .map(|p| p.config.inner.to_owned())?; + let server_name = services().globals.server_name(); + + let RegistrationInfo { + username, + displayname, + avatar_url, + email, + } = registration_token.info; + + let additional_info = (&displayname, &avatar_url, &email) != (&None, &None, &None); + + fn detail(title: &str, body: maud::Markup) -> maud::Markup { + maud::html! { + label .detail for=(title) { + div .check-row { + span .name { (title) } " " + span .use { "use" } + input #(title) type="checkbox" name={(title)"-checkbox"} value=(true) checked; + } + (body) + } + } + } + + let body = maud::html! { + header { + h1 { "Complete your registration at " (server_name) } + p { "Confirm your details to finish creating your account." } + } + main { + form .form #form method="post" { + div .username-div #username-div { + label for="username-input" { "Username (required)" } + div .prefix { "@" } + input .username-input type="text" name="username" + value=(username) autofocus autocorrect="off" autocapitalize="none"; + div .postfix { ":" (server_name) } + } + output .username-output for="username-input" { } + + @if additional_info { + section .additional-info { + h2 { + @if let Some(icon) = provider.icon.as_deref().and_then(utils::mxc_to_http) { + img src=(icon.to_string()); + } + "Optional data from " (provider.name) + } + @if let Some(avatar_url) = avatar_url.as_ref() { + (detail("avatar", maud::html!{ + img .avatar src=(avatar_url); + })) + } + @if let Some(displayname) = displayname.as_ref() { + (detail("displayname", maud::html!{ + p .value { (displayname) }; + })) + } + @if let Some(email) = email.as_ref() { + (detail("email", maud::html!{ + p .value { (email) }; + })) + } + } + } + + input type="submit" value="Submit" .primary-button {} + } + } + }; + + Ok(( + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + maud::html! { + (templates::base("Register Account", body)) + + (templates::footer()) + }, + ) + .into_response()) +} + +/// # `POST /_conduit/client/sso/register` +/// +/// Submit the registration form. +pub async fn submit_sso_registration( + cookie: TypedHeader, + axum::extract::Form(registration_info): axum::extract::Form, +) -> Result { + let token = cookie.get("sso-registration").ok_or_else(|| { + Error::BadRequest( + ErrorKind::MissingParam, + "Missing registration token cookie.", + ) + })?; + + let registration_token: RegistrationToken = services() + .globals + .validate_macaroon(token, None) + .map_err(|_| { + Error::BadRequest( + ErrorKind::MissingParam, + "Invalid registration token cookie.", + ) + })?; + + let RegistrationInfo { + username, + mut displayname, + avatar_url, + email: _, + } = registration_info; + + let user_id = + UserId::parse_with_server_name(username.to_lowercase(), services().globals.server_name()) + .map_err(|_| Error::BadRequest(ErrorKind::InvalidUsername, "Invalid username."))?; + + if services().users.exists(&user_id)? { + return Err(Error::BadRequest( + ErrorKind::UserInUse, + "Desired UserId is already taken.", + )); + } + + if services().appservice.is_exclusive_user_id(&user_id).await { + return Err(Error::BadRequest( + ErrorKind::Exclusive, + "Desired UserId reserved by appservice.", + )); + } + + services().users.create(&user_id, None)?; + services().users.set_password_placeholder(&user_id)?; + + if let Some(avatar_url) = avatar_url { + let request = services().globals.default_client().get(avatar_url.as_ref()); + + let res = request.send().await.map_err(|_| { + Error::BadRequest(ErrorKind::UserInUse, "Desired UserId is already taken.") + })?; + + let filename = avatar_url.path_segments().and_then(Iterator::last); + + let (content_type, body): (Option, Vec) = ( + res.headers().typed_get(), + res.bytes().await.map(Into::into).map_err(|_| { + Error::BadRequest(ErrorKind::UserInUse, "Desired UserId is already taken.") + })?, + ); + + let mxc = format!( + "mxc://{}/{}", + services().globals.server_name(), + utils::random_string(crate::api::client_server::MXC_LENGTH) + ); + + services() + .media + .create( + mxc.clone(), + filename + .map(|filename| "inline; filename=".to_owned() + filename) + .as_deref(), + content_type.map(|header| header.to_string()).as_deref(), + &body, + ) + .await?; + + services() + .users + .set_avatar_url(&user_id, Some(OwnedMxcUri::from(mxc)))?; + }; + + if let (Some(displayname), true) = ( + displayname.as_mut(), + services().globals.config.enable_lightning_bolt, + ) { + displayname.push_str(" ⚡️"); + } + + services().users.set_displayname(&user_id, displayname)?; + + services().sso.save_claim( + ®istration_token.provider_id, + &user_id, + ®istration_token.unique_claim, + )?; + + services().account_data.update( + None, + &user_id, + GlobalAccountDataEventType::PushRules.to_string().into(), + &serde_json::to_value(ruma::events::push_rules::PushRulesEvent { + content: ruma::events::push_rules::PushRulesEventContent { + global: push::Ruleset::server_default(&user_id), + }, + }) + .expect("PushRulesEvent should always serialize"), + )?; + + let login_token = LoginToken::new(registration_token.provider_id, user_id); + let redirect_uri = redirect_with_login_token(registration_token.redirect_uri, &login_token); + + Ok(( + AppendHeaders([( + header::SET_COOKIE, + utils::reset_cookie("sso-registration").to_string(), + )]), + Redirect::temporary(redirect_uri.as_str()), + ) + .into_response()) +} + +fn redirect_with_login_token(mut redirect_uri: Url, login_token: &LoginToken) -> Url { + let signed = services() + .globals + .sign_macaroon(login_token) + .expect("signing macaroons should always works"); + + redirect_uri + .query_pairs_mut() + .append_pair("loginToken", &signed); + + redirect_uri +} diff --git a/src/service/sso/data.rs b/src/service/sso/data.rs new file mode 100644 index 00000000..75d45bf2 --- /dev/null +++ b/src/service/sso/data.rs @@ -0,0 +1,9 @@ +use ruma::{OwnedUserId, UserId}; + +use crate::Result; + +pub trait Data: Send + Sync { + fn save_subject(&self, provider: &str, user_id: &UserId, subject: &str) -> Result<()>; + + fn user_from_subject(&self, provider: &str, subject: &str) -> Result>; +} diff --git a/src/service/sso/mod.rs b/src/service/sso/mod.rs new file mode 100644 index 00000000..1206ed34 --- /dev/null +++ b/src/service/sso/mod.rs @@ -0,0 +1,299 @@ +mod data; +use std::{ + borrow::Borrow, + collections::{HashMap, HashSet}, + hash::{Hash, Hasher}, + str::FromStr, + sync::{Arc, RwLock}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use crate::{ + api::client_server::TOKEN_LENGTH, + config::{sso::ProviderConfig as Config, IdpConfig}, + utils, Error, Result, +}; +pub use data::Data; +use email_address::EmailAddress; +use futures_util::future::{self}; +use mas_oidc_client::{ + http_service::{hyper, HttpService}, + jose::jwk::PublicJsonWebKeySet, + requests::{ + authorization_code::{self, AuthorizationRequestData, AuthorizationValidationData}, + discovery, + jose::{self, JwtVerificationData}, + userinfo, + }, + types::{ + iana::jose::JsonWebSignatureAlg, oidc::VerifiedProviderMetadata, + requests::AccessTokenResponse, IdToken, + }, +}; +use rand::SeedableRng; +use ruma::{api::client::error::ErrorKind, MilliSecondsSinceUnixEpoch, OwnedUserId, UserId}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::{oneshot, OnceCell}; +use tracing::error; +use url::Url; + +use crate::services; + +pub use data::Data; + +pub const SSO_AUTH_EXPIRATION_SECS: u64 = 60 * 60; +pub const SSO_TOKEN_EXPIRATION_SECS: u64 = 60 * 2; +pub const SSO_SESSION_COOKIE: &str = "sso-auth"; + +pub struct Service { + db: &'static dyn Data, + service: HttpService, + providers: OnceCell>, +} + +impl Service { + pub fn build(db: &'static dyn Data) -> Result> { + Ok(Arc::new(Self { + db, + service: HttpService::new(hyper::hyper_service()), + providers: OnceCell::new(), + })) + } + + pub fn service(&self) -> &HttpService { + &self.service + } + + pub async fn start_handler(&self) -> Result<()> { + let providers = services().globals.config.idps.iter(); + + self.providers + .get_or_try_init(|| { + future::try_join_all(providers.map(Provider::fetch_metadata)) + .await + .map(Vec::into_iter) + .map(HashSet::from_iter) + }) + .await?; + + Ok(()) + } + + pub fn get(&self, provider: &str) -> Option<&Provider> { + let providers = self.providers.get().expect(""); + + providers.get(provider) + } + + pub fn user_from_subject(&self, provider: &str, subject: &str) -> Result> { + self.db.user_from_subject(provider, subject) + } +} + +#[derive(Clone, Debug)] +pub struct Provider { + pub config: &'static IdpConfig, + pub metadata: VerifiedProviderMetadata, +} + +impl Provider { + pub async fn fetch_metadata(config: &'static IdpConfig) -> Result { + discovery::discover(services().sso.service(), &config.issuer) + .await + .map(|metadata| Provider { config, metadata }) + .map_err(|e| { + error!( + "Failed to fetch identity provider metadata ({}): {}", + &config.inner.id, e + ); + + Error::bad_config("Failed to fetch identity provider metadata.") + }) + } + + async fn fetch_signing_keys(&self) -> Result { + jose::fetch_jwks(&services().sso.service, self.metadata.jwks_uri()) + .await + .map_err(|e| { + error!("Failed to fetch signing keys for token endpoint: {}", e); + + Error::bad_config("Failed to fetch signing keys for token endpoint.") + }) + } + + pub async fn fetch_access_token( + &self, + auth_code: String, + validation_data: AuthorizationValidationData, + ) -> Result<(AccessTokenResponse, Option>)> { + } + + pub async fn fetch_userinfo( + &self, + access_token: &str, + id_token: &IdToken<'_>, + ) -> Result>> { + } +} + +impl Borrow for Provider { + fn borrow(&self) -> &str { + self.config.borrow() + } +} + +impl PartialEq for Provider { + fn eq(&self, other: &Self) -> bool { + self.config == other.config + } +} + +impl Eq for Provider {} + +impl Hash for Provider { + fn hash(&self, hasher: &mut H) { + self.config.hash(hasher) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RegistrationToken { + pub info: RegistrationInfo, + pub provider_id: String, + pub unique_claim: String, + pub redirect_uri: Url, + pub expires_at: MilliSecondsSinceUnixEpoch, +} + +impl RegistrationToken { + pub fn new( + provider_id: String, + unique_claim: String, + redirect_uri: Url, + info: RegistrationInfo, + ) -> Self { + let expires_at = MilliSecondsSinceUnixEpoch::from_system_time( + UNIX_EPOCH + .checked_add(Duration::from_secs(REGISTRATION_EXPIRATION_SECS)) + .expect("SystemTime should not overflow"), + ) + .expect("MilliSecondsSinceUnixEpoch is not too large"); + + Self { + info, + provider_id, + unique_claim, + redirect_uri, + expires_at, + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct RegistrationInfo { + pub username: String, + pub displayname: Option, + pub avatar_url: Option, + pub email: Option, +} + +impl RegistrationInfo { + pub fn new( + claims: &HashMap, + username: &str, + displayname: &str, + avatar_url: &str, + email: &str, + ) -> Self { + Self { + username: claims + .get(username) + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned) + .unwrap_or_default(), + displayname: claims + .get(displayname) + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned), + avatar_url: claims + .get(avatar_url) + .and_then(|v| v.as_str()) + .map(Url::parse) + .and_then(Result::ok), + email: claims + .get(email) + .and_then(|v| v.as_str()) + .map(EmailAddress::from_str) + .and_then(Result::ok), + } + } +} + +#[derive(Clone, Deserialize, Serialize)] +pub struct LoginToken { + pub inner: String, + pub provider_id: String, + pub user_id: OwnedUserId, + + #[serde(rename = "exp")] + expires_at: u64, +} + +impl LoginToken { + pub fn new(provider_id: String, user_id: OwnedUserId) -> Self { + let expires_at = SystemTime::now() + .checked_add(Duration::from_secs(LOGIN_TOKEN_EXPIRATION_SECS)) + .expect("SystemTime should not overflow") + .duration_since(UNIX_EPOCH) + .expect("SystemTime went backwards") + .as_secs(); + + Self { + inner: utils::random_string(TOKEN_LENGTH), + provider_id, + user_id, + expires_at, + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ValidationData { + pub provider: String, + #[serde(flatten, with = "AuthorizationValidationDataDef")] + pub inner: AuthorizationValidationData, +} + +impl ValidationData { + pub fn new(provider: String, inner: AuthorizationValidationData) -> Self { + Self { provider, inner } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(remote = "AuthorizationValidationData")] +pub struct AuthorizationValidationDataDef { + pub state: String, + pub nonce: String, + pub redirect_uri: Url, + pub code_challenge_verifier: Option, +} + +impl From for AuthorizationValidationDataDef { + fn from( + AuthorizationValidationData { + state, + nonce, + redirect_uri, + code_challenge_verifier, + }: AuthorizationValidationData, + ) -> Self { + Self { + state, + nonce, + redirect_uri, + code_challenge_verifier, + } + } +} diff --git a/src/service/sso/templates.rs b/src/service/sso/templates.rs new file mode 100644 index 00000000..01512cc4 --- /dev/null +++ b/src/service/sso/templates.rs @@ -0,0 +1,34 @@ +pub fn base(title: &str, body: maud::Markup) -> maud::Markup { + maud::html! { + (maud::DOCTYPE) + html lang="en" { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1.0"; + link rel="icon" type="image/png" sizes="32x32" href="https://conduit.rs/conduit.svg"; + style { (FONT_FACE) } + title { (title) } + } + body { (body) } + } + } +} + +pub fn footer() -> maud::Markup { + let info = "An open network for secure, decentralized communication."; + + maud::html! { + footer { p { (info) } } + } +} + +const FONT_FACE: &str = r#" + @font-face { + font-family: 'Source Sans 3 Variable'; + font-style: normal; + font-display: swap; + font-weight: 200 900; + src: url(https://cdn.jsdelivr.net/fontsource/fonts/source-sans-3:vf@latest/latin-wght-normal.woff2) format('woff2-variations'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; + } +"#;