use crate::casts::{Cast, EncryptedString};
use crate::sql::Auto;
#[derive(crate::Model, Debug, Clone)]
#[rustango(
table = "rustango_sso_providers",
admin(
list_display = "slug, label, kind, enabled, sort_order",
ordering = "sort_order",
readonly_fields = "created_at, updated_at",
)
)]
#[allow(dead_code)]
pub struct SsoProvider {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(max_length = 64, unique)]
pub slug: String,
#[rustango(max_length = 150)]
pub label: String,
#[rustango(max_length = 32)]
pub kind: String,
#[rustango(max_length = 255)]
pub issuer_url: Option<String>,
#[rustango(max_length = 255)]
pub client_id: String,
#[rustango(max_length = 1024)]
pub client_secret: Cast<EncryptedString>,
#[rustango(default = "true")]
pub enabled: bool,
#[rustango(default = "0")]
pub sort_order: i32,
#[rustango(max_length = 255)]
pub scopes: Option<String>,
#[rustango(default = "false")]
pub allow_email_link: bool,
#[rustango(auto_now_add)]
pub created_at: Auto<chrono::DateTime<chrono::Utc>>,
#[rustango(auto_now)]
pub updated_at: Auto<chrono::DateTime<chrono::Utc>>,
}
use super::link::{LinkSource, ProviderKey};
use super::{parse_scopes, ProviderButton, ResolvedSso, SsoError};
use crate::core::{Model, SqlValue};
use crate::query::QuerySet;
use crate::sql::{ExecError, Pool};
const DEFERRED: &[&str] = &["allow_email_link", "created_at", "updated_at"];
pub(crate) struct ProviderRow {
pub id: i64,
pub slug: String,
pub label: String,
pub kind: String,
pub issuer_url: Option<String>,
pub client_id: String,
client_secret: String,
pub enabled: bool,
pub sort_order: i32,
pub scopes: Option<String>,
}
fn text(v: Option<&SqlValue>) -> Option<String> {
match v {
Some(SqlValue::String(s)) => Some(s.clone()),
_ => None,
}
}
fn int(v: Option<&SqlValue>) -> i64 {
match v {
Some(SqlValue::I64(n)) => *n,
Some(SqlValue::I32(n)) => i64::from(*n),
Some(SqlValue::I16(n)) => i64::from(*n),
Some(SqlValue::Bool(b)) => i64::from(*b),
_ => 0,
}
}
pub(crate) async fn load_rows<T: Model>(
qs: QuerySet<T>,
pool: &Pool,
) -> Result<Vec<ProviderRow>, ExecError> {
let rows = qs.defer(DEFERRED).fetch(pool).await?;
Ok(rows
.iter()
.map(|r| ProviderRow {
id: int(r.get("id")),
slug: text(r.get("slug")).unwrap_or_default(),
label: text(r.get("label")).unwrap_or_default(),
kind: text(r.get("kind")).unwrap_or_default(),
issuer_url: text(r.get("issuer_url")),
client_id: text(r.get("client_id")).unwrap_or_default(),
client_secret: text(r.get("client_secret")).unwrap_or_default(),
enabled: int(r.get("enabled")) != 0,
sort_order: i32::try_from(int(r.get("sort_order"))).unwrap_or(0),
scopes: text(r.get("scopes")),
})
.collect())
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ResolvedProvider {
pub sso: ResolvedSso,
pub id: i64,
pub allow_email_link: bool,
}
impl ResolvedProvider {
#[must_use]
pub fn key(&self, source: LinkSource) -> ProviderKey {
ProviderKey::for_row(
source,
self.id,
&self.sso.provider,
self.sso.issuer_url.as_deref(),
)
}
}
pub(crate) async fn resolve_row<T: Model>(
pool: &Pool,
slug: &str,
redirect_uri: String,
) -> Result<Option<ResolvedProvider>, SsoError> {
use crate::casts::CastValue as _;
let row = load_rows(QuerySet::<T>::new().filter("slug", slug.to_owned()), pool)
.await
.map_err(|e| SsoError::Config(format!("db: {e}")))?
.into_iter()
.find(|r| r.enabled);
let Some(r) = row else {
return Ok(None);
};
let client_secret =
EncryptedString::from_db(&r.client_secret).map_err(|e| SsoError::Secret(e.to_string()))?;
let allow_email_link = match QuerySet::<T>::new()
.filter("id", r.id)
.values_list_flat("allow_email_link")
.fetch::<bool>(pool)
.await
{
Ok(v) => v.first().copied().unwrap_or(false),
Err(e) => {
tracing::warn!(target: "rustango::sso", "allow_email_link unreadable, treated as off: {e}");
false
}
};
Ok(Some(ResolvedProvider {
sso: ResolvedSso {
provider: r.kind,
issuer_url: r.issuer_url,
client_id: r.client_id,
client_secret,
redirect_uri,
scopes: parse_scopes(r.scopes.as_deref()),
},
id: r.id,
allow_email_link,
}))
}
pub async fn list_enabled(pool: &Pool, login_base: &str) -> Vec<ProviderButton> {
let mut rows = load_rows(QuerySet::<SsoProvider>::new(), pool)
.await
.unwrap_or_default();
rows.retain(|r| r.enabled);
rows.sort_by_key(|r| r.sort_order);
rows.into_iter()
.map(|r| ProviderButton {
login_url: format!("{login_base}/sso/{}", r.slug),
slug: r.slug,
label: r.label,
})
.collect()
}
pub async fn resolve_by_slug(
pool: &Pool,
slug: &str,
redirect_uri: String,
) -> Result<Option<ResolvedProvider>, SsoError> {
resolve_row::<SsoProvider>(pool, slug, redirect_uri).await
}