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::sso::link::{Account, AccountLookup, EmailLookup};
#[derive(serde::Deserialize)]
struct CallbackParams {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
pub(crate) fn sso_router(state: AppState) -> Router {
Router::new()
.route("/login/sso/{slug}", get(sso_begin))
.route("/login/sso/{slug}/callback", get(sso_callback))
.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, state: &AppState, slug: &str) -> Option<String> {
let host = headers.get(header::HOST)?.to_str().ok()?;
let scheme = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or(s).trim())
.filter(|s| !s.is_empty())
.unwrap_or("https");
Some(format!(
"{scheme}://{host}{}/sso/{slug}/callback",
login_path(state)
))
}
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,
) -> Response {
let Some(secret) = state.config.session_secret.as_ref() else {
return login_error(&state, "disabled");
};
let Some(redirect_uri) = derive_bare_redirect(&headers, &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, secret.key());
let cookie = format!(
"{SSO_FLOW_COOKIE}={sealed}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600{s}",
s = cookie_attrs(state.config.secure_cookies),
);
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,
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) = crate::cookies::cookie_from_headers(&headers, SSO_FLOW_COOKIE) else {
return login_error(&state, "expired");
};
let flow = match open_flow(sealed, secret.key()) {
Ok(f) => f,
Err(_) => return login_error(&state, "expired"),
};
let Some(redirect_uri) = derive_bare_redirect(&headers, &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 normalized = match provider.complete(&flow, &code, &cb_state).await {
Ok((u, _tokens)) => u,
Err(e) => {
tracing::warn!(target: "rustango::admin::sso", "handshake: {e}");
return login_error(&state, "handshake");
}
};
let key = cfg.key(LinkSource::Admin);
let accounts = AdminAccounts(&state.pool);
let uid =
match crate::sso::link::sign_in(&state.pool, &key, false, &normalized, &accounts).await {
Ok(uid) => uid,
Err(e) => {
tracing::warn!(
target: "rustango::admin::sso",
slug,
provider_id = cfg.id,
issuer = key.issuer(),
subject = %normalized.provider_user_id,
"sso refused: {e}"
);
return login_error(&state, "nouser");
}
};
let Ok(Some(user)) = accounts.user(uid).await else {
return login_error(&state, "nouser");
};
if !user.active {
return login_error(&state, "inactive");
}
let auth_hash = crate::session::PasswordFingerprint::of(secret, &user.password_hash);
let cookie_value = session::encode(
secret,
AdminSession {
user_id: uid,
username: user.username,
is_superuser: user.is_superuser,
},
&auth_hash,
);
let session_cookie = format!(
"{SESSION_COOKIE}={cookie_value}; Path=/; HttpOnly; SameSite=Lax{s}",
s = cookie_attrs(state.config.secure_cookies),
);
let clear_flow = format!("{SSO_FLOW_COOKIE}=; Path=/; HttpOnly; Max-Age=0");
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);
}
if let Ok(v) = HeaderValue::from_str(&clear_flow) {
resp.headers_mut().append(header::SET_COOKIE, v);
}
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))
}
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,
},
)
}
}