Redirect back to the originating page after sign-in, not always /
Deploy / deploy (push) Successful in 29s

Sign in link now carries ?redirect=<question_id>; /auth/login stashes
it in the session (validated same-site-path-only to avoid an open
redirect via a crafted query param), /auth/callback reads it back for
the final redirect instead of a hardcoded "/".
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-05 14:40:44 +02:00
parent d5bbe799ce
commit 3242482c8e
2 changed files with 36 additions and 5 deletions
+31 -4
View File
@@ -40,6 +40,7 @@ pub struct Oidc {
const PKCE_KEY: &str = "oidc_pkce_verifier";
const CSRF_KEY: &str = "oidc_csrf_state";
const NONCE_KEY: &str = "oidc_nonce";
const REDIRECT_KEY: &str = "oidc_post_login_redirect";
impl Oidc {
/// Discovers the provider and builds the client from environment:
@@ -92,8 +93,31 @@ fn internal(err: impl std::fmt::Display) -> HandlerError {
)
}
/// GET /auth/login — stash PKCE/state/nonce in the session and bounce to Kanidm.
pub async fn login(State(state): State<AppState>, session: Session) -> Result<Redirect, HandlerError> {
#[derive(Deserialize)]
pub struct LoginParams {
redirect: Option<String>,
}
/// A same-site, single-segment-rooted path only - never an absolute
/// URL or scheme-relative one (`//evil.com/...` is a valid "path" to a
/// browser but resolves to an external host), since this value comes
/// straight from a query param an attacker could craft into a phishing
/// link that still passes through this app's own real login flow.
fn is_safe_redirect(path: &str) -> bool {
path.starts_with('/') && !path.starts_with("//") && !path.contains("://")
}
/// GET /auth/login — stash PKCE/state/nonce (and where to return to
/// after) in the session and bounce to Kanidm.
pub async fn login(
State(state): State<AppState>,
session: Session,
Query(params): Query<LoginParams>,
) -> Result<Redirect, HandlerError> {
if let Some(redirect) = params.redirect.filter(|r| is_safe_redirect(r)) {
session.insert(REDIRECT_KEY, redirect).await.map_err(internal)?;
}
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_state, nonce) = state
@@ -209,8 +233,11 @@ pub async fn callback(
.await
.map_err(internal)?;
tracing::info!(user = %user.username, "signed in");
Ok(Redirect::to("/"))
let redirect: Option<String> = session.remove(REDIRECT_KEY).await.map_err(internal)?;
let target = redirect.filter(|r| is_safe_redirect(r)).unwrap_or_else(|| "/".to_string());
tracing::info!(user = %user.username, redirect = %target, "signed in");
Ok(Redirect::to(&target))
}
/// GET /auth/logout — drop the session.