pub use crate::sso::*;
use axum::{
extract::{Path, Query, State},
http::{header, HeaderMap, HeaderValue},
response::{IntoResponse, Redirect, Response},
routing::get,
Router,
};
use super::session::{self, AdminSession, SESSION_COOKIE};
use super::urls::AppState;
use super::user::AdminUser;
use crate::signals::auth::{
meta_from_parts, send_user_logged_in, AuthFailureReason, AuthRequestMeta, UserLoggedInContext,
};
use crate::sso::link::{signal_refused, Account, AccountLookup, EmailLookup};
const SIGNAL_SOURCE: &str = "admin_sso";
#[derive(serde::Deserialize)]
struct CallbackParams {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
pub(crate) fn sso_router(state: AppState) -> Router {
let router = Router::new()
.route("/login/sso/{slug}", get(sso_begin))
.route("/login/sso/{slug}/callback", get(sso_callback));
#[cfg(feature = "totp")]
let router = router.route(
"/login/sso-totp",
axum::routing::post(second_factor::submit),
);
router.with_state(state)
}
fn login_path(state: &AppState) -> String {
let p = &state.config.admin_prefix;
if p.is_empty() {
"/login".to_owned()
} else {
format!("{p}/login")
}
}
fn derive_bare_redirect(
headers: &HeaderMap,
extensions: &axum::http::Extensions,
state: &AppState,
slug: &str,
) -> Option<String> {
redirect_for(
headers,
extensions,
&login_path(state),
state.config.allowed_hosts.as_ref(),
slug,
)
}
fn redirect_for(
headers: &HeaderMap,
extensions: &axum::http::Extensions,
login_path: &str,
allowed: Option<&crate::host_validation::AllowedHostsLayer>,
slug: &str,
) -> Option<String> {
let host = crate::urls::HostAuthority::parse(headers.get(header::HOST)?.to_str().ok()?)?;
if allowed.is_some_and(|a| !a.permits(&host.to_string())) {
tracing::warn!(target: "rustango::admin::sso", %host, "SSO refused: the Host is not an allowed host");
return None;
}
let scheme = crate::real_ip::trusted_forwarded(headers, extensions, "x-forwarded-proto")
.unwrap_or("https");
Some(format!("{scheme}://{host}{login_path}/sso/{slug}/callback"))
}
fn login_error(state: &AppState, code: &str) -> Response {
Redirect::to(&format!("{}?sso_error={code}", login_path(state))).into_response()
}
fn cookie_attrs(secure: bool) -> &'static str {
if secure {
"; Secure"
} else {
""
}
}
async fn sso_begin(
State(state): State<AppState>,
Path(slug): Path<String>,
headers: HeaderMap,
extensions: axum::http::Extensions,
) -> Response {
let Some(secret) = state.config.session_secret.as_ref() else {
return login_error(&state, "disabled");
};
let scope = crate::sso::policy::throttle_scope(state.config.tenant.as_deref());
if let Err(refused) = crate::sso::policy::throttle_check(&scope, &extensions, &headers).await {
return refused;
}
let Some(redirect_uri) = derive_bare_redirect(&headers, &extensions, &state, &slug) else {
return login_error(&state, "config");
};
let cfg = match super::sso_provider::resolve_by_slug(&state.pool, &slug, redirect_uri).await {
Ok(Some(c)) => c,
Ok(None) => return login_error(&state, "disabled"),
Err(e) => {
tracing::error!(target: "rustango::admin::sso", "begin resolve: {e}");
return login_error(&state, "config");
}
};
let provider = match build_provider(&cfg.sso).await {
Ok(p) => p,
Err(e) => {
tracing::error!(target: "rustango::admin::sso", "begin: {e}");
return login_error(&state, "config");
}
};
let (url, flow) = provider.begin();
let sealed = seal_flow(
&flow,
FlowScope::new(FlowPurpose::Admin, "", &slug),
secret.key(),
);
let cookie = flow_cookie(&state).set(&sealed, 600);
let mut resp = Redirect::to(&url).into_response();
if let Ok(v) = HeaderValue::from_str(&cookie) {
resp.headers_mut().insert(header::SET_COOKIE, v);
}
resp
}
async fn sso_callback(
State(state): State<AppState>,
Path(slug): Path<String>,
headers: HeaderMap,
extensions: axum::http::Extensions,
Query(params): Query<CallbackParams>,
) -> Response {
let Some(secret) = state.config.session_secret.as_ref() else {
return login_error(&state, "disabled");
};
if params.error.is_some() {
return login_error(&state, "denied");
}
let (Some(code), Some(cb_state)) = (params.code, params.state) else {
return login_error(&state, "callback");
};
let Some(sealed) = flow_cookie(&state).read(&headers) else {
return login_error(&state, "expired");
};
let flow = match open_flow(
sealed,
FlowScope::new(FlowPurpose::Admin, "", &slug),
secret.key(),
) {
Ok(f) => f,
Err(_) => return login_error(&state, "expired"),
};
let scope = crate::sso::policy::throttle_scope(state.config.tenant.as_deref());
let attempt = match crate::sso::policy::throttle_begin(&scope, &extensions, &headers).await {
Ok(a) => a,
Err(refused) => return refused,
};
if !crate::sso::claim_flow(&flow) {
return login_error(&state, "expired");
}
let Some(redirect_uri) = derive_bare_redirect(&headers, &extensions, &state, &slug) else {
return login_error(&state, "config");
};
let cfg = match super::sso_provider::resolve_by_slug(&state.pool, &slug, redirect_uri).await {
Ok(Some(c)) => c,
Ok(None) => return login_error(&state, "disabled"),
Err(e) => {
tracing::error!(target: "rustango::admin::sso", "callback resolve: {e}");
return login_error(&state, "config");
}
};
let provider = match build_provider(&cfg.sso).await {
Ok(p) => p,
Err(e) => {
tracing::error!(target: "rustango::admin::sso", "callback build: {e}");
return login_error(&state, "config");
}
};
let identity = match provider.complete(&flow, &code, &cb_state).await {
Ok((u, tokens)) => {
crate::sso::policy::Identity::from_handshake(&provider, u, &tokens, &flow).await
}
Err(e) => {
tracing::warn!(target: "rustango::admin::sso", "handshake: {e}");
attempt.failed().await;
return login_error(&state, "handshake");
}
};
let normalized = &identity.profile;
let key = cfg.key(LinkSource::Admin);
let accounts = AdminAccounts(&state.pool);
let path = format!("/login/sso/{slug}");
let meta = || meta_from_parts(&extensions, &headers, Some(&path));
let policy = crate::sso::policy::EffectivePolicy::new(cfg.policy.clone(), false)
.with_lifecycle(cfg.lifecycle.clone());
let request = crate::sso::policy::AdmitRequest::new(
&state.pool,
crate::sso::policy::Surface::BareAdmin,
&key,
&policy,
&identity,
)
.provider(&slug);
let uid = match crate::sso::policy::admit(request, &accounts).await {
Ok(a) => a.user_id,
Err(crate::sso::policy::Denied::Refused(r)) => {
let ctx = crate::sso::policy::RefusalContext {
pool: &state.pool,
surface: crate::sso::policy::Surface::BareAdmin,
signal_source: SIGNAL_SOURCE,
provider: &slug,
key: &key,
identity: &identity,
tenant: state.config.tenant.clone(),
extensions: &extensions,
headers: &headers,
path: &path,
};
crate::sso::policy::refuse(&ctx, &r).await;
attempt.failed().await;
return login_error(&state, "nouser");
}
Err(e) => {
tracing::error!(target: "rustango::admin::sso", slug, "sso sign-in failed: {e}");
return login_error(&state, "nouser");
}
};
let Ok(Some(user)) = accounts.user(uid).await else {
return login_error(&state, "nouser");
};
if !user.active {
signal_refused(
SIGNAL_SOURCE,
normalized,
AuthFailureReason::Inactive,
meta(),
state.config.tenant.clone(),
)
.await;
return login_error(&state, "nouser");
}
attempt.succeeded().await;
let ttl = policy.session_ttl(session::DEFAULT_TTL_SECS, user.is_superuser);
#[cfg(feature = "totp")]
match super::totp_store::confirmed_secret_checked(&state.pool, uid).await {
Ok(None) => {}
Ok(Some(_)) => {
return second_factor::prompt(&state, &extensions, &headers, secret, &user, ttl).await
}
Err(e) => {
tracing::error!(target: "rustango::admin::sso", user_id = uid, error = %e, "cannot read the TOTP device");
return login_error(&state, "config");
}
}
mint_session(&state, secret, &user, meta(), ttl).await
}
async fn mint_session(
state: &AppState,
secret: &session::AdminSessionSecret,
user: &AdminUser,
request: AuthRequestMeta,
ttl_secs: i64,
) -> Response {
let uid = user.id.get().copied().unwrap_or_default();
let auth_hash = crate::session::PasswordFingerprint::of(secret, &user.password_hash);
let cookie_value = session::encode_for(
secret,
AdminSession::new(uid, user.username.clone(), user.is_superuser),
&auth_hash,
user.sessions_revoked_at,
ttl_secs,
);
let session_cookie = format!(
"{SESSION_COOKIE}={cookie_value}; Path=/; HttpOnly; SameSite=Lax{s}",
s = cookie_attrs(state.config.secure_cookies),
);
let redirect_to = if state.config.admin_prefix.is_empty() {
"/".to_owned()
} else {
state.config.admin_prefix.clone()
};
let mut resp = Redirect::to(&redirect_to).into_response();
if let Ok(v) = HeaderValue::from_str(&session_cookie) {
resp.headers_mut().append(header::SET_COOKIE, v);
}
clear_flow(&mut resp, state);
send_user_logged_in(UserLoggedInContext {
source: SIGNAL_SOURCE,
user_id: uid,
username: user.username.clone(),
is_superuser: user.is_superuser,
request,
tenant: state.config.tenant.clone(),
})
.await;
resp
}
fn flow_cookie(state: &AppState) -> crate::sso::FlowCookie<'static> {
crate::sso::FlowCookie::new(SSO_FLOW_COOKIE, "/", state.config.secure_cookies)
}
fn clear_flow(resp: &mut Response, state: &AppState) {
for c in flow_cookie(state).clear() {
if let Ok(v) = HeaderValue::from_str(&c) {
resp.headers_mut().append(header::SET_COOKIE, v);
}
}
}
#[cfg(feature = "totp")]
fn clear_cookie(resp: &mut Response, name: &str) {
if let Ok(v) = HeaderValue::from_str(&format!("{name}=; Path=/; HttpOnly; Max-Age=0")) {
resp.headers_mut().append(header::SET_COOKIE, v);
}
}
#[cfg(feature = "totp")]
mod second_factor {
use super::*;
use crate::session::PendingSecondFactor as Pending;
const PENDING_COOKIE: &str = "rustango_admin_sso_totp";
const TAG: &[u8] = b"rustango-admin-sso-totp-v1";
const TTL_SECS: i64 = Pending::TTL_SECS;
fn seal(secret: &session::AdminSessionSecret, p: &Pending) -> String {
p.seal(secret, TAG)
}
fn open(secret: &session::AdminSessionSecret, value: &str) -> Option<Pending> {
Pending::open(secret, TAG, value)
}
pub(super) async fn prompt(
state: &AppState,
extensions: &axum::http::Extensions,
headers: &HeaderMap,
secret: &session::AdminSessionSecret,
user: &AdminUser,
session_ttl: i64,
) -> Response {
let mut pending = Pending::new(
secret,
user.id.get().copied().unwrap_or_default(),
&user.password_hash,
);
pending.session_ttl = Some(session_ttl);
let cookie = format!(
"{PENDING_COOKIE}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age={TTL_SECS}{s}",
seal(secret, &pending),
s = cookie_attrs(state.config.secure_cookies),
);
let mut resp =
crate::admin::login_view::sso_totp_response(state, extensions, headers, None).await;
if let Ok(v) = HeaderValue::from_str(&cookie) {
resp.headers_mut().append(header::SET_COOKIE, v);
}
clear_flow(&mut resp, state);
resp
}
#[derive(serde::Deserialize)]
pub(super) struct CodeInput {
#[serde(rename = "_csrf", default)]
csrf_token: Option<String>,
#[serde(default)]
totp_code: Option<String>,
}
pub(super) async fn submit(
State(state): State<AppState>,
ip: crate::login_throttle::ClientIp,
extensions: axum::http::Extensions,
headers: HeaderMap,
axum::Form(form): axum::Form<CodeInput>,
) -> Response {
use crate::login_throttle::LoginScope;
let Some(secret) = state.config.session_secret.as_ref() else {
return login_error(&state, "disabled");
};
if !crate::forms::csrf::verify_form_token(&headers, form.csrf_token.as_deref()) {
return crate::admin::login_view::sso_totp_response(
&state,
&extensions,
&headers,
Some("Your session expired or the form was invalid. Please try again."),
)
.await;
}
let Some(pending) = crate::cookies::cookie_from_headers(&headers, PENDING_COOKIE)
.and_then(|v| open(secret, v))
else {
return login_error(&state, "expired");
};
let Ok(Some(user)) = AdminAccounts(&state.pool).user(pending.uid).await else {
return login_error(&state, "nouser");
};
if !user.active || !pending.pwf.matches(secret, &user.password_hash) {
return login_error(&state, "expired");
}
let attempt = match crate::login_throttle::shared()
.begin(&LoginScope::Admin, &ip, &user.username)
.await
{
Ok(a) => a,
Err(refused) => return refused.into_response(),
};
let device = match super::super::totp_store::confirmed_secret_checked(
&state.pool,
pending.uid,
)
.await
{
Ok(Some(device)) => device,
Ok(None) | Err(_) => {
attempt.prompted().await;
return login_error(&state, "expired");
}
};
let code = form.totp_code.as_deref().unwrap_or("").trim();
let accepted = !code.is_empty()
&& super::super::totp_store::redeem_code(&state.pool, pending.uid, &device, code)
.await
.unwrap_or(false);
if !accepted {
if code.is_empty() {
attempt.prompted().await;
} else {
attempt.failed().await;
}
use crate::signals::auth::{send_user_login_failed, AuthFailureReason};
send_user_login_failed(crate::signals::auth::UserLoginFailedContext {
source: SIGNAL_SOURCE,
attempted_username: Some(user.username.clone()),
reason: AuthFailureReason::InvalidCredentials,
request: crate::signals::auth::meta_from_parts(
&extensions,
&headers,
Some("/login/sso-totp"),
),
tenant: state.config.tenant.clone(),
})
.await;
return crate::admin::login_view::sso_totp_response(
&state,
&extensions,
&headers,
Some("Enter the 6-digit code from your authenticator app."),
)
.await;
}
attempt.succeeded().await;
let meta = meta_from_parts(&extensions, &headers, Some("/login/sso-totp"));
let ttl = pending.session_ttl.map_or(session::DEFAULT_TTL_SECS, |t| {
t.min(session::DEFAULT_TTL_SECS)
});
let mut resp = mint_session(&state, secret, &user, meta, ttl).await;
clear_cookie(&mut resp, PENDING_COOKIE);
resp
}
}
struct AdminAccounts<'a>(&'a crate::sql::Pool);
impl AdminAccounts<'_> {
async fn user(&self, id: i64) -> Result<Option<AdminUser>, String> {
use crate::sql::FetcherPool as _;
Ok(AdminUser::objects()
.filter("id", id)
.fetch(self.0)
.await
.map_err(|e| e.to_string())?
.into_iter()
.next())
}
}
fn admin_account(u: &AdminUser) -> Option<Account> {
Some(
Account::new(u.id.get().copied()?, true, u.active)
.with_superuser(u.is_superuser)
.with_created_at(u.created_at),
)
}
impl AccountLookup for AdminAccounts<'_> {
async fn by_id(&self, id: i64) -> Result<Option<Account>, String> {
Ok(self.user(id).await?.as_ref().and_then(admin_account))
}
async fn by_email(&self, email: &str) -> Result<EmailLookup, String> {
use crate::sql::FetcherPool as _;
let rows = AdminUser::objects()
.filter("email__iexact", email.to_owned())
.fetch(self.0)
.await
.map_err(|e| e.to_string())?;
Ok(
match EmailLookup::pick(&rows, email, |u| u.email.as_deref()) {
Ok(Some(u)) => admin_account(u).map_or(EmailLookup::Missing, EmailLookup::Found),
Ok(None) => EmailLookup::Missing,
Err(()) => EmailLookup::Collides,
},
)
}
}
#[cfg(test)]
mod redirect_tests {
#[test]
fn the_redirect_uri_needs_an_allowed_host() {
let headers = |host: &str| {
let mut h = axum::http::HeaderMap::new();
h.insert(axum::http::header::HOST, host.parse().unwrap());
h
};
let ext = axum::http::Extensions::new();
let allowed = crate::host_validation::AllowedHostsLayer::new(["admin.example.com"]);
let go = |host: &str, a| super::redirect_for(&headers(host), &ext, "/login", a, "g");
assert_eq!(
go("admin.example.com:8443", Some(&allowed)).as_deref(),
Some("https://admin.example.com:8443/login/sso/g/callback")
);
assert_eq!(go("evil.example", Some(&allowed)), None);
assert!(go("evil.example", None).is_some());
}
}
#[cfg(all(test, feature = "testkit"))]
mod tests {
use super::AdminUser;
#[test]
fn admin_account_carries_created_at() {
let at = chrono::Utc::now() - chrono::TimeDelta::days(3);
let u = AdminUser {
id: crate::sql::Auto::Set(7),
created_at: at,
..crate::testkit::admin_user()
};
assert_eq!(super::admin_account(&u).unwrap().created_at, Some(at));
}
}