rustango 0.60.0

Batteries-included web framework for Rust: ORM + migrations + auto-admin + multi-tenancy + audit log + auth (sessions, JWT, OAuth2/OIDC, HMAC) + APIs (ViewSet, OpenAPI auto-derive, JSON:API) + jobs (in-mem + Postgres) + email + media (S3 / R2 / B2 / MinIO + presigned uploads + collections + tags) + production middleware (CSRF, CSP, rate-limiting, compression, idempotency, etc.).
Documentation
//! Bare-admin SSO login wiring, behind the `admin-sso` feature.
//!
//! The reusable SSO core (types, `build_provider`, `verified_email`,
//! `SSO_FLOW_COOKIE`) lives in [`crate::sso`]. This module only wires
//! it to the bare admin: it builds a
//! [`ResolvedSso`](crate::sso::ResolvedSso) from an
//! [`SsoProvider`](crate::sso::SsoProvider) row, runs the handshake,
//! signs in the [`AdminUser`] linked to the IdP subject, and mints the
//! admin session cookie.
//!
//! SSO never creates an admin account or links one by email: an admin
//! adds the [`SsoLink`](crate::sso::SsoLink) row. The refusal log names the subject.
//!
//! The re-export below keeps the older `crate::admin::sso::…` paths
//! working for callers such as [`crate::tenancy::sso`].

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};

/// Query params on the IdP callback (`?code=…&state=…` or `?error=…`).
#[derive(serde::Deserialize)]
struct CallbackParams {
    code: Option<String>,
    state: Option<String>,
    error: Option<String>,
}

/// Routes for the bare-admin SSO flow, mounted next to `/login`.
/// `GET /login/sso/{slug}` starts the handshake for one configured
/// [`SsoProvider`](super::sso_provider::SsoProvider), and
/// `.../callback` completes it.
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")
    }
}

/// Per-provider callback URL built from the request host:
/// `{scheme}://{host}{login_path}/sso/{slug}/callback`. The scheme
/// comes from `X-Forwarded-Proto` sent by a trusted proxy, else `https`.
fn derive_bare_redirect(
    headers: &HeaderMap,
    extensions: &axum::http::Extensions,
    state: &AppState,
    slug: &str,
) -> Option<String> {
    let host = headers.get(header::HOST)?.to_str().ok()?;
    let scheme = crate::real_ip::trusted_forwarded(headers, extensions, "x-forwarded-proto")
        .unwrap_or("https");
    Some(format!(
        "{scheme}://{host}{}/sso/{slug}/callback",
        login_path(state)
    ))
}

/// Redirect back to the login page with a generic `?sso_error=` marker.
/// Details are logged, never shown to the user.
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 {
        ""
    }
}

// GET /login/sso/{slug}: start the handshake for one provider.
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 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, 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
}

// GET /login/sso/{slug}/callback: finish the handshake, link the
// account, mint the session.
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");
    };
    // Recover + verify the sealed flow from its cookie.
    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, &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 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");
        }
    };
    // Sign in by (provider, sub) link only: every admin account is staff,
    // so a verified email never creates a link here.
    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");
    }

    // Mint the normal admin session, bound to the user's stored
    // password hash, exactly as a successful password login does.
    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,
        user.sessions_revoked_at,
    );
    let session_cookie = format!(
        "{SESSION_COOKIE}={cookie_value}; Path=/; HttpOnly; SameSite=Lax{s}",
        s = cookie_attrs(state.config.secure_cookies),
    );
    // Clear the transient flow cookie.
    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
}

/// The bare admin's accounts. Every one is staff, so none links by email.
struct AdminAccounts<'a>(&'a crate::sql::Pool);

impl AdminAccounts<'_> {
    /// The admin user with this id; a driver error is an error, not "missing".
    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,
            },
        )
    }
}