use crate::sql::{Auto, ExecError, Pool};
use crate::Model;
use super::NormalizedUser;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum LinkSource {
Tenant,
Shared,
Admin,
App,
}
impl LinkSource {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Tenant => "tenant",
Self::Shared => "shared",
Self::Admin => "admin",
Self::App => "app",
}
}
}
pub const MAX_ISSUER_LEN: usize = 300;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderKey {
source: LinkSource,
provider_id: i64,
issuer: String,
}
impl ProviderKey {
pub(crate) fn for_row(
source: LinkSource,
provider_id: i64,
kind: &str,
issuer_url: Option<&str>,
) -> Self {
let issuer = match issuer_url.map(str::trim).filter(|u| !u.is_empty()) {
Some(u) => format!("{kind}|{}", u.trim_end_matches('/')),
None => kind.to_owned(),
};
Self {
source,
provider_id,
issuer,
}
}
#[must_use]
pub fn app(issuer: &str) -> Option<Self> {
(!issuer.is_empty() && issuer.len() <= MAX_ISSUER_LEN).then(|| Self {
source: LinkSource::App,
provider_id: 0,
issuer: issuer.to_owned(),
})
}
#[must_use]
pub fn issuer(&self) -> &str {
&self.issuer
}
}
#[derive(Model, Debug, Clone)]
#[rustango(
table = "rustango_sso_links",
unique_together = "provider_source, provider_id, key_sha256",
admin(
list_display = "provider_source, provider_id, subject, user_id, created_at",
search_fields = "subject",
ordering = "user_id",
readonly_fields = "key_sha256, created_at",
)
)]
#[allow(dead_code)]
pub struct SsoLink {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(max_length = 16)]
pub provider_source: String,
pub provider_id: i64,
#[rustango(max_length = 300)]
pub issuer: String,
#[rustango(max_length = 255)]
pub subject: String,
#[rustango(max_length = 64)]
pub key_sha256: String,
pub user_id: i64,
#[rustango(auto_now_add)]
pub created_at: Auto<chrono::DateTime<chrono::Utc>>,
}
#[cfg(feature = "admin")]
fn superuser_only(parts: &axum::http::request::Parts, _: Option<&serde_json::Value>) -> bool {
crate::admin::session::is_superuser(parts)
}
#[cfg(feature = "admin")]
crate::register_admin_object_permission!("rustango_sso_links", "add", superuser_only);
#[cfg(feature = "admin")]
crate::register_admin_object_permission!("rustango_sso_links", "change", superuser_only);
#[cfg(feature = "admin")]
crate::register_admin_object_permission!("rustango_sso_links", "delete", superuser_only);
#[cfg(feature = "admin")]
crate::register_admin_object_permission!("rustango_sso_providers", "add", superuser_only);
#[cfg(feature = "admin")]
crate::register_admin_object_permission!("rustango_sso_providers", "change", superuser_only);
#[cfg(feature = "admin")]
crate::register_admin_object_permission!("rustango_sso_providers", "delete", superuser_only);
#[cfg(feature = "admin")]
fn derive_key<'a>(
values: &'a mut Vec<(&'static str, crate::core::SqlValue)>,
before: Option<&'a serde_json::Value>,
) -> crate::admin::derived_fields::DeriveFuture<'a> {
use crate::admin::derived_fields::text;
let issuer = text(values, before, "issuer").unwrap_or_default();
let subject = text(values, before, "subject").unwrap_or_default();
values.retain(|(c, _)| *c != "key_sha256");
values.push((
"key_sha256",
crate::core::SqlValue::String(key_sha256(&issuer, &subject)),
));
Box::pin(async { Ok::<(), String>(()) })
}
#[cfg(feature = "admin")]
inventory::submit! {
crate::admin::derived_fields::AdminDerivedField {
table: "rustango_sso_links",
derive: derive_key,
}
}
#[must_use]
pub fn key_sha256(issuer: &str, subject: &str) -> String {
use sha2::{Digest as _, Sha256};
Sha256::digest(format!("{issuer}\n{subject}").as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
pub async fn ensure_table(pool: &Pool) -> Result<(), sqlx::Error> {
use crate::core::Model as _;
let snapshot = crate::migrate::SchemaSnapshot::from_models(&[SsoLink::SCHEMA]);
crate::migrate::apply_idempotent(pool, &snapshot).await
}
pub async fn linked_user(
pool: &Pool,
key: &ProviderKey,
subject: &str,
) -> Result<Option<SsoLink>, String> {
use crate::sql::{FetcherPool as _, UpdaterPool as _};
let rows: Vec<SsoLink> = SsoLink::objects()
.filter("provider_source", key.source.as_str())
.filter("provider_id", key.provider_id)
.filter("subject", subject.to_owned())
.fetch(pool)
.await
.map_err(|e| e.to_string())?;
let mut exact = rows
.into_iter()
.filter(|l| l.issuer == key.issuer && l.subject == subject);
let Some(mut link) = exact.next() else {
return Ok(None);
};
if exact.next().is_some() {
return Err(format!("two links for subject {subject:?}"));
}
let want = key_sha256(&key.issuer, subject);
if link.key_sha256 != want {
let id = link.id.get().copied().unwrap_or_default();
SsoLink::objects()
.filter("id", id)
.update()
.set("key_sha256", want.clone())
.execute_pool(pool)
.await
.map_err(|e| format!("repair key_sha256 of link {id}: {e}"))?;
link.key_sha256 = want;
}
Ok(Some(link))
}
#[cfg(any(feature = "tenancy", feature = "admin-sso"))]
pub(crate) async fn any_link(pool: &Pool, key: &ProviderKey) -> Result<bool, ExecError> {
const PAGE: i64 = 500;
let mut offset = 0;
loop {
let issuers = SsoLink::objects()
.filter("provider_source", key.source.as_str())
.filter("provider_id", key.provider_id)
.filter("issuer", key.issuer.clone())
.order_by(&[("id", false)])
.limit(PAGE)
.offset(offset)
.values_list_flat("issuer")
.fetch::<String>(pool)
.await?;
if issuers.iter().any(|i| *i == key.issuer) {
return Ok(true);
}
if i64::try_from(issuers.len()).unwrap_or(PAGE) < PAGE {
return Ok(false);
}
offset += PAGE;
}
}
fn link_row(key: &ProviderKey, subject: &str, user_id: i64) -> SsoLink {
SsoLink {
id: Auto::Unset,
provider_source: key.source.as_str().to_owned(),
provider_id: key.provider_id,
issuer: key.issuer.clone(),
subject: subject.to_owned(),
key_sha256: key_sha256(&key.issuer, subject),
user_id,
created_at: Auto::Unset,
}
}
pub async fn create_link(
pool: &Pool,
key: &ProviderKey,
subject: &str,
user_id: i64,
) -> Result<(), ExecError> {
link_row(key, subject, user_id).insert_pool(pool).await
}
pub async fn create_link_tx(
tx: &mut crate::sql::PoolTx<'_>,
key: &ProviderKey,
subject: &str,
user_id: i64,
) -> Result<(), ExecError> {
link_row(key, subject, user_id).insert_tx(tx).await
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct Account {
pub user_id: i64,
pub privileged: bool,
pub active: bool,
}
impl Account {
#[must_use]
pub const fn new(user_id: i64, privileged: bool, active: bool) -> Self {
Self {
user_id,
privileged,
active,
}
}
}
pub trait AccountLookup: Sync {
fn by_id(
&self,
id: i64,
) -> impl std::future::Future<Output = Result<Option<Account>, String>> + Send;
fn by_email(
&self,
email: &str,
) -> impl std::future::Future<Output = Result<EmailLookup, String>> + Send;
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum EmailLookup {
Missing,
Found(Account),
Collides,
}
impl EmailLookup {
#[allow(clippy::result_unit_err)]
pub fn pick<'a, T>(
rows: &'a [T],
wanted: &str,
email: impl Fn(&T) -> Option<&str>,
) -> Result<Option<&'a T>, ()> {
let mut exact = rows
.iter()
.filter(|r| email(r).is_some_and(|e| e.eq_ignore_ascii_case(wanted)));
match (exact.next(), exact.next()) {
(Some(one), None) => Ok(Some(one)),
(None, _) if rows.is_empty() => Ok(None),
_ => Err(()),
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum LinkRefusal {
NoSubject,
Unverified,
NoAccount(String),
EmailLinkDisabled,
Privileged,
EmailCollides,
Inactive,
Storage(String),
}
impl std::fmt::Display for LinkRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoSubject => write!(f, "the IdP returned no subject"),
Self::Unverified => write!(f, "no link and no verified email"),
Self::NoAccount(e) => write!(f, "no link and no account for {e}"),
Self::EmailLinkDisabled => {
write!(f, "no link; email linking is off for this provider")
}
Self::Privileged => {
write!(f, "no link; email linking refused for a privileged account")
}
Self::EmailCollides => write!(f, "no link; the email collides with another account"),
Self::Inactive => write!(f, "the account is inactive"),
Self::Storage(e) => write!(f, "storage: {e}"),
}
}
}
#[cfg(all(feature = "signals", feature = "admin-sso"))]
impl LinkRefusal {
pub(crate) fn failure_reason(&self) -> Option<crate::signals::auth::AuthFailureReason> {
use crate::signals::auth::AuthFailureReason as R;
match self {
Self::Storage(_) => None,
Self::Inactive => Some(R::Inactive),
Self::NoSubject
| Self::Unverified
| Self::NoAccount(_)
| Self::EmailLinkDisabled
| Self::Privileged
| Self::EmailCollides => Some(R::InvalidCredentials),
}
}
}
#[cfg(feature = "signals")]
pub(crate) async fn signal_refused(
source: &'static str,
profile: &NormalizedUser,
reason: crate::signals::auth::AuthFailureReason,
request: crate::signals::auth::AuthRequestMeta,
) {
let attempted = super::verified_email(profile)
.ok()
.map(str::to_owned)
.or_else(|| Some(profile.provider_user_id.clone()))
.filter(|s| !s.is_empty());
crate::signals::auth::send_user_login_failed(crate::signals::auth::UserLoginFailedContext {
source,
attempted_username: attempted,
reason,
request,
})
.await;
}
fn storage(e: impl std::fmt::Display) -> LinkRefusal {
LinkRefusal::Storage(e.to_string())
}
pub async fn sign_in(
pool: &Pool,
key: &ProviderKey,
allow_email_link: bool,
profile: &NormalizedUser,
accounts: &impl AccountLookup,
) -> Result<i64, LinkRefusal> {
let subject = profile.provider_user_id.as_str();
if subject.is_empty() {
return Err(LinkRefusal::NoSubject);
}
if let Some(link) = linked_user(pool, key, subject)
.await
.map_err(LinkRefusal::Storage)?
{
match accounts
.by_id(link.user_id)
.await
.map_err(LinkRefusal::Storage)?
{
Some(a) if a.active => return Ok(a.user_id),
Some(_) => return Err(LinkRefusal::Inactive),
None => {
link.delete_pool(pool).await.map_err(storage)?;
}
}
}
let email = super::verified_email(profile)
.map_err(|_| LinkRefusal::Unverified)?
.to_ascii_lowercase();
let found = match accounts
.by_email(&email)
.await
.map_err(LinkRefusal::Storage)?
{
EmailLookup::Found(a) => a,
EmailLookup::Missing => return Err(LinkRefusal::NoAccount(email)),
EmailLookup::Collides => return Err(LinkRefusal::EmailCollides),
};
if !allow_email_link {
return Err(LinkRefusal::EmailLinkDisabled);
}
if found.privileged {
return Err(LinkRefusal::Privileged);
}
if !found.active {
return Err(LinkRefusal::Inactive);
}
if let Err(e) = create_link(pool, key, subject, found.user_id).await {
return match linked_user(pool, key, subject)
.await
.map_err(LinkRefusal::Storage)?
{
Some(link) if link.user_id == found.user_id => Ok(found.user_id),
_ => Err(storage(e)),
};
}
Ok(found.user_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issuer_is_part_of_the_key() {
let a = ProviderKey::for_row(LinkSource::Tenant, 1, "oidc", Some("https://a.example/"));
let b = ProviderKey::for_row(LinkSource::Tenant, 1, "oidc", Some("https://b.example"));
assert_eq!(a.issuer, "oidc|https://a.example");
assert_ne!(a, b);
assert_eq!(
ProviderKey::for_row(LinkSource::Admin, 1, "github", Some(" ")).issuer,
"github"
);
assert_ne!(
ProviderKey::for_row(LinkSource::Tenant, 1, "github", None),
ProviderKey::for_row(LinkSource::Shared, 1, "github", None)
);
}
#[test]
fn app_issuer_is_bounded() {
assert!(ProviderKey::app("").is_none());
assert!(ProviderKey::app(&"x".repeat(MAX_ISSUER_LEN + 1)).is_none());
assert!(ProviderKey::app(&"x".repeat(MAX_ISSUER_LEN)).is_some());
}
#[test]
fn key_hash_is_exact() {
assert_ne!(key_sha256("i", "Sub-A"), key_sha256("i", "sub-a"));
assert_ne!(key_sha256("I", "s"), key_sha256("i", "s"));
assert_eq!(key_sha256("i", "x").len(), 64);
}
#[test]
fn email_pick_is_ascii_case_insensitive_and_refuses_ambiguity() {
let pick = |rows: &[&str], wanted: &str| {
let rows: Vec<String> = rows.iter().map(|r| (*r).to_owned()).collect();
EmailLookup::pick(&rows, wanted, |r| Some(r.as_str())).map(|r| r.cloned())
};
assert_eq!(
pick(&["Ann@X.com"], "ann@x.com"),
Ok(Some("Ann@X.com".into()))
);
assert_eq!(pick(&["jose@x.com"], "josé@x.com"), Err(()));
assert_eq!(pick(&["a@x", "A@x"], "a@x"), Err(()));
assert_eq!(pick(&[], "a@x"), Ok(None));
}
}