Skip to content

Commit 5cbe503

Browse files
authored
Allow account linking for existing users when the localpart matches in upstream OAuth 2.0 logins (#4193)
2 parents 5eb8e78 + cfa9a23 commit 5cbe503

File tree

15 files changed

+643
-36
lines changed

15 files changed

+643
-36
lines changed

crates/cli/src/sync.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,30 @@ fn map_import_action(
3737
}
3838
}
3939

40+
fn map_import_on_conflict(
41+
config: mas_config::UpstreamOAuth2OnConflict,
42+
) -> mas_data_model::UpstreamOAuthProviderOnConflict {
43+
match config {
44+
mas_config::UpstreamOAuth2OnConflict::Add => {
45+
mas_data_model::UpstreamOAuthProviderOnConflict::Add
46+
}
47+
mas_config::UpstreamOAuth2OnConflict::Fail => {
48+
mas_data_model::UpstreamOAuthProviderOnConflict::Fail
49+
}
50+
}
51+
}
52+
4053
fn map_claims_imports(
4154
config: &mas_config::UpstreamOAuth2ClaimsImports,
4255
) -> mas_data_model::UpstreamOAuthProviderClaimsImports {
4356
mas_data_model::UpstreamOAuthProviderClaimsImports {
4457
subject: mas_data_model::UpstreamOAuthProviderSubjectPreference {
4558
template: config.subject.template.clone(),
4659
},
47-
localpart: mas_data_model::UpstreamOAuthProviderImportPreference {
60+
localpart: mas_data_model::UpstreamOAuthProviderLocalpartPreference {
4861
action: map_import_action(config.localpart.action),
4962
template: config.localpart.template.clone(),
63+
on_conflict: map_import_on_conflict(config.localpart.on_conflict),
5064
},
5165
displayname: mas_data_model::UpstreamOAuthProviderImportPreference {
5266
action: map_import_action(config.displayname.action),

crates/config/src/sections/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ pub use self::{
5454
EmailImportPreference as UpstreamOAuth2EmailImportPreference,
5555
ImportAction as UpstreamOAuth2ImportAction,
5656
OnBackchannelLogout as UpstreamOAuth2OnBackchannelLogout,
57-
PkceMethod as UpstreamOAuth2PkceMethod, Provider as UpstreamOAuth2Provider,
58-
ResponseMode as UpstreamOAuth2ResponseMode,
57+
OnConflict as UpstreamOAuth2OnConflict, PkceMethod as UpstreamOAuth2PkceMethod,
58+
Provider as UpstreamOAuth2Provider, ResponseMode as UpstreamOAuth2ResponseMode,
5959
TokenAuthMethod as UpstreamOAuth2TokenAuthMethod, UpstreamOAuth2Config,
6060
},
6161
};

crates/config/src/sections/upstream_oauth2.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,18 @@ impl ConfigurationSection for UpstreamOAuth2Config {
117117
}
118118
}
119119
}
120+
121+
if matches!(
122+
provider.claims_imports.localpart.on_conflict,
123+
OnConflict::Add
124+
) && !matches!(
125+
provider.claims_imports.localpart.action,
126+
ImportAction::Force | ImportAction::Require
127+
) {
128+
return Err(annotate(figment::Error::custom(
129+
"The field `action` must be either `force` or `require` when `on_conflict` is set to `add`",
130+
)).into());
131+
}
120132
}
121133

122134
Ok(())
@@ -190,6 +202,26 @@ impl ImportAction {
190202
}
191203
}
192204

