#[cfg(any(feature = "tenancy", feature = "admin-sso"))]
pub(crate) mod check;
pub mod link;
pub mod provider;
pub use link::{LinkSource, ProviderKey, SsoLink};
pub use provider::{list_enabled, resolve_by_slug, ResolvedProvider, SsoProvider};
use crate::oauth2::{providers, OAuth2Provider, OAuthError};
use crate::outbound::TargetPolicy;
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex, PoisonError};
use std::time::{Duration, Instant};
pub use crate::oauth2::{open_flow, seal_flow, FlowPurpose, FlowScope, NormalizedUser, OAuth2Flow};
pub const SSO_FLOW_COOKIE: &str = "rustango_admin_sso_flow";
#[derive(Debug, Clone)]
pub struct ResolvedSso {
pub provider: String,
pub issuer_url: Option<String>,
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
pub scopes: Option<Vec<String>>,
}
#[derive(Debug)]
pub enum SsoError {
UnknownProvider(String),
MissingIssuer,
NotEnabled,
EmailNotVerified,
NoMatchingUser(String),
Inactive,
Secret(String),
Config(String),
Oauth(OAuthError),
}
impl From<OAuthError> for SsoError {
fn from(e: OAuthError) -> Self {
SsoError::Oauth(e)
}
}
impl std::fmt::Display for SsoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SsoError::UnknownProvider(p) => write!(f, "unknown SSO provider: {p}"),
SsoError::MissingIssuer => write!(f, "provider=oidc requires an issuer_url"),
SsoError::NotEnabled => write!(f, "SSO is not enabled"),
SsoError::EmailNotVerified => write!(f, "the IdP email is not verified"),
SsoError::NoMatchingUser(e) => write!(f, "no admin account for {e}"),
SsoError::Inactive => write!(f, "the matched account is inactive"),
SsoError::Secret(m) => write!(f, "could not resolve the SSO client secret: {m}"),
SsoError::Config(m) => write!(f, "SSO misconfigured: {m}"),
SsoError::Oauth(e) => write!(f, "SSO handshake failed: {e}"),
}
}
}
impl std::error::Error for SsoError {}
pub async fn build_provider(cfg: &ResolvedSso) -> Result<OAuth2Provider, SsoError> {
build_provider_with(cfg, &TargetPolicy::from_env()).await
}
async fn build_provider_with(
cfg: &ResolvedSso,
policy: &TargetPolicy,
) -> Result<OAuth2Provider, SsoError> {
if cfg.client_id.trim().is_empty() {
return Err(SsoError::Config("client_id is empty".into()));
}
let (id, secret, redirect) = (
cfg.client_id.clone(),
cfg.client_secret.clone(),
cfg.redirect_uri.clone(),
);
let provider = match cfg.provider.as_str() {
"google" => providers::google(id, secret, redirect),
"microsoft" => providers::microsoft(id, secret, redirect),
"github" => providers::github(id, secret, redirect),
"gitlab" => providers::gitlab(id, secret, redirect),
"discord" => providers::discord(id, secret, redirect),
"oidc" => {
let issuer = cfg.issuer_url.as_deref().ok_or(SsoError::MissingIssuer)?;
discovered(issuer, id, secret, redirect, policy).await?
}
other => return Err(SsoError::UnknownProvider(other.to_owned())),
};
let provider = match &cfg.scopes {
Some(s) if !s.is_empty() => provider.with_scopes(s.iter().cloned()),
_ => provider,
};
Ok(provider)
}
const DISCOVERY_TTL: Duration = Duration::from_secs(3600);
const DISCOVERY_MAX: usize = 1024;
#[derive(Clone)]
struct Endpoints {
auth_url: String,
token_url: String,
userinfo_url: Option<String>,
}
static DISCOVERED: LazyLock<Mutex<HashMap<String, (Instant, Endpoints)>>> =
LazyLock::new(Mutex::default);
async fn discovered(
issuer: &str,
id: String,
secret: String,
redirect: String,
policy: &TargetPolicy,
) -> Result<OAuth2Provider, OAuthError> {
let key = issuer.trim_end_matches('/').to_owned();
let hit = {
let map = DISCOVERED.lock().unwrap_or_else(PoisonError::into_inner);
map.get(&key)
.filter(|(at, _)| at.elapsed() < DISCOVERY_TTL)
.map(|(_, e)| e.clone())
};
if let Some(e) = hit {
let p = OAuth2Provider::new("oidc", id, secret, redirect, e.auth_url, e.token_url);
return Ok(match e.userinfo_url {
Some(u) => p.with_userinfo_url(u),
None => p,
});
}
let p = OAuth2Provider::new("oidc", id, secret, redirect, "", "")
.discover(&key, policy)
.await?;
let e = Endpoints {
auth_url: p.auth_url.clone(),
token_url: p.token_url.clone(),
userinfo_url: p.userinfo_url.clone(),
};
let mut map = DISCOVERED.lock().unwrap_or_else(PoisonError::into_inner);
remember(&mut map, key, e, DISCOVERY_MAX);
Ok(p)
}
fn remember(
map: &mut HashMap<String, (Instant, Endpoints)>,
key: String,
e: Endpoints,
max: usize,
) {
map.retain(|_, (at, _)| at.elapsed() < DISCOVERY_TTL);
if map.len() >= max && !map.contains_key(&key) {
let oldest = map
.iter()
.min_by_key(|(_, (at, _))| *at)
.map(|(k, _)| k.clone());
if let Some(k) = oldest {
map.remove(&k);
}
}
map.insert(key, (Instant::now(), e));
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ProviderButton {
pub slug: String,
pub label: String,
pub login_url: String,
}
#[must_use]
pub fn parse_scopes(scopes: Option<&str>) -> Option<Vec<String>> {
let s = scopes?.trim();
if s.is_empty() {
return None;
}
Some(s.split_whitespace().map(str::to_owned).collect())
}
pub fn resolve_secret_ref_env(reference: &str) -> Result<String, SsoError> {
if let Some(var) = reference.strip_prefix("env://") {
std::env::var(var).map_err(|_| SsoError::Secret(format!("env var `{var}` is unset")))
} else {
Ok(reference.to_owned())
}
}
pub fn verified_email(user: &NormalizedUser) -> Result<&str, SsoError> {
match (&user.email, user.email_verified) {
(Some(e), true) if !e.is_empty() => Ok(e.as_str()),
_ => Err(SsoError::EmailNotVerified),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
fn cfg(provider: &str) -> ResolvedSso {
ResolvedSso {
provider: provider.into(),
issuer_url: None,
client_id: "cid".into(),
client_secret: "csecret".into(),
redirect_uri: "https://app.example.com/login/sso/x/callback".into(),
scopes: None,
}
}
#[tokio::test]
async fn presets_build_without_network() {
for name in ["google", "microsoft", "github", "gitlab", "discord"] {
let p = build_provider(&cfg(name)).await.expect("preset builds");
assert_eq!(p.client_id, "cid");
assert_eq!(
p.redirect_uri,
"https://app.example.com/login/sso/x/callback"
);
}
}
#[tokio::test]
async fn scopes_override_is_applied() {
let p = build_provider(&cfg("google")).await.expect("builds");
assert_eq!(p.scopes, vec!["openid", "email", "profile"]);
let mut c = cfg("google");
c.scopes = Some(vec!["openid".into(), "email".into(), "groups".into()]);
let p = build_provider(&c).await.expect("builds");
assert_eq!(p.scopes, vec!["openid", "email", "groups"]);
}
#[test]
fn parse_scopes_splits_and_trims() {
assert_eq!(parse_scopes(None), None);
assert_eq!(parse_scopes(Some(" ")), None);
assert_eq!(
parse_scopes(Some("openid email profile")),
Some(vec!["openid".into(), "email".into(), "profile".into()])
);
}
#[test]
fn resolve_secret_ref_env_reads_env_or_literal() {
assert_eq!(
resolve_secret_ref_env("plain-literal").unwrap(),
"plain-literal"
);
assert!(resolve_secret_ref_env("env://PATH").is_ok());
assert!(resolve_secret_ref_env("env://RUSTANGO_TEST_UNSET_VAR_QQQ").is_err());
}
async fn issuer(fail_first: usize) -> (String, Arc<AtomicUsize>) {
let hits = Arc::new(AtomicUsize::new(0));
let h = hits.clone();
let app = axum::Router::new().route(
"/.well-known/openid-configuration",
axum::routing::get(move || async move {
if h.fetch_add(1, Ordering::SeqCst) < fail_first {
return Err(axum::http::StatusCode::BAD_GATEWAY);
}
Ok(axum::Json(serde_json::json!({
"authorization_endpoint": "https://idp.test/auth",
"token_endpoint": "https://idp.test/token",
})))
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(base, hits)
}
fn oidc(issuer: &str, client_id: &str) -> ResolvedSso {
let mut c = cfg("oidc");
c.issuer_url = Some(issuer.to_owned());
c.client_id = client_id.into();
c
}
fn loopback() -> TargetPolicy {
TargetPolicy::Public(crate::outbound::Allowlist::parse("127.0.0.1"))
}
#[tokio::test]
async fn discovery_is_cached_per_issuer() {
let (base, hits) = issuer(0).await;
let a = build_provider_with(&oidc(&base, "a"), &loopback())
.await
.unwrap();
let b = build_provider_with(&oidc(&format!("{base}/"), "b"), &loopback())
.await
.unwrap();
assert_eq!(hits.load(Ordering::SeqCst), 1);
assert_eq!((a.client_id.as_str(), b.client_id.as_str()), ("a", "b"));
assert_eq!(b.token_url, "https://idp.test/token");
assert_eq!(b.userinfo_url, None);
}
#[test]
fn a_full_cache_drops_its_oldest_issuer() {
let e = || Endpoints {
auth_url: "a".into(),
token_url: "t".into(),
userinfo_url: None,
};
let mut map = HashMap::new();
for k in ["one", "two", "three"] {
remember(&mut map, k.into(), e(), 2);
std::thread::sleep(Duration::from_millis(2));
}
let mut keys: Vec<_> = map.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(keys, ["three", "two"]);
}
#[tokio::test]
async fn failed_discovery_is_retried() {
let (base, hits) = issuer(1).await;
assert!(build_provider_with(&oidc(&base, "a"), &loopback())
.await
.is_err());
assert!(build_provider_with(&oidc(&base, "a"), &loopback())
.await
.is_ok());
assert_eq!(hits.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn unknown_provider_is_rejected() {
let e = build_provider(&cfg("myspace")).await.unwrap_err();
assert!(matches!(e, SsoError::UnknownProvider(p) if p == "myspace"));
}
#[tokio::test]
async fn oidc_without_issuer_is_rejected() {
assert!(matches!(
build_provider(&cfg("oidc")).await.unwrap_err(),
SsoError::MissingIssuer
));
}
#[tokio::test]
async fn empty_client_id_is_rejected() {
let mut c = cfg("google");
c.client_id = " ".into();
assert!(matches!(
build_provider(&c).await.unwrap_err(),
SsoError::Config(_)
));
}
#[test]
fn verified_email_requires_verified_flag() {
let mut u = NormalizedUser {
provider: "google".into(),
provider_user_id: "1".into(),
email: Some("a@example.com".into()),
email_verified: false,
name: None,
avatar_url: None,
raw: serde_json::json!({}),
};
assert!(verified_email(&u).is_err());
u.email_verified = true;
assert_eq!(verified_email(&u).unwrap(), "a@example.com");
}
}