mirror of
https://gitlab.com/famedly/conduit.git
synced 2025-06-27 16:35:59 +00:00
initial commit
This commit is contained in:
parent
895b66fa50
commit
10ce7ea3a9
4 changed files with 990 additions and 0 deletions
648
src/api/client_server/sso.rs
Normal file
648
src/api/client_server/sso.rs
Normal 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(®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<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(
|
||||
®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
|
||||
}
|
9
src/service/sso/data.rs
Normal file
9
src/service/sso/data.rs
Normal 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
299
src/service/sso/mod.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
34
src/service/sso/templates.rs
Normal file
34
src/service/sso/templates.rs
Normal 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;
|
||||
}
|
||||
"#;
|
Loading…
Add table
Add a link
Reference in a new issue