205+
/// How to handle an existing localpart claim
206+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
207+
#[serde(rename_all = "lowercase")]
208+
pub enum OnConflict {
209+
/// Fails the sso login on conflict
210+
#[default]
211+
Fail,
212+
213+
/// Adds the oauth identity link, regardless of whether there is an existing
214+
/// link or not
215+
Add,
216+
}
217+
218+
impl OnConflict {
219+
#[allow(clippy::trivially_copy_pass_by_ref)]
220+
const fn is_default(&self) -> bool {
221+
matches!(self, OnConflict::Fail)
222+
}
223+
}
224+
193225
/// What should be done for the subject attribute
194226
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
195227
pub struct SubjectImportPreference {
@@ -218,6 +250,10 @@ pub struct LocalpartImportPreference {
218250
/// If not provided, the default template is `{{ user.preferred_username }}`
219251
#[serde(default, skip_serializing_if = "Option::is_none")]
220252
pub template: Option<String>,
253+
254+
/// How to handle conflicts on the claim, default value is `Fail`
255+
#[serde(default, skip_serializing_if = "OnConflict::is_default")]
256+
pub on_conflict: OnConflict,
221257
}
222258

223259
impl LocalpartImportPreference {

crates/data-model/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ pub use self::{
4242
UpstreamOAuthAuthorizationSession, UpstreamOAuthAuthorizationSessionState,
4343
UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports,
4444
UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderImportAction,
45-
UpstreamOAuthProviderImportPreference, UpstreamOAuthProviderOnBackchannelLogout,
45+
UpstreamOAuthProviderImportPreference, UpstreamOAuthProviderLocalpartPreference,
46+
UpstreamOAuthProviderOnBackchannelLogout, UpstreamOAuthProviderOnConflict,
4647
UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderResponseMode,
4748
UpstreamOAuthProviderSubjectPreference, UpstreamOAuthProviderTokenAuthMethod,
4849
},

crates/data-model/src/upstream_oauth2/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ pub use self::{
1515
DiscoveryMode as UpstreamOAuthProviderDiscoveryMode,
1616
ImportAction as UpstreamOAuthProviderImportAction,
1717
ImportPreference as UpstreamOAuthProviderImportPreference,
18+
LocalpartPreference as UpstreamOAuthProviderLocalpartPreference,
1819
OnBackchannelLogout as UpstreamOAuthProviderOnBackchannelLogout,
19-
PkceMode as UpstreamOAuthProviderPkceMode,
20+
OnConflict as UpstreamOAuthProviderOnConflict, PkceMode as UpstreamOAuthProviderPkceMode,
2021
ResponseMode as UpstreamOAuthProviderResponseMode,
2122
SubjectPreference as UpstreamOAuthProviderSubjectPreference,
2223
TokenAuthMethod as UpstreamOAuthProviderTokenAuthMethod, UpstreamOAuthProvider,

crates/data-model/src/upstream_oauth2/provider.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ pub struct ClaimsImports {
313313
pub subject: SubjectPreference,
314314

315315
#[serde(default)]
316-
pub localpart: ImportPreference,
316+
pub localpart: LocalpartPreference,
317317

318318
#[serde(default)]
319319
pub displayname: ImportPreference,
@@ -332,6 +332,26 @@ pub struct SubjectPreference {
332332
pub template: Option<String>,
333333
}
334334

335+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
336+
pub struct LocalpartPreference {
337+
#[serde(default)]
338+
pub action: ImportAction,
339+
340+
#[serde(default)]
341+
pub template: Option<String>,
342+
343+
#[serde(default)]
344+
pub on_conflict: OnConflict,
345+
}
346+
347+
impl std::ops::Deref for LocalpartPreference {
348+
type Target = ImportAction;
349+
350+
fn deref(&self) -> &Self::Target {
351+
&self.action
352+
}
353+
}
354+
335355
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
336356
pub struct ImportPreference {
337357
#[serde(default)]
@@ -368,7 +388,7 @@ pub enum ImportAction {
368388

369389
impl ImportAction {
370390
#[must_use]
371-
pub fn is_forced(&self) -> bool {
391+
pub fn is_forced_or_required(&self) -> bool {
372392
matches!(self, Self::Force | Self::Require)
373393
}
374394

@@ -391,3 +411,15 @@ impl ImportAction {
391411
}
392412
}
393413
}
414+
415+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
416+
#[serde(rename_all = "lowercase")]
417+
pub enum OnConflict {
418+
/// Fails the upstream OAuth 2.0 login
419+
#[default]
420+
Fail,
421+
422+
/// Adds the upstream account link, regardless of whether there is an
423+
/// existing link or not
424+
Add,
425+
}

0 commit comments

Comments
 (0)