Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 42 additions & 1 deletion crates/core/src/oidc/flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,16 @@ pub struct ValidatedLogin {
///
/// `redirect_to` is the (already open-redirect-validated by the caller) relative
/// path to send the user to after login; it is persisted so the completing
/// instance — possibly a *different* one — can honor it.
/// instance — possibly a *different* one — can honor it. `login_hint` (already
/// validated by the caller) is the account the user means to sign in as; it
/// goes to the IdP as the authorize request's `login_hint`, so the IdP can
/// preselect that account instead of asking for it again.
pub async fn begin_login(
pool: &PgPool,
meta: &OidcMetadataClient,
client: &ClientParams,
redirect_to: &str,
login_hint: Option<&str>,
) -> Result<BeginLogin, FlowError> {
let discovery = meta.discovery().await?;

Expand Down Expand Up @@ -215,6 +219,7 @@ pub async fn begin_login(
&state,
&nonce,
&code_challenge,
login_hint,
)
.map_err(|e| FlowError::TokenResponse(format!("bad authorize endpoint: {e}")))?;

Expand Down Expand Up @@ -438,6 +443,7 @@ fn build_authorize_url(
state: &str,
nonce: &str,
code_challenge: &str,
login_hint: Option<&str>,
) -> Result<String, url::ParseError> {
let mut url = Url::parse(authorization_endpoint)?;
{
Expand All @@ -451,6 +457,9 @@ fn build_authorize_url(
q.append_pair("nonce", nonce);
q.append_pair("code_challenge", code_challenge);
q.append_pair("code_challenge_method", "S256");
if let Some(hint) = login_hint {
q.append_pair("login_hint", hint);
}
}
Ok(url.to_string())
}
Expand Down Expand Up @@ -525,6 +534,7 @@ mod tests {
"the-state",
"the-nonce",
"the-challenge",
None,
)
.unwrap();
let parsed = Url::parse(&url).unwrap();
Expand All @@ -539,6 +549,37 @@ mod tests {
assert_eq!(pairs["redirect_uri"], client.redirect_url);
// The secret is never in the authorize URL.
assert!(!url.contains("shh"));
// Without a hint, the IdP gets none.
assert!(!pairs.contains_key("login_hint"));
}

#[test]
fn authorize_url_carries_the_login_hint_encoded() {
let client = ClientParams {
client_id: "client-abc".into(),
auth: ClientAuth::Secret("shh".into()),
redirect_url: "https://forge.test/auth/oidc/callback".into(),
scopes: vec!["openid".into()],
leeway_seconds: 60,
};
let url = build_authorize_url(
"https://idp.test/authorize?tenant=t1",
&client,
"s",
"n",
"c",
Some("alice+ci@corp.example&prompt=none"),
)
.unwrap();
let parsed = Url::parse(&url).unwrap();
let pairs: Vec<(String, String)> = parsed.query_pairs().into_owned().collect();
// One parameter, encoded: it cannot add parameters of its own.
let hints: Vec<_> = pairs.iter().filter(|(k, _)| k == "login_hint").collect();
assert_eq!(hints.len(), 1, "{url}");
assert_eq!(hints[0].1, "alice+ci@corp.example&prompt=none");
assert!(!pairs.iter().any(|(k, _)| k == "prompt"), "{url}");
// The endpoint's own parameters are kept.
assert!(pairs.iter().any(|(k, v)| k == "tenant" && v == "t1"));
}

#[test]
Expand Down
14 changes: 7 additions & 7 deletions crates/core/tests/oidc_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ async fn begin_login_persists_flow_and_builds_authorize_url() {
let pool = pool().await;
let idp = MockIdp::start().await;

let begun = begin_login(&pool, &idp.meta(), &idp.client(), "/packages")
let begun = begin_login(&pool, &idp.meta(), &idp.client(), "/packages", None)
.await
.expect("begin_login");

Expand Down Expand Up @@ -233,7 +233,7 @@ async fn complete_login_happy_path_then_replay_fails() {
let n = unique();
let sub = format!("sub-{n}");

let begun = begin_login(&pool, &idp.meta(), &idp.client(), "/home")
let begun = begin_login(&pool, &idp.meta(), &idp.client(), "/home", None)
.await
.expect("begin");

Expand Down Expand Up @@ -310,7 +310,7 @@ async fn complete_login_authenticates_with_a_client_assertion_instead_of_a_secre
..idp.client()
};

let begun = begin_login(&pool, &idp.meta(), &client, "/")
let begun = begin_login(&pool, &idp.meta(), &client, "/", None)
.await
.expect("begin");
let nonce = peek_nonce(&pool, &begun.state).await;
Expand Down Expand Up @@ -365,7 +365,7 @@ async fn complete_login_fails_without_calling_the_idp_when_the_assertion_cannot_
auth: ClientAuth::Assertion(std::sync::Arc::new(FixedAssertion(Err("no keys")))),
..idp.client()
};
let begun = begin_login(&pool, &idp.meta(), &client, "/")
let begun = begin_login(&pool, &idp.meta(), &client, "/", None)
.await
.expect("begin");

Expand Down Expand Up @@ -395,7 +395,7 @@ async fn complete_login_rejects_nonce_mismatch() {
let idp = MockIdp::start().await;
let n = unique();

let begun = begin_login(&pool, &idp.meta(), &idp.client(), "/")
let begun = begin_login(&pool, &idp.meta(), &idp.client(), "/", None)
.await
.expect("begin");

Expand Down Expand Up @@ -451,7 +451,7 @@ async fn complete_login_rejects_other_browser_without_consuming_flow() {
let pool = pool().await;
let idp = MockIdp::start().await;
let n = unique();
let begun = begin_login(&pool, &idp.meta(), &idp.client(), "/")
let begun = begin_login(&pool, &idp.meta(), &idp.client(), "/", None)
.await
.expect("begin");

Expand Down Expand Up @@ -1156,7 +1156,7 @@ async fn run_login(
username: &str,
roles: Value,
) -> oidc::ValidatedLogin {
let begun = begin_login(pool, &idp.meta(), &idp.client(), "/")
let begun = begin_login(pool, &idp.meta(), &idp.client(), "/", None)
.await
.expect("begin");
let nonce = peek_nonce(pool, &begun.state).await;
Expand Down
12 changes: 12 additions & 0 deletions crates/db/src/external_identity_dao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ pub async fn user_has_identity(pool: &PgPool, user_id: i64) -> sqlx::Result<bool
Ok(linked)
}

/// The display name the IdP sent for identity `id` at its last sign-in, if
/// the identity exists and the IdP sent one.
pub async fn display_name(pool: &PgPool, id: i64) -> sqlx::Result<Option<String>> {
let name = sqlx::query_scalar!(
"SELECT display_name FROM external_identity WHERE id = $1",
id,
)
.fetch_optional(pool)
.await?;
Ok(name.flatten())
}

/// Find the identity row for `(issuer, subject)`, if any.
pub async fn find_by_issuer_subject(
pool: &PgPool,
Expand Down
106 changes: 94 additions & 12 deletions crates/server/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
//! * `POST /auth/identify` — identifier-first routing,
//! decided ONLY by public realm config (anti-enumeration).
//! * `GET /auth/login-options` — what the login screen should render.
//! * `GET /auth/oidc/login` — start the auth-code+PKCE flow (302 → IdP);
//! 404 when no enabled oidc realm exists.
//! * `GET /auth/oidc/login` — start the auth-code+PKCE flow (302 → IdP,
//! with the optional `login_hint` the login screen sends); 404 when no
//! enabled oidc realm exists.
//! * `GET /auth/oidc/callback` — complete it, set the session cookie, 302 to a
//! validated relative target.
//! * `POST /auth/logout` — revoke the session + clear the cookie (CSRF
Expand Down Expand Up @@ -55,7 +56,7 @@ use forge_core::oidc::{
use forge_core::session::{csrf_token_matches, IssuedSession, SessionService};
use forge_db::model::{AppUser, Session};
use forge_db::token_dao::{self, NewToken};
use forge_db::user_dao;
use forge_db::{external_identity_dao, user_dao};
use serde::{Deserialize, Serialize};

use crate::config::OidcConfig;
Expand Down Expand Up @@ -177,6 +178,29 @@ pub struct LoginQuery {
/// Optional post-login relative path; sanitized to defeat open redirects.
#[serde(default)]
pub redirect_to: Option<String>,
/// Optional account the user means to sign in as (the identifier typed
/// on the login screen, or a remembered account's): passed to the IdP as
/// `login_hint` once [`sanitize_login_hint`] accepts it.
#[serde(default)]
pub login_hint: Option<String>,
}

/// Longest accepted `login_hint` (bytes): an email address or UPN fits many
/// times over.
pub const MAX_LOGIN_HINT_BYTES: usize = 256;

/// The `login_hint` to pass to the IdP: `raw` trimmed, when it is not empty,
/// at most [`MAX_LOGIN_HINT_BYTES`] and free of control characters. Anything
/// else is dropped rather than refused: the hint only saves the user typing
/// their account at the IdP again, and the sign-in works without it.
fn sanitize_login_hint(raw: Option<&str>) -> Option<&str> {
let hint = raw.map(str::trim).filter(|h| !h.is_empty())?;
if hint.len() > MAX_LOGIN_HINT_BYTES || hint.chars().any(char::is_control) {
// It is an email address: never above debug, and only its size.
tracing::debug!(bytes = hint.len(), "oidc login_hint dropped");
return None;
}
Some(hint)
}

/// Build the `/auth` router. Always mounted: sessions exist for local
Expand Down Expand Up @@ -250,8 +274,9 @@ async fn oidc_login(
};
let rt = &orr.runtime;
let redirect_to = sanitize_redirect_to(q.redirect_to.as_deref());
let login_hint = sanitize_login_hint(q.login_hint.as_deref());

match begin_login(&state.pool, &rt.meta, &rt.client, &redirect_to).await {
match begin_login(&state.pool, &rt.meta, &rt.client, &redirect_to, login_hint).await {
// Bind the flow to this browser: the callback must present the cookie.
Ok(begun) => (
StatusCode::FOUND,
Expand Down Expand Up @@ -448,8 +473,26 @@ async fn oidc_callback_inner(
forge_core::authz::invalidate_user(&state.cache, user.id).await;
}

// Link the session to the identity that signed in (`/auth/me` reports its
// display name). Display only, so a failed lookup leaves it unlinked.
let identity_id = external_identity_dao::find_by_issuer_subject(
&state.pool,
&validated.issuer,
&validated.subject,
)
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "oidc identity lookup failed");
None
})
.map(|identity| identity.id);

// Create a fresh session (login always mints a new row — fixation-proof).
let issued = match rt.sessions.create_for_user(user.id, None, None).await {
let issued = match rt
.sessions
.create_for_user(user.id, identity_id, None)
.await
{
Ok(s) => s,
Err(e) => {
tracing::error!(error = %e, "session creation failed");
Expand Down Expand Up @@ -662,6 +705,10 @@ async fn require_session_csrf(
struct AuthMe {
user_id: i64,
username: String,
/// The name the identity provider gave for the user, for a session an
/// OIDC sign-in opened (`null` otherwise, and when it sent none). Display
/// only: the login screen's remembered accounts show it.
display_name: Option<String>,
is_admin: bool,
/// Per-session double-submit CSRF secret (echo it in `X-CSRF-Token`).
csrf_token: String,
Expand All @@ -687,9 +734,20 @@ async fn me(State(state): State<AppState>, headers: HeaderMap) -> Response {
method: forge_core::auth::AuthMethod::Session,
})
.await;
let display_name = match session.external_identity_id {
Some(id) => external_identity_dao::display_name(&state.pool, id)
.await
.unwrap_or_else(|e| {
// Display only: answer without it rather than fail the session.
tracing::warn!(error = %e, "display name lookup failed");
None
}),
None => None,
};
Json(AuthMe {
user_id: u.id,
username: u.username,
display_name,
is_admin,
csrf_token: session.csrf_secret,
})
Expand Down Expand Up @@ -1147,6 +1205,7 @@ async fn password_login(
Json(AuthMe {
user_id: principal.user_id,
username,
display_name: None,
is_admin: state
.authz
.is_global_admin(&forge_core::auth::Principal::from_user(principal))
Expand Down Expand Up @@ -1412,6 +1471,10 @@ struct LoginOptions {
password: bool,
/// Enabled OIDC realm buttons (at most one).
oidc: Vec<OidcLoginOption>,
/// Whether the login screen remembers the accounts signed in on the
/// device and offers them (the `remember_accounts` auth setting). The
/// accounts are kept in the browser only; the server never lists any.
remember_accounts: bool,
}

/// `GET /auth/login-options` — public, unauthenticated: the login screen asks
Expand All @@ -1432,16 +1495,12 @@ async fn login_options(State(state): State<AppState>) -> Response {
}]
})
.unwrap_or_default();
let settings = &state.auth_settings.current().await.settings;
Json(LoginOptions {
mode: state
.auth_settings
.current()
.await
.settings
.login_mode
.as_str(),
mode: settings.login_mode.as_str(),
password: snap.chain.is_some(),
oidc,
remember_accounts: settings.remember_accounts,
})
.into_response()
}
Expand Down Expand Up @@ -1588,6 +1647,29 @@ mod tests {
assert!(clear.contains("Path=/;"));
}

#[test]
fn login_hint_is_bounded_and_printable() {
assert_eq!(sanitize_login_hint(None), None);
assert_eq!(sanitize_login_hint(Some("")), None);
assert_eq!(sanitize_login_hint(Some(" ")), None);
assert_eq!(
sanitize_login_hint(Some(" alice@corp.example ")),
Some("alice@corp.example")
);
// Not ASCII-only: a UPN may hold any printable character.
assert_eq!(
sanitize_login_hint(Some("jürgen@bücher.example")),
Some("jürgen@bücher.example")
);
for bad in ["alice\n@corp.example", "alice\u{0}", "a\tb", "\u{7f}x"] {
assert_eq!(sanitize_login_hint(Some(bad)), None, "{bad:?}");
}
let fits = "a".repeat(MAX_LOGIN_HINT_BYTES);
assert_eq!(sanitize_login_hint(Some(&fits)), Some(fits.as_str()));
let long = "a".repeat(MAX_LOGIN_HINT_BYTES + 1);
assert_eq!(sanitize_login_hint(Some(&long)), None);
}

#[test]
fn clear_cookie_expires_immediately() {
let v = clear_cookie_value(true);
Expand Down
Loading
Loading