feat(oidc): permit auto/manual oidc account link/unlink
link are checking that email matches, +email alias are normalize into email if email is already used on another account, link is not possible not usurpation risk as the IDP is choosen by the admin
This commit is contained in:
@@ -20,6 +20,39 @@ pub fn ascii_ci_contains(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
.any(|w| w.eq_ignore_ascii_case(needle))
|
||||
}
|
||||
|
||||
/// Normalize an email address for **linking-equivalence comparison**
|
||||
/// (NOT for storage — never modify what the user typed when persisting
|
||||
/// or displaying).
|
||||
///
|
||||
/// Rules:
|
||||
/// - Case-fold to ASCII lowercase (email addresses are treated as
|
||||
/// case-insensitive in practice per RFC 5321 §2.4).
|
||||
/// - Strip `+alias` sub-addressing from the local part:
|
||||
/// `alice+github@example.com` → `alice@example.com`. Supported by
|
||||
/// Gmail / Google Workspace, Outlook/O365 (since 2018), Fastmail
|
||||
/// (since ~2020), and most modern providers. Safe for a 1:1
|
||||
/// comparison — two same-user addresses normalise to the same value.
|
||||
///
|
||||
/// NOT doing:
|
||||
/// - Dot-stripping (Gmail-only: `a.lice@gmail.com == alice@gmail.com`).
|
||||
/// Applying universally would false-positive on providers that treat
|
||||
/// dots as significant.
|
||||
/// - Unicode normalisation — email addresses compare as ASCII already.
|
||||
///
|
||||
/// Load-bearing for `POST /api/auth/oidc/link/start` → callback and
|
||||
/// for the auto-link decision on OIDC login. See
|
||||
/// docs/plan/oidc-account-linking.md § Safety checks.
|
||||
pub fn normalize_email_for_link(email: &str) -> String {
|
||||
let lower = email.trim().to_ascii_lowercase();
|
||||
let Some((local, domain)) = lower.split_once('@') else {
|
||||
// Malformed — return the lowercased form; caller's comparison
|
||||
// will fail naturally.
|
||||
return lower;
|
||||
};
|
||||
let local_base = local.split_once('+').map(|(b, _)| b).unwrap_or(local);
|
||||
format!("{}@{}", local_base, domain)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -51,4 +84,83 @@ mod tests {
|
||||
fn empty_needle_is_true() {
|
||||
assert!(ascii_ci_contains(b"anything", b""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_email_for_link_matrix() {
|
||||
// Behaviour matrix from docs/plan/oidc-account-linking.md
|
||||
// § Email normalization. Left = raw, right = expected normalized.
|
||||
let cases: &[(&str, &str)] = &[
|
||||
// Identity
|
||||
("alice@example.com", "alice@example.com"),
|
||||
// Case fold
|
||||
("Alice@Example.COM", "alice@example.com"),
|
||||
// +alias stripped
|
||||
("alice+github@example.com", "alice@example.com"),
|
||||
("alice+oidc@example.com", "alice@example.com"),
|
||||
// Both sides of a match normalise the same way
|
||||
("alice+work@example.com", "alice@example.com"),
|
||||
// Empty +alias suffix is still stripped
|
||||
("alice+@example.com", "alice@example.com"),
|
||||
// Multiple + in local: everything after the FIRST + is dropped
|
||||
("alice+work+extra@example.com", "alice@example.com"),
|
||||
// Trim leading/trailing whitespace
|
||||
(" alice@example.com ", "alice@example.com"),
|
||||
// Different local parts stay different
|
||||
("bob@example.com", "bob@example.com"),
|
||||
// Different domains stay different (no cross-domain equivalence)
|
||||
("alice@corp.com", "alice@corp.com"),
|
||||
// Domain case-folded too
|
||||
("alice@Example.COM", "alice@example.com"),
|
||||
];
|
||||
for (raw, expected) in cases {
|
||||
assert_eq!(
|
||||
normalize_email_for_link(raw),
|
||||
*expected,
|
||||
"normalize_email_for_link({raw:?}) should equal {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_email_link_equivalence_pairs() {
|
||||
// Anti-drift: pairs that MUST compare equal after normalization
|
||||
// (the "auto-link email match" cases the plan doc lists as ✅).
|
||||
let equivalent: &[(&str, &str)] = &[
|
||||
("alice@example.com", "alice@example.com"),
|
||||
("alice@example.com", "Alice@example.com"),
|
||||
("alice@example.com", "alice+oidc@example.com"),
|
||||
("alice+work@example.com", "alice@example.com"),
|
||||
("alice+work@example.com", "alice+home@example.com"),
|
||||
];
|
||||
for (left, right) in equivalent {
|
||||
assert_eq!(
|
||||
normalize_email_for_link(left),
|
||||
normalize_email_for_link(right),
|
||||
"{left:?} should equal {right:?} under linking normalization"
|
||||
);
|
||||
}
|
||||
|
||||
// Pairs that MUST NOT match — the plan's ❌ cases.
|
||||
let distinct: &[(&str, &str)] = &[
|
||||
("alice@example.com", "bob@example.com"),
|
||||
("alice@example.com", "alice@corp.com"),
|
||||
// Dot-stripping deliberately NOT applied — dots stay significant.
|
||||
("a.lice@gmail.com", "alice@gmail.com"),
|
||||
];
|
||||
for (left, right) in distinct {
|
||||
assert_ne!(
|
||||
normalize_email_for_link(left),
|
||||
normalize_email_for_link(right),
|
||||
"{left:?} MUST NOT equal {right:?} — dot-stripping is Gmail-only, we don't apply it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_email_malformed_returns_lowercased() {
|
||||
// No `@` → return lowercased trimmed form; the caller's
|
||||
// downstream comparison will fail naturally.
|
||||
assert_eq!(normalize_email_for_link("not-an-email"), "not-an-email");
|
||||
assert_eq!(normalize_email_for_link(" MIXED-Case "), "mixed-case");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user