mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-08-29 03:18:23 +00:00
allow importing existing users when the localpart matches in upstream OAuth 2.0 logins
This commit is contained in:
@@ -37,6 +37,19 @@ fn map_import_action(
|
||||
}
|
||||
}
|
||||
|
||||
fn map_import_on_conflict(
|
||||
config: mas_config::UpstreamOAuth2OnConflict,
|
||||
) -> mas_data_model::UpstreamOAuthProviderOnConflict {
|
||||
match config {
|
||||
mas_config::UpstreamOAuth2OnConflict::Add => {
|
||||
mas_data_model::UpstreamOAuthProviderOnConflict::Add
|
||||
}
|
||||
mas_config::UpstreamOAuth2OnConflict::Fail => {
|
||||
mas_data_model::UpstreamOAuthProviderOnConflict::Fail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_claims_imports(
|
||||
config: &mas_config::UpstreamOAuth2ClaimsImports,
|
||||
) -> mas_data_model::UpstreamOAuthProviderClaimsImports {
|
||||
@@ -47,14 +60,17 @@ fn map_claims_imports(
|
||||
localpart: mas_data_model::UpstreamOAuthProviderImportPreference {
|
||||
action: map_import_action(config.localpart.action),
|
||||
template: config.localpart.template.clone(),
|
||||
on_conflict: map_import_on_conflict(config.localpart.on_conflict),
|
||||
},
|
||||
displayname: mas_data_model::UpstreamOAuthProviderImportPreference {
|
||||
action: map_import_action(config.displayname.action),
|
||||
template: config.displayname.template.clone(),
|
||||
on_conflict: mas_data_model::UpstreamOAuthProviderOnConflict::default(),
|
||||
},
|
||||
email: mas_data_model::UpstreamOAuthProviderImportPreference {
|
||||
action: map_import_action(config.email.action),
|
||||
template: config.email.template.clone(),
|
||||
on_conflict: mas_data_model::UpstreamOAuthProviderOnConflict::default(),
|
||||
},
|
||||
account_name: mas_data_model::UpstreamOAuthProviderSubjectPreference {
|
||||
template: config.account_name.template.clone(),
|
||||
|
||||
@@ -52,7 +52,7 @@ pub use self::{
|
||||
upstream_oauth2::{
|
||||
ClaimsImports as UpstreamOAuth2ClaimsImports, DiscoveryMode as UpstreamOAuth2DiscoveryMode,
|
||||
EmailImportPreference as UpstreamOAuth2EmailImportPreference,
|
||||
ImportAction as UpstreamOAuth2ImportAction,
|
||||
ImportAction as UpstreamOAuth2ImportAction, OnConflict as UpstreamOAuth2OnConflict,
|
||||
OnBackchannelLogout as UpstreamOAuth2OnBackchannelLogout,
|
||||
PkceMethod as UpstreamOAuth2PkceMethod, Provider as UpstreamOAuth2Provider,
|
||||
ResponseMode as UpstreamOAuth2ResponseMode,
|
||||
|
||||
@@ -117,6 +117,20 @@ impl ConfigurationSection for UpstreamOAuth2Config {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !provider.claims_imports.localpart.on_conflict.is_default()
|
||||
&& !matches!(
|
||||
provider.claims_imports.localpart.action,
|
||||
ImportAction::Force | ImportAction::Require
|
||||
)
|
||||
{
|
||||
return annotate(figment::Error::custom(
|
||||
"The field `action` must be either `force` or `require` when `on_conflict` is set to `add`",
|
||||
));
|
||||
}
|
||||
|
||||
//TODO : check that claims imports use on_conflict where it is not
|
||||
// supported?
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -190,6 +204,26 @@ impl ImportAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// How to handle an existing localpart claim
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OnConflict {
|
||||
/// Fails the sso login on conflict
|
||||
#[default]
|
||||
Fail,
|
||||
|
||||
/// Adds the oauth identity link, regardless of whether there is an existing
|
||||
/// link or not
|
||||
Add,
|
||||
}
|
||||
|
||||
impl OnConflict {
|
||||
#[allow(clippy::trivially_copy_pass_by_ref)]
|
||||
const fn is_default(&self) -> bool {
|
||||
matches!(self, OnConflict::Fail)
|
||||
}
|
||||
}
|
||||
|
||||
/// What should be done for the subject attribute
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
|
||||
pub struct SubjectImportPreference {
|
||||
@@ -218,6 +252,10 @@ pub struct LocalpartImportPreference {
|
||||
/// If not provided, the default template is `{{ user.preferred_username }}`
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub template: Option<String>,
|
||||
|
||||
/// How to handle conflicts on the claim, default value is `Fail`
|
||||
#[serde(default, skip_serializing_if = "OnConflict::is_default")]
|
||||
pub on_conflict: OnConflict,
|
||||
}
|
||||
|
||||
impl LocalpartImportPreference {
|
||||
|
||||
@@ -42,7 +42,7 @@ pub use self::{
|
||||
UpstreamOAuthAuthorizationSession, UpstreamOAuthAuthorizationSessionState,
|
||||
UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports,
|
||||
UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderImportAction,
|
||||
UpstreamOAuthProviderImportPreference, UpstreamOAuthProviderOnBackchannelLogout,
|
||||
UpstreamOAuthProviderImportPreference, UpstreamOAuthProviderOnConflict, UpstreamOAuthProviderOnBackchannelLogout,
|
||||
UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderResponseMode,
|
||||
UpstreamOAuthProviderSubjectPreference, UpstreamOAuthProviderTokenAuthMethod,
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ pub use self::{
|
||||
ImportAction as UpstreamOAuthProviderImportAction,
|
||||
ImportPreference as UpstreamOAuthProviderImportPreference,
|
||||
OnBackchannelLogout as UpstreamOAuthProviderOnBackchannelLogout,
|
||||
PkceMode as UpstreamOAuthProviderPkceMode,
|
||||
OnConflict as UpstreamOAuthProviderOnConflict, PkceMode as UpstreamOAuthProviderPkceMode,
|
||||
ResponseMode as UpstreamOAuthProviderResponseMode,
|
||||
SubjectPreference as UpstreamOAuthProviderSubjectPreference,
|
||||
TokenAuthMethod as UpstreamOAuthProviderTokenAuthMethod, UpstreamOAuthProvider,
|
||||
|
||||
@@ -339,6 +339,9 @@ pub struct ImportPreference {
|
||||
|
||||
#[serde(default)]
|
||||
pub template: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub on_conflict: OnConflict,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for ImportPreference {
|
||||
@@ -391,3 +394,22 @@ impl ImportAction {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OnConflict {
|
||||
/// Fails the upstream OAuth 2.0 login
|
||||
#[default]
|
||||
Fail,
|
||||
|
||||
/// Adds the upstream account link, regardless of whether there is an
|
||||
/// existing link or not
|
||||
Add,
|
||||
}
|
||||
|
||||
impl OnConflict {
|
||||
#[must_use]
|
||||
pub fn is_add(&self) -> bool {
|
||||
matches!(self, Self::Add)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use mas_axum_utils::{
|
||||
csrf::{CsrfExt, ProtectedForm},
|
||||
record_error,
|
||||
};
|
||||
use mas_data_model::UpstreamOAuthProviderOnConflict;
|
||||
use mas_jose::jwt::Jwt;
|
||||
use mas_matrix::HomeserverConnection;
|
||||
use mas_policy::Policy;
|
||||
@@ -37,7 +38,6 @@ use minijinja::Environment;
|
||||
use opentelemetry::{Key, KeyValue, metrics::Counter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tracing::warn;
|
||||
use ulid::Ulid;
|
||||
|
||||
use super::{
|
||||
@@ -473,19 +473,49 @@ pub(crate) async fn get(
|
||||
.await
|
||||
.map_err(RouteError::HomeserverConnection)?;
|
||||
|
||||
if maybe_existing_user.is_some() || !is_available {
|
||||
if let Some(existing_user) = maybe_existing_user {
|
||||
// The mapper returned a username which already exists, but isn't
|
||||
// linked to this upstream user.
|
||||
warn!(username = %localpart, user_id = %existing_user.id, "Localpart template returned an existing username");
|
||||
}
|
||||
if let Some(existing_user) = maybe_existing_user {
|
||||
// The mapper returned a username which already exists, but isn't
|
||||
// linked to this upstream user.
|
||||
let on_conflict = provider.claims_imports.localpart.on_conflict;
|
||||
|
||||
match on_conflict {
|
||||
UpstreamOAuthProviderOnConflict::Fail => {
|
||||
// TODO: translate
|
||||
let ctx = ErrorContext::new()
|
||||
.with_code("User exists")
|
||||
.with_description(format!(
|
||||
r"Upstream account provider returned {localpart:?} as username,
|
||||
which is not linked to that upstream account. Your homeserver does not allow
|
||||
linking an upstream account to an existing account"
|
||||
))
|
||||
.with_language(&locale);
|
||||
|
||||
return Ok((
|
||||
cookie_jar,
|
||||
Html(templates.render_error(&ctx)?).into_response(),
|
||||
));
|
||||
}
|
||||
UpstreamOAuthProviderOnConflict::Add => {
|
||||
// new oauth link is allowed
|
||||
let ctx = UpstreamExistingLinkContext::new(existing_user)
|
||||
.with_csrf(csrf_token.form_value())
|
||||
.with_language(locale);
|
||||
|
||||
return Ok((
|
||||
cookie_jar,
|
||||
Html(templates.render_upstream_oauth2_login_link(&ctx)?)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !is_available {
|
||||
// TODO: translate
|
||||
let ctx = ErrorContext::new()
|
||||
.with_code("User exists")
|
||||
.with_code("Localpart not available")
|
||||
.with_description(format!(
|
||||
r"Upstream account provider returned {localpart:?} as username,
|
||||
which is not linked to that upstream account"
|
||||
r"Localpart {localpart:?} is not available on this homeserver"
|
||||
))
|
||||
.with_language(&locale);
|
||||
|
||||
@@ -618,6 +648,79 @@ pub(crate) async fn post(
|
||||
session
|
||||
}
|
||||
|
||||
(None, None, FormData::Link) => {
|
||||
// User already exists, but it is not linked, neither logged in
|
||||
// Proceed by associating the link and log in the user
|
||||
// Upstream_session is used to re-render the username as it is the only source
|
||||
// of truth
|
||||
|
||||
let id_token = upstream_session.id_token().map(Jwt::try_from).transpose()?;
|
||||
|
||||
let provider = repo
|
||||
.upstream_oauth_provider()
|
||||
.lookup(link.provider_id)
|
||||
.await?
|
||||
.ok_or(RouteError::ProviderNotFound(link.provider_id))?;
|
||||
|
||||
let env = environment();
|
||||
|
||||
let mut context = AttributeMappingContext::new();
|
||||
if let Some(id_token) = id_token {
|
||||
let (_, payload) = id_token.into_parts();
|
||||
context = context.with_id_token_claims(payload);
|
||||
}
|
||||
if let Some(extra_callback_parameters) = upstream_session.extra_callback_parameters() {
|
||||
context = context.with_extra_callback_parameters(extra_callback_parameters.clone());
|
||||
}
|
||||
if let Some(userinfo) = upstream_session.userinfo() {
|
||||
context = context.with_userinfo_claims(userinfo.clone());
|
||||
}
|
||||
let context = context.build();
|
||||
|
||||
// Check both if is it `force` or `require`
|
||||
if !provider.claims_imports.localpart.is_forced() {
|
||||
//Claims import for `localpart` should be `require` or `force` at this stage
|
||||
return Err(RouteError::InvalidFormAction);
|
||||
}
|
||||
|
||||
let template = provider
|
||||
.claims_imports
|
||||
.localpart
|
||||
.template
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_LOCALPART_TEMPLATE);
|
||||
|
||||
let localpart = render_attribute_template(&env, template, &context, true)?;
|
||||
|
||||
let maybe_user = repo.user().find_by_username(&localpart.unwrap()).await?;
|
||||
|
||||
if maybe_user.is_none() {
|
||||
//user can not be None at this stage
|
||||
return Err(RouteError::InvalidFormAction);
|
||||
}
|
||||
|
||||
let user = maybe_user.unwrap();
|
||||
|
||||
let on_conflict = provider.claims_imports.localpart.on_conflict;
|
||||
|
||||
match on_conflict {
|
||||
UpstreamOAuthProviderOnConflict::Fail => {
|
||||
//OnConflict can not be equals to Fail at this stage
|
||||
return Err(RouteError::InvalidFormAction);
|
||||
}
|
||||
UpstreamOAuthProviderOnConflict::Add => {
|
||||
//add link to the user
|
||||
repo.upstream_oauth_link()
|
||||
.associate_to_user(&link, &user)
|
||||
.await?;
|
||||
|
||||
repo.browser_session()
|
||||
.add(&mut rng, &clock, &user, user_agent)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
None,
|
||||
None,
|
||||
@@ -900,16 +1003,20 @@ pub(crate) async fn post(
|
||||
mod tests {
|
||||
use hyper::{Request, StatusCode, header::CONTENT_TYPE};
|
||||
use mas_data_model::{
|
||||
UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderImportPreference,
|
||||
UpstreamOAuthProviderTokenAuthMethod,
|
||||
UpstreamOAuthAuthorizationSession, UpstreamOAuthLink, UpstreamOAuthProviderClaimsImports,
|
||||
UpstreamOAuthProviderImportPreference, UpstreamOAuthProviderTokenAuthMethod, User,
|
||||
};
|
||||
use mas_iana::jose::JsonWebSignatureAlg;
|
||||
use mas_jose::jwt::{JsonWebSignatureHeader, Jwt};
|
||||
use mas_keystore::Keystore;
|
||||
use mas_router::Route;
|
||||
use mas_storage::{
|
||||
Pagination, upstream_oauth2::UpstreamOAuthProviderParams, user::UserEmailFilter,
|
||||
Pagination, Repository, RepositoryError, upstream_oauth2::UpstreamOAuthProviderParams,
|
||||
user::UserEmailFilter,
|
||||
};
|
||||
use oauth2_types::scope::{OPENID, Scope};
|
||||
use rand_chacha::ChaChaRng;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use super::UpstreamSessionsCookie;
|
||||
@@ -926,10 +1033,12 @@ mod tests {
|
||||
localpart: UpstreamOAuthProviderImportPreference {
|
||||
action: mas_data_model::UpstreamOAuthProviderImportAction::Force,
|
||||
template: None,
|
||||
on_conflict: mas_data_model::UpstreamOAuthProviderOnConflict::default(),
|
||||
},
|
||||
email: UpstreamOAuthProviderImportPreference {
|
||||
action: mas_data_model::UpstreamOAuthProviderImportAction::Force,
|
||||
template: None,
|
||||
on_conflict: mas_data_model::UpstreamOAuthProviderOnConflict::default(),
|
||||
},
|
||||
..UpstreamOAuthProviderClaimsImports::default()
|
||||
};
|
||||
@@ -953,8 +1062,7 @@ mod tests {
|
||||
.signing_key_for_alg(&JsonWebSignatureAlg::Rs256)
|
||||
.unwrap();
|
||||
let header = JsonWebSignatureHeader::new(JsonWebSignatureAlg::Rs256);
|
||||
let id_token =
|
||||
Jwt::sign_with_rng(&mut rng, header, id_token_claims.clone(), &signer).unwrap();
|
||||
let id_token = Jwt::sign_with_rng(&mut rng, header, id_token_claims.clone(), &signer).unwrap();
|
||||
|
||||
// Provision a provider and a link
|
||||
let mut repo = state.repository().await.unwrap();
|
||||
@@ -1099,4 +1207,351 @@ mod tests {
|
||||
|
||||
assert_eq!(email.email, "john@example.com");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_link_existing_account(pool: PgPool) {
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
let timestamp = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
//suffix timestamp to generate unique test data
|
||||
let existing_username = format!("{}{}", "john", timestamp);
|
||||
let existing_email = format!("{}@{}", existing_username, "example.com");
|
||||
|
||||
//existing username matches oidc username
|
||||
let oidc_username = existing_username.clone();
|
||||
|
||||
//oidc email is different from existing email
|
||||
let oidc_email: String = format!("{}{}@{}", "any_email", timestamp, "example.com");
|
||||
|
||||
//generate unique subject
|
||||
let subject = format!("{}+{}", "subject", timestamp);
|
||||
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
let mut rng = state.rng();
|
||||
let cookies = CookieHelper::new();
|
||||
|
||||
let claims_imports = UpstreamOAuthProviderClaimsImports {
|
||||
localpart: UpstreamOAuthProviderImportPreference {
|
||||
action: mas_data_model::UpstreamOAuthProviderImportAction::Require,
|
||||
template: None,
|
||||
on_conflict: mas_data_model::UpstreamOAuthProviderOnConflict::Add,
|
||||
},
|
||||
email: UpstreamOAuthProviderImportPreference {
|
||||
action: mas_data_model::UpstreamOAuthProviderImportAction::Require,
|
||||
template: None,
|
||||
on_conflict: mas_data_model::UpstreamOAuthProviderOnConflict::default(),
|
||||
},
|
||||
..UpstreamOAuthProviderClaimsImports::default()
|
||||
};
|
||||
|
||||
let id_token_claims = serde_json::json!({
|
||||
"preferred_username": oidc_username,
|
||||
"email": oidc_email,
|
||||
"email_verified": true,
|
||||
});
|
||||
|
||||
let id_token = sign_token(&mut rng, &state.key_store, id_token_claims.clone()).unwrap();
|
||||
|
||||
// Provision a provider and a link
|
||||
let mut repo = state.repository().await.unwrap();
|
||||
let provider = repo
|
||||
.upstream_oauth_provider()
|
||||
.add(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
UpstreamOAuthProviderParams {
|
||||
issuer: Some("https://example.com/".to_owned()),
|
||||
human_name: Some("Example Ltd.".to_owned()),
|
||||
brand_name: None,
|
||||
scope: Scope::from_iter([OPENID]),
|
||||
token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::None,
|
||||
token_endpoint_signing_alg: None,
|
||||
id_token_signed_response_alg: JsonWebSignatureAlg::Rs256,
|
||||
client_id: "client".to_owned(),
|
||||
encrypted_client_secret: None,
|
||||
claims_imports,
|
||||
authorization_endpoint_override: None,
|
||||
token_endpoint_override: None,
|
||||
userinfo_endpoint_override: None,
|
||||
fetch_userinfo: false,
|
||||
userinfo_signed_response_alg: None,
|
||||
jwks_uri_override: None,
|
||||
discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc,
|
||||
pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto,
|
||||
response_mode: None,
|
||||
additional_authorization_parameters: Vec::new(),
|
||||
forward_login_hint: false,
|
||||
on_backchannel_logout:
|
||||
mas_data_model::UpstreamOAuthProviderOnBackchannelLogout::DoNothing,
|
||||
ui_order: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
//provision upstream authorization session to setup cookies
|
||||
let (link, session) = add_linked_upstream_session(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
&mut repo,
|
||||
&provider,
|
||||
&subject,
|
||||
&id_token.into_string(),
|
||||
id_token_claims
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let cookie_jar = state.cookie_jar();
|
||||
let upstream_sessions = UpstreamSessionsCookie::default()
|
||||
.add(session.id, provider.id, "state".to_owned(), None)
|
||||
.add_link_to_session(session.id, link.id)
|
||||
.unwrap();
|
||||
let cookie_jar = upstream_sessions.save(cookie_jar, &state.clock);
|
||||
cookies.import(cookie_jar);
|
||||
|
||||
let user = create_user(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
&mut repo,
|
||||
existing_username.clone(),
|
||||
existing_email.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
repo.save().await.unwrap();
|
||||
|
||||
let request = Request::get(&*mas_router::UpstreamOAuth2Link::new(link.id).path()).empty();
|
||||
let request = cookies.with_cookies(request);
|
||||
let response = state.request(request).await;
|
||||
cookies.save_cookies(&response);
|
||||
response.assert_status(StatusCode::OK);
|
||||
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
|
||||
|
||||
// Extract the CSRF token from the response body
|
||||
let csrf_token = response
|
||||
.body()
|
||||
.split("name=\"csrf\" value=\"")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split('\"')
|
||||
.next()
|
||||
.unwrap();
|
||||
|
||||
let request = Request::post(&*mas_router::UpstreamOAuth2Link::new(link.id).path()).form(
|
||||
serde_json::json!({
|
||||
"csrf": csrf_token,
|
||||
"action": "link"
|
||||
}),
|
||||
);
|
||||
let request = cookies.with_cookies(request);
|
||||
let response = state.request(request).await;
|
||||
cookies.save_cookies(&response);
|
||||
response.assert_status(StatusCode::SEE_OTHER);
|
||||
|
||||
// Check that the existing user has the oidc link
|
||||
let mut repo = state.repository().await.unwrap();
|
||||
|
||||
let link = repo
|
||||
.upstream_oauth_link()
|
||||
.find_by_subject(&provider, &subject)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("link exists");
|
||||
|
||||
assert_eq!(link.user_id, Some(user.id));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_link_existing_account_when_not_allowed_by_default(pool: PgPool) {
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
let timestamp = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
//suffix timestamp to generate unique test data
|
||||
let existing_username = format!("{}{}", "john", timestamp);
|
||||
let existing_email = format!("{}@{}", existing_username, "example.com");
|
||||
|
||||
//existing username matches oidc username
|
||||
let oidc_username = existing_username.clone();
|
||||
|
||||
//oidc email is different from existing email
|
||||
let oidc_email: String = format!("{}{}@{}", "any_email", timestamp, "example.com");
|
||||
|
||||
let subject = format!("{}+{}", "subject", timestamp);
|
||||
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
let mut rng = state.rng();
|
||||
let cookies = CookieHelper::new();
|
||||
|
||||
let claims_imports = UpstreamOAuthProviderClaimsImports {
|
||||
localpart: UpstreamOAuthProviderImportPreference {
|
||||
action: mas_data_model::UpstreamOAuthProviderImportAction::Require,
|
||||
template: None,
|
||||
on_conflict: mas_data_model::UpstreamOAuthProviderOnConflict::default(),
|
||||
},
|
||||
email: UpstreamOAuthProviderImportPreference {
|
||||
action: mas_data_model::UpstreamOAuthProviderImportAction::Require,
|
||||
template: None,
|
||||
on_conflict: mas_data_model::UpstreamOAuthProviderOnConflict::default(),
|
||||
},
|
||||
..UpstreamOAuthProviderClaimsImports::default()
|
||||
};
|
||||
|
||||
let id_token_claims = serde_json::json!({
|
||||
"preferred_username": oidc_username,
|
||||
"email": oidc_email,
|
||||
"email_verified": true,
|
||||
});
|
||||
|
||||
let id_token = sign_token(&mut rng, &state.key_store, id_token_claims.clone()).unwrap();
|
||||
|
||||
// Provision a provider and a link
|
||||
let mut repo = state.repository().await.unwrap();
|
||||
let provider = repo
|
||||
.upstream_oauth_provider()
|
||||
.add(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
UpstreamOAuthProviderParams {
|
||||
issuer: Some("https://example.com/".to_owned()),
|
||||
human_name: Some("Example Ltd.".to_owned()),
|
||||
brand_name: None,
|
||||
scope: Scope::from_iter([OPENID]),
|
||||
token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::None,
|
||||
token_endpoint_signing_alg: None,
|
||||
id_token_signed_response_alg: JsonWebSignatureAlg::Rs256,
|
||||
client_id: "client".to_owned(),
|
||||
encrypted_client_secret: None,
|
||||
claims_imports,
|
||||
authorization_endpoint_override: None,
|
||||
token_endpoint_override: None,
|
||||
userinfo_endpoint_override: None,
|
||||
fetch_userinfo: false,
|
||||
userinfo_signed_response_alg: None,
|
||||
jwks_uri_override: None,
|
||||
discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc,
|
||||
pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto,
|
||||
response_mode: None,
|
||||
additional_authorization_parameters: Vec::new(),
|
||||
forward_login_hint: false,
|
||||
on_backchannel_logout:
|
||||
mas_data_model::UpstreamOAuthProviderOnBackchannelLogout::DoNothing,
|
||||
ui_order: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (link, session) = add_linked_upstream_session(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
&mut repo,
|
||||
&provider,
|
||||
&subject,
|
||||
&id_token.into_string(),
|
||||
id_token_claims
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _user = create_user(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
&mut repo,
|
||||
existing_username.clone(),
|
||||
existing_email.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
repo.save().await.unwrap();
|
||||
|
||||
let cookie_jar = state.cookie_jar();
|
||||
let upstream_sessions = UpstreamSessionsCookie::default()
|
||||
.add(session.id, provider.id, "state".to_owned(), None)
|
||||
.add_link_to_session(session.id, link.id)
|
||||
.unwrap();
|
||||
let cookie_jar = upstream_sessions.save(cookie_jar, &state.clock);
|
||||
cookies.import(cookie_jar);
|
||||
|
||||
let request = Request::get(&*mas_router::UpstreamOAuth2Link::new(link.id).path()).empty();
|
||||
let request = cookies.with_cookies(request);
|
||||
let response = state.request(request).await;
|
||||
cookies.save_cookies(&response);
|
||||
response.assert_status(StatusCode::OK);
|
||||
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
|
||||
|
||||
assert!(response.body().contains("Unexpected error"));
|
||||
}
|
||||
|
||||
fn sign_token(
|
||||
rng: &mut ChaChaRng,
|
||||
keystore: &Keystore,
|
||||
payload: Value,
|
||||
) -> Result<Jwt<'static, Value>, mas_jose::jwt::JwtSignatureError> {
|
||||
let key = keystore
|
||||
.signing_key_for_algorithm(&JsonWebSignatureAlg::Rs256)
|
||||
.unwrap();
|
||||
|
||||
let signer = key
|
||||
.params()
|
||||
.signing_key_for_alg(&JsonWebSignatureAlg::Rs256)
|
||||
.unwrap();
|
||||
|
||||
let header = JsonWebSignatureHeader::new(JsonWebSignatureAlg::Rs256);
|
||||
|
||||
Jwt::sign_with_rng(rng, header, payload, &signer)
|
||||
}
|
||||
|
||||
async fn create_user(
|
||||
rng: &mut ChaChaRng,
|
||||
clock: &impl mas_storage::Clock,
|
||||
repo: &mut Box<dyn Repository<RepositoryError> + Send + Sync + 'static>,
|
||||
username: String,
|
||||
email: String,
|
||||
) -> Result<User, anyhow::Error> {
|
||||
//create a user with an email
|
||||
let user = repo.user().add(rng, clock, username).await.unwrap();
|
||||
|
||||
let _user_email = repo.user_email().add(rng, clock, &user, email).await;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn add_linked_upstream_session(
|
||||
rng: &mut ChaChaRng,
|
||||
clock: &impl mas_storage::Clock,
|
||||
repo: &mut Box<dyn Repository<RepositoryError> + Send + Sync + 'static>,
|
||||
provider: &mas_data_model::UpstreamOAuthProvider,
|
||||
subject: &str,
|
||||
id_token: &str,
|
||||
id_token_claims: Value,
|
||||
) -> Result<(UpstreamOAuthLink, UpstreamOAuthAuthorizationSession), anyhow::Error> {
|
||||
let session = repo
|
||||
.upstream_oauth_session()
|
||||
.add(
|
||||
rng,
|
||||
clock,
|
||||
provider,
|
||||
"state".to_owned(),
|
||||
None,
|
||||
Some("nonce".to_owned()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let link = repo
|
||||
.upstream_oauth_link()
|
||||
.add(rng, clock, provider, subject.to_owned(), None)
|
||||
.await?;
|
||||
|
||||
let session = repo
|
||||
.upstream_oauth_session()
|
||||
.complete_with_link(clock, session, &link, Some(id_token.to_owned()), Some(id_token_claims), None, None)
|
||||
.await?;
|
||||
|
||||
Ok((link, session))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1332,7 +1332,7 @@ impl TemplateContext for RecoveryFinishContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Context used by the `pages/upstream_oauth2/{link_mismatch,do_login}.html`
|
||||
/// Context used by the `pages/upstream_oauth2/{link_mismatch,login_link}.html`
|
||||
/// templates
|
||||
#[derive(Serialize)]
|
||||
pub struct UpstreamExistingLinkContext {
|
||||
|
||||
@@ -401,6 +401,9 @@ register_templates! {
|
||||
/// Render the upstream link mismatch message
|
||||
pub fn render_upstream_oauth2_link_mismatch(WithLanguage<WithCsrf<WithSession<UpstreamExistingLinkContext>>>) { "pages/upstream_oauth2/link_mismatch.html" }
|
||||
|
||||
/// Render the upstream link match
|
||||
pub fn render_upstream_oauth2_login_link(WithLanguage<WithCsrf<UpstreamExistingLinkContext>>) { "pages/upstream_oauth2/login_link.html" }
|
||||
|
||||
/// Render the upstream suggest link message
|
||||
pub fn render_upstream_oauth2_suggest_link(WithLanguage<WithCsrf<WithSession<UpstreamSuggestLink>>>) { "pages/upstream_oauth2/suggest_link.html" }
|
||||
|
||||
@@ -468,6 +471,7 @@ impl Templates {
|
||||
check::render_email_verification_html(self, now, rng)?;
|
||||
check::render_email_verification_subject(self, now, rng)?;
|
||||
check::render_upstream_oauth2_link_mismatch(self, now, rng)?;
|
||||
check::render_upstream_oauth2_login_link(self, now, rng)?;
|
||||
check::render_upstream_oauth2_suggest_link(self, now, rng)?;
|
||||
check::render_upstream_oauth2_do_register(self, now, rng)?;
|
||||
check::render_device_link(self, now, rng)?;
|
||||
|
||||
@@ -2361,6 +2361,14 @@
|
||||
"template": {
|
||||
"description": "The Jinja2 template to use for the localpart attribute\n\nIf not provided, the default template is `{{ user.preferred_username }}`",
|
||||
"type": "string"
|
||||
},
|
||||
"on_conflict": {
|
||||
"description": "How to handle conflicts on the claim, default value is `Fail`",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/OnConflict"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2397,6 +2405,25 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"OnConflict": {
|
||||
"description": "How to handle an existing localpart claim",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Fails the sso login on conflict",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"fail"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Adds the oauth identity link, regardless of whether there is an existing link or not",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"add"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"DisplaynameImportPreference": {
|
||||
"description": "What should be done for the displayname attribute",
|
||||
"type": "object",
|
||||
|
||||
@@ -776,6 +776,12 @@ upstream_oauth2:
|
||||
localpart:
|
||||
#action: force
|
||||
#template: "{{ user.preferred_username }}"
|
||||
|
||||
# How to handle when localpart already exists.
|
||||
# Possible values are (default: fail):
|
||||
# - `add` : Adds the upstream account link to the existing user, regardless of whether there is an existing link or not.
|
||||
# - `fail` : Fails the upstream OAuth 2.0 login.
|
||||
#on_conflict: fail
|
||||
|
||||
# The display name is the user's display name.
|
||||
displayname:
|
||||
|
||||
@@ -66,6 +66,30 @@ The template has the following variables available:
|
||||
- `user`: an object which contains the claims from both the `id_token` and the `userinfo` endpoint
|
||||
- `extra_callback_parameters`: an object with the additional parameters the provider sent to the redirect URL
|
||||
|
||||
|
||||
## Allow linking existing user accounts
|
||||
|
||||
The authentication service supports linking external provider identities to existing local user accounts.
|
||||
|
||||
To enable this behavior, the following option must be explicitly set in the provider configuration:
|
||||
|
||||
```yaml
|
||||
claims_imports:
|
||||
localpart:
|
||||
on_conflict: add
|
||||
```
|
||||
`on_conflict` configuration is specific to `localpart` claim_imports, it can be either:
|
||||
* `add` : when a user authenticates with the provider for the first time, the system checks whether a local user already exists with a `localpart` matching the attribute mapping `localpart` , _by default `{{ user.preferred_username }}`_. If a match is found, the external identity is linked to the existing local account.
|
||||
* `fail` *(default)* : fails the sso login.
|
||||
|
||||
To enable this option, the `localpart` mapping must be set to either `force` or `require`.
|
||||
|
||||
> ⚠️ **Security Notice**
|
||||
> Enabling this option can introduce a risk of account takeover.
|
||||
>
|
||||
> To mitigate this risk, ensure that this option is only enabled for identity providers where you can guarantee that the attribute mapping `localpart` will reliably and uniquely correspond to the intended local user account.
|
||||
|
||||
|
||||
## Multiple providers behaviour
|
||||
|
||||
Multiple authentication methods can be configured at the same time, in which case the authentication service will let the user choose which one to use.
|
||||
|
||||
@@ -188,12 +188,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
{{ button.button(text=_("action.create_account")) }}
|
||||
</form>
|
||||
|
||||
{# Leave this for now as we don't have that fully designed yet
|
||||
{{ field.separator() }}
|
||||
{{ button.link_outline(text=_("mas.upstream_oauth2.register.link_existing"), href=login_link) }}
|
||||
#}
|
||||
{% endblock content %}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{#
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
-#}
|
||||
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-heading">
|
||||
<div class="icon">
|
||||
{{ icon.link() }}
|
||||
</div>
|
||||
|
||||
<div class="header">
|
||||
<h1 class="title">{{ _("mas.upstream_oauth2.login_link.heading") }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
<section class="flex flex-col gap-6 justify-center">
|
||||
{{ _("mas.upstream_oauth2.login_link.description", username=linked_user.username) }}
|
||||
|
||||
<form method="POST" class="cpd-form-root">
|
||||
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="action" value="link" />
|
||||
|
||||
{{ button.button(text=_("mas.upstream_oauth2.login_link.action")) }}
|
||||
</form>
|
||||
|
||||
</section>
|
||||
{% endblock content %}
|
||||
+15
-1
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"create_account": "Create Account",
|
||||
"@create_account": {
|
||||
"context": "pages/login.html:94:33-59, pages/upstream_oauth2/do_register.html:192:26-52"
|
||||
"context": "pages/login.html:94:33-59, pages/upstream_oauth2/do_register.html:191:26-52"
|
||||
},
|
||||
"sign_in": "Sign in",
|
||||
"@sign_in": {
|
||||
@@ -693,6 +693,20 @@
|
||||
"description": "Page shown when the user tries to link an upstream account that is already linked to another account"
|
||||
}
|
||||
},
|
||||
"login_link": {
|
||||
"action": "Continue",
|
||||
"@action": {
|
||||
"context": "pages/upstream_oauth2/login_link.html:27:28-70"
|
||||
},
|
||||
"description": "An account exists for this username (%(username)s), it will be linked to this upstream account.",
|
||||
"@description": {
|
||||
"context": "pages/upstream_oauth2/login_link.html:21:7-85"
|
||||
},
|
||||
"heading": "Link to your existing account",
|
||||
"@heading": {
|
||||
"context": "pages/upstream_oauth2/login_link.html:17:27-70"
|
||||
}
|
||||
},
|
||||
"register": {
|
||||
"choose_username": {
|
||||
"description": "This cannot be changed later.",
|
||||
|
||||
Reference in New Issue
Block a user