use axum::{
http::{header, request::Parts, HeaderValue},
response::{IntoResponse, Redirect, Response},
};
use crate::admin::sso::{
open_flow, seal_flow, FlowPurpose, FlowScope, ProviderButton, SsoError, SSO_FLOW_COOKIE,
};
use crate::admin::sso_provider::SsoProvider;
use crate::query::QuerySet;
use crate::signals::auth::{
meta_from_parts, send_user_logged_in, AuthFailureReason, UserLoggedInContext,
};
use crate::sql::Pool;
use crate::sso::build_provider_for;
use crate::sso::link::{signal_refused, LinkSource, ProviderKey};
use crate::sso::policy::{
admit, refuse, AdmitRequest, Denied, EffectivePolicy, Identity, PolicyError, RefusalContext,
SharedOverride, Surface, TenantSettings,
};
use crate::sso::provider::{load_rows, lookup_row, resolve_row, Lookup, ResolvedProvider};
const SIGNAL_SOURCE: &str = "tenant_admin_sso";
pub(crate) const REFUSED: &str = "nouser";
#[derive(serde::Deserialize, Default)]
struct CallbackParams {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
use super::member_auth;
use super::org::Org;
use super::routes::RouteConfig;
use super::tenant_console::{self, SessionSecret, TenantSessionPayload};
#[derive(crate::Model, Debug, Clone)]
#[rustango(
table = "rustango_shared_sso_providers",
scope = "registry",
admin(
list_display = "slug, label, kind, enabled, sort_order",
ordering = "sort_order",
readonly_fields = "created_at, updated_at",
formfield_overrides = "client_secret: password",
)
)]
#[allow(dead_code)]
pub struct SharedSsoProvider {
#[rustango(primary_key)]
pub id: crate::sql::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: crate::casts::Cast<crate::casts::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(max_length = 2000)]
pub allowed_email_domains: Option<String>,
#[rustango(max_length = 2000)]
pub blocked_email_domains: Option<String>,
#[rustango(max_length = 1000)]
pub allowed_idp_tenants: Option<String>,
#[rustango(max_length = 1000)]
pub allowed_hosted_domains: Option<String>,
#[rustango(default = "'{}'")]
pub required_claims: serde_json::Value,
#[rustango(
max_length = 16,
choices = "closed:Closed, invite_only:Invite only, allowlist:Allowlist, open:Open"
)]
pub signup_mode: Option<String>,
#[rustango(max_length = 1000)]
pub default_roles: Option<String>,
#[rustango(default = "'{}'")]
pub role_map: serde_json::Value,
#[rustango(max_length = 255)]
pub role_claims: Option<String>,
#[rustango(default = "false")]
pub sync_roles_on_login: bool,
#[rustango(
max_length = 24,
default = "'email_local'",
choices = "email_local:Email local part, email:Email, preferred_username:Preferred username"
)]
pub username_from: String,
#[rustango(
max_length = 16,
default = "'suffix'",
choices = "suffix:Add a suffix, refuse:Refuse"
)]
pub username_clash: String,
#[rustango(default = "false")]
pub sync_profile: bool,
#[rustango(default = "false")]
pub allow_email_link_staff: bool,
#[rustango(default = "false")]
pub sso_only: bool,
pub auto_deactivate_after_days: Option<i32>,
pub session_ttl_secs: Option<i64>,
pub superuser_session_ttl_secs: Option<i64>,
#[rustango(auto_now_add)]
pub created_at: crate::sql::Auto<chrono::DateTime<chrono::Utc>>,
#[rustango(auto_now)]
pub updated_at: crate::sql::Auto<chrono::DateTime<chrono::Utc>>,
}
inventory::submit! {
crate::admin::derived_fields::AdminDerivedField {
table: "rustango_shared_sso_providers",
derive: crate::sso::provider::admin_encrypt_secret,
}
}
inventory::submit! {
crate::admin::derived_fields::AdminDerivedField {
table: "rustango_shared_sso_providers",
derive: crate::sso::policy::admin_normalise,
}
}
crate::object_permissions::register_framework_object_permission!(
"rustango_shared_sso_providers",
"add",
crate::object_permissions::superuser_only
);
crate::object_permissions::register_framework_object_permission!(
"rustango_shared_sso_providers",
"change",
crate::object_permissions::superuser_only
);
crate::object_permissions::register_framework_object_permission!(
"rustango_shared_sso_providers",
"delete",
crate::object_permissions::superuser_only
);
crate::object_permissions::register_framework_object_permission!(
"rustango_shared_sso_providers",
"view",
crate::object_permissions::superuser_only
);
pub(crate) async fn list_enabled(
tenant_pool: &Pool,
registry_pool: &Pool,
routes: &RouteConfig,
) -> Vec<ProviderButton> {
let tenant_rows = load_rows(QuerySet::<SsoProvider>::new(), tenant_pool)
.await
.unwrap_or_default();
let shared_rows = load_rows(QuerySet::<SharedSsoProvider>::new(), registry_pool)
.await
.unwrap_or_default();
merge_provider_buttons(
&routes.login_url,
tenant_rows
.into_iter()
.map(|r| (r.slug, r.label, r.sort_order, r.enabled)),
shared_rows
.into_iter()
.map(|r| (r.slug, r.label, r.sort_order, r.enabled)),
)
}
fn merge_provider_buttons(
login_base: &str,
tenant: impl IntoIterator<Item = (String, String, i32, bool)>,
shared: impl IntoIterator<Item = (String, String, i32, bool)>,
) -> Vec<ProviderButton> {
let mut seen = std::collections::HashSet::new();
let mut out: Vec<(i32, ProviderButton)> = Vec::new();
for (slug, label, sort_order, enabled) in tenant.into_iter().chain(shared) {
if seen.insert(slug.clone()) && enabled {
out.push((
sort_order,
ProviderButton {
login_url: format!("{login_base}/sso/{slug}"),
slug,
label,
},
));
}
}
out.sort_by_key(|(o, _)| *o);
out.into_iter().map(|(_, b)| b).collect()
}
async fn resolve_by_slug(
tenant_pool: &Pool,
registry_pool: &Pool,
slug: &str,
redirect_uri: String,
) -> Result<Option<(ResolvedProvider, ProviderKey)>, SsoError> {
match lookup_row::<SsoProvider>(tenant_pool, slug, redirect_uri.clone()).await? {
Lookup::Enabled(p) => {
let key = p.key(LinkSource::Tenant);
return Ok(Some((*p, key)));
}
Lookup::Disabled => return Ok(None),
Lookup::Missing => {}
}
Ok(
resolve_row::<SharedSsoProvider>(registry_pool, slug, redirect_uri)
.await?
.map(|p| {
let key = p.key(LinkSource::Shared);
(p, key)
}),
)
}
pub(crate) async fn tenant_policy(
tenant_pool: &Pool,
cfg: &ResolvedProvider,
key: &ProviderKey,
slug: &str,
settings: &Result<TenantSettings, PolicyError>,
) -> EffectivePolicy {
let mut policy = cfg.effective_policy();
if key.source() == LinkSource::Shared {
policy = policy.shared_override(SharedOverride::load(tenant_pool, key, slug).await);
}
match settings {
Ok(s) => policy.tenant(s),
Err(e) => policy.tighten(Err(e.clone())),
}
}
fn derive_redirect(parts: &Parts, routes: &RouteConfig, slug: &str) -> Option<String> {
let host = crate::urls::HostAuthority::parse(parts.headers.get(header::HOST)?.to_str().ok()?)?;
let scheme =
crate::real_ip::trusted_forwarded(&parts.headers, &parts.extensions, "x-forwarded-proto")
.unwrap_or("https");
Some(format!(
"{scheme}://{host}{}",
callback_path(&routes.login_url, slug)
))
}
pub(crate) fn callback_path(login_url: &str, slug: &str) -> String {
format!("{login_url}/sso/{slug}/callback")
}
pub(crate) async fn login_notice(
tenant_pool: &Pool,
query: Option<&str>,
) -> Option<(&'static str, Option<String>)> {
let code = query?
.split('&')
.find_map(|p| p.strip_prefix("sso_error="))?;
let notice = match code {
REFUSED => "You can't sign in with this account. Ask your administrator for access.",
"denied" => "The sign-in was cancelled.",
"expired" | "callback" | "handshake" => "The sign-in did not finish. Please try again.",
"config" | "disabled" => "This sign-in option is not available right now.",
_ => return None,
};
let note = if code == REFUSED {
denied_note(tenant_pool).await
} else {
None
};
Some((notice, note))
}
async fn denied_note(pool: &Pool) -> Option<String> {
TenantSettings::load(pool).await.ok()?.access_denied_message
}
pub(crate) const AUTO_COOKIE: &str = "rustango_sso_auto";
pub(crate) fn auto_cookie(cookie_path: &str) -> String {
format!(
"{AUTO_COOKIE}=1; Path={cookie_path}; HttpOnly; SameSite=Lax; Max-Age=60{}",
secure_suffix()
)
}
pub(crate) fn auto_redirect(
settings: &TenantSettings,
providers: &[ProviderButton],
query: Option<&str>,
shown: bool,
) -> Option<String> {
let pairs: Vec<&str> = query.unwrap_or("").split('&').collect();
if shown
|| pairs
.iter()
.any(|p| *p == "local=1" || p.starts_with("sso_error="))
{
return None;
}
let pick = match &settings.default_provider {
Some(slug) => providers.iter().find(|b| &b.slug == slug),
None if !settings.shows_password_form(false) && providers.len() == 1 => providers.first(),
None => None,
};
pick.map(|b| b.login_url.clone())
}
fn login_error(routes: &RouteConfig, code: &str) -> Response {
Redirect::to(&format!("{}?sso_error={code}", routes.login_url)).into_response()
}
fn secure_suffix() -> &'static str {
if crate::session::secure_cookies() {
"; Secure"
} else {
""
}
}
fn flow_cookie(cookie_path: &str) -> crate::sso::FlowCookie<'_> {
crate::sso::FlowCookie::new(
SSO_FLOW_COOKIE,
cookie_path,
crate::session::secure_cookies(),
)
}
fn set_cookie(resp: &mut Response, value: &str) {
if let Ok(v) = HeaderValue::from_str(value) {
resp.headers_mut().append(header::SET_COOKIE, v);
}
}
pub(super) async fn tenant_sso_begin(
org: &Org,
slug: &str,
secret: &SessionSecret,
tenant_pool: &Pool,
registry_pool: &Pool,
routes: &RouteConfig,
cookie_path: &str,
parts: &Parts,
) -> Response {
let scope = crate::sso::policy::throttle_scope(Some(&org.slug));
if let Err(refused) =
crate::sso::policy::throttle_check(&scope, &parts.extensions, &parts.headers).await
{
return refused;
}
let Some(redirect_uri) = derive_redirect(parts, routes, slug) else {
return login_error(routes, "config");
};
let (cfg, key) = match resolve_by_slug(tenant_pool, registry_pool, slug, redirect_uri).await {
Ok(Some(c)) => c,
Ok(None) => return login_error(routes, "disabled"),
Err(e) => {
tracing::error!(target: "rustango::tenancy::sso", "begin resolve: {e}");
return login_error(routes, "config");
}
};
let provider = match build_provider_for(&cfg.sso, key.source()).await {
Ok(p) => p,
Err(e) => {
tracing::error!(target: "rustango::tenancy::sso", "begin build: {e}");
return login_error(routes, "config");
}
};
let Some(tenant) = super::TenantSlug::of(org) else {
return login_error(routes, "config");
};
let (url, flow) = provider.begin();
let sealed = seal_flow(
&flow,
FlowScope::for_tenant(FlowPurpose::TenantAdmin, &tenant, slug),
secret.key(),
);
let mut resp = Redirect::to(&url).into_response();
set_cookie(&mut resp, &flow_cookie(cookie_path).set(&sealed, 600));
resp
}
pub(super) async fn tenant_sso_callback(
org: &Org,
slug: &str,
secret: &SessionSecret,
tenant_pool: &Pool,
registry_pool: &Pool,
routes: &RouteConfig,
cookie_path: &str,
parts: &Parts,
) -> Response {
let params: CallbackParams =
serde_urlencoded::from_str(parts.uri.query().unwrap_or("")).unwrap_or_default();
if params.error.is_some() {
return login_error(routes, "denied");
}
let (Some(code), Some(cb_state)) = (params.code, params.state) else {
return login_error(routes, "callback");
};
let Some(sealed) = flow_cookie(cookie_path).read(&parts.headers) else {
return login_error(routes, "expired");
};
let Some(tenant) = super::TenantSlug::of(org) else {
return login_error(routes, "config");
};
let flow = match open_flow(
sealed,
FlowScope::for_tenant(FlowPurpose::TenantAdmin, &tenant, slug),
secret.key(),
) {
Ok(f) => f,
Err(_) => return login_error(routes, "expired"),
};
let scope = crate::sso::policy::throttle_scope(Some(&org.slug));
let attempt =
match crate::sso::policy::throttle_begin(&scope, &parts.extensions, &parts.headers).await {
Ok(a) => a,
Err(refused) => return refused,
};
if !crate::sso::claim_flow(&flow) {
return login_error(routes, "expired");
}
let Some(redirect_uri) = derive_redirect(parts, routes, slug) else {
return login_error(routes, "config");
};
let (cfg, key) = match resolve_by_slug(tenant_pool, registry_pool, slug, redirect_uri).await {
Ok(Some(c)) => c,
Ok(None) => return login_error(routes, "disabled"),
Err(e) => {
tracing::error!(target: "rustango::tenancy::sso", "callback resolve: {e}");
return login_error(routes, "config");
}
};
let provider = match build_provider_for(&cfg.sso, key.source()).await {
Ok(p) => p,
Err(e) => {
tracing::error!(target: "rustango::tenancy::sso", "callback build: {e}");
return login_error(routes, "config");
}
};
let identity = match provider.complete(&flow, &code, &cb_state).await {
Ok((u, tokens)) => Identity::from_handshake(&provider, u, &tokens, &flow).await,
Err(e) => {
tracing::warn!(target: "rustango::tenancy::sso", "handshake: {e}");
attempt.failed().await;
return login_error(routes, "handshake");
}
};
let normalized = &identity.profile;
let meta = || meta_from_parts(&parts.extensions, &parts.headers, Some(parts.uri.path()));
let settings = TenantSettings::load(tenant_pool).await;
let policy = tenant_policy(tenant_pool, &cfg, &key, slug, &settings).await;
let request = AdmitRequest::new(tenant_pool, Surface::TenantAdmin, &key, &policy, &identity)
.provider(slug);
let uid = match admit(request, &member_auth::TenantAccounts(tenant_pool)).await {
Ok(a) => a.user_id,
Err(Denied::Refused(r)) => {
let ctx = RefusalContext {
pool: tenant_pool,
surface: Surface::TenantAdmin,
signal_source: SIGNAL_SOURCE,
provider: slug,
key: &key,
identity: &identity,
tenant: Some(org.slug.clone()),
extensions: &parts.extensions,
headers: &parts.headers,
path: parts.uri.path(),
};
refuse(&ctx, &r).await;
attempt.failed().await;
return login_error(routes, REFUSED);
}
Err(e) => {
tracing::error!(target: "rustango::tenancy::sso", tenant = %org.slug, slug, "sso sign-in failed: {e}");
return login_error(routes, REFUSED);
}
};
let Some(user) = member_auth::tenant_user(tenant_pool, uid).await else {
return login_error(routes, REFUSED);
};
if !user.active {
signal_refused(
SIGNAL_SOURCE,
normalized,
AuthFailureReason::Inactive,
meta(),
Some(org.slug.clone()),
)
.await;
return login_error(routes, REFUSED);
}
attempt.succeeded().await;
let route_ttl = i64::try_from(routes.tenant_session_ttl.as_secs())
.unwrap_or(tenant_console::SESSION_TTL_SECS);
let ttl = policy.session_ttl(route_ttl, user.is_superuser);
let mut payload = TenantSessionPayload::new(
uid,
&tenant,
ttl,
super::session::PasswordFingerprint::of(secret, &user.password_hash),
);
payload.iat = crate::session::issued_at(user.sessions_revoked_at);
let cookie_value = tenant_console::encode(secret, &payload);
let session_cookie = format!(
"{}={cookie_value}; Path={cookie_path}; HttpOnly; SameSite=Lax; Max-Age={ttl}{}",
tenant_console::COOKIE_NAME,
secure_suffix()
);
let mut resp = Redirect::to(routes.admin_url.as_str()).into_response();
set_cookie(&mut resp, &session_cookie);
for c in flow_cookie(cookie_path).clear() {
set_cookie(&mut resp, &c);
}
send_user_logged_in(UserLoggedInContext {
source: SIGNAL_SOURCE,
user_id: uid,
username: user.username.clone(),
is_superuser: user.is_superuser,
request: meta(),
tenant: Some(org.slug.clone()),
})
.await;
resp
}
#[cfg(test)]
mod tests {
use super::merge_provider_buttons;
fn row(slug: &str, label: &str, sort: i32, enabled: bool) -> (String, String, i32, bool) {
(slug.into(), label.into(), sort, enabled)
}
#[test]
fn tenant_wins_on_slug_clash_and_result_is_sorted() {
let buttons = merge_provider_buttons(
"/login",
vec![
row("corp", "Tenant Corp", 1, true),
row("off", "Off", 0, false),
],
vec![
row("corp", "Shared Corp", 0, true),
row("global", "Global", 5, true),
],
);
let view: Vec<_> = buttons
.iter()
.map(|b| (b.slug.as_str(), b.label.as_str(), b.login_url.as_str()))
.collect();
assert_eq!(
view,
vec![
("corp", "Tenant Corp", "/login/sso/corp"), ("global", "Global", "/login/sso/global"), ]
);
}
#[test]
fn a_disabled_tenant_row_hides_the_shared_one() {
let buttons = merge_provider_buttons(
"/login",
vec![row("corp", "Tenant Corp", 0, false)],
vec![row("corp", "Shared Corp", 0, true)],
);
assert!(buttons.is_empty(), "{:?}", buttons.len());
}
#[test]
fn auto_redirect_picks_and_never_loops() {
use crate::sso::policy::{PasswordLogin, TenantSettings};
let one = merge_provider_buttons("/login", vec![row("corp", "C", 0, true)], vec![]);
let two = merge_provider_buttons(
"/login",
vec![row("corp", "C", 0, true), row("b", "B", 1, true)],
vec![],
);
let on = TenantSettings::default();
let off = TenantSettings {
password_login: PasswordLogin::SuperusersOnly,
..TenantSettings::default()
};
let default = TenantSettings {
default_provider: Some("corp".into()),
..TenantSettings::default()
};
let go = |s: &TenantSettings, p: &[super::ProviderButton], q: Option<&str>, f| {
super::auto_redirect(s, p, q, f)
};
assert_eq!(go(&on, &one, None, false), None);
assert_eq!(
go(&off, &one, None, false).as_deref(),
Some("/login/sso/corp")
);
assert_eq!(go(&off, &two, None, false), None);
assert_eq!(
go(&default, &two, None, false).as_deref(),
Some("/login/sso/corp")
);
for q in ["local=1", "sso_error=nouser", "next=/x&local=1"] {
assert_eq!(go(&default, &two, Some(q), false), None, "{q}");
}
assert_eq!(go(&default, &two, None, true), None);
}
#[test]
fn shared_providers_are_superuser_only_in_the_admin() {
use crate::admin::object_permissions::is_allowed;
let parts = |scoped: bool| {
let mut p = axum::http::Request::builder()
.body(())
.unwrap()
.into_parts()
.0;
p.extensions
.insert(crate::admin::session::PermsScoped(scoped));
p
};
for action in ["add", "change", "delete", "view"] {
let t = "rustango_shared_sso_providers";
assert!(!is_allowed(t, action, &parts(true), None), "{action}");
assert!(is_allowed(t, action, &parts(false), None), "{action}");
}
}
#[test]
fn shared_only_when_no_tenant_providers() {
let buttons = merge_provider_buttons(
"/admin/login",
std::iter::empty(),
vec![row("okta", "Okta", 0, true)],
);
assert_eq!(buttons.len(), 1);
assert_eq!(buttons[0].login_url, "/admin/login/sso/okta");
}
#[test]
fn derive_redirect_refuses_a_host_with_userinfo() {
let routes = super::RouteConfig::default();
let parts = |host: &str| {
axum::http::Request::builder()
.header("host", host)
.body(())
.unwrap()
.into_parts()
.0
};
assert!(super::derive_redirect(&parts("good.com:1@evil.com:2"), &routes, "g").is_none());
assert!(super::derive_redirect(&parts("good.com@evil.com"), &routes, "g").is_none());
let ok = super::derive_redirect(&parts("good.com:8443"), &routes, "g").unwrap();
assert!(ok.starts_with("https://good.com:8443/"), "{ok}");
}
}