1
0
Fork 0
mirror of https://gitlab.com/famedly/conduit.git synced 2025-06-27 16:35:59 +00:00

initial commit

This commit is contained in:
avdb13 2024-07-11 21:55:52 +02:00
parent 895b66fa50
commit 10ce7ea3a9
4 changed files with 990 additions and 0 deletions

View file

@ -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<sso_login::v3::Request>,
) -> Result<sso_login::v3::Response> {
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<sso_login_with_provider::v3::Request>,
) -> Result<sso_login_with_provider::v3::Response> {
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::<Url>()
.map_err(|_| Error::BadRequest(ErrorKind::InvalidParam, "Invalid redirect_url."))?;
let mut callback = services()
.globals
.well_known_client()
.parse::<Url>()
.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<axum::response::Response> {
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::<AuthorizationResponse>(query).map_err(|_| {
serde_html_form::from_str::<ClientError>(query).unwrap_or_else(|_| {
error!("Failed to deserialize authorization callback: {}", callback);
Error::BadRequest(
ErrorKind::Unknown,
"Failed to deserialize authorization callback.",
)
})
})?;
let cookie = req
.extract::<Option<TypedHeader<headers::Cookie>>>()
.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<headers::Cookie>,
) -> Result<axum::response::Response> {
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(&registration_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<headers::Cookie>,
axum::extract::Form(registration_info): axum::extract::Form<RegistrationInfo>,
) -> Result<axum::response::Response> {
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<headers::ContentType>, Vec<u8>) = (
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(
&registration_token.provider_id,
&user_id,
&registration_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
}

9
src/service/sso/data.rs Normal file
View file

@ -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<Option<OwnedUserId>>;
}

299
src/service/sso/mod.rs Normal file
View file

@ -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<HashSet<Provider>>,
}
impl Service {
pub fn build(db: &'static dyn Data) -> Result<Arc<Self>> {
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<Option<OwnedUserId>> {
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<Self> {
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<PublicJsonWebKeySet> {
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<IdToken<'_>>)> {
}
pub async fn fetch_userinfo(
&self,
access_token: &str,
id_token: &IdToken<'_>,
) -> Result<Option<HashMap<String, Value>>> {
}
}
impl Borrow<str> 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<H: Hasher>(&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<String>,
pub avatar_url: Option<Url>,
pub email: Option<EmailAddress>,
}
impl RegistrationInfo {
pub fn new(
claims: &HashMap<String, Value>,
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<String>,
}
impl From<AuthorizationValidationData> for AuthorizationValidationDataDef {
fn from(
AuthorizationValidationData {
state,
nonce,
redirect_uri,
code_challenge_verifier,
}: AuthorizationValidationData,
) -> Self {
Self {
state,
nonce,
redirect_uri,
code_challenge_verifier,
}
}
}

View file

@ -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;
}
"#;