diff --git a/.env.template b/.env.template index 0d922774..331e5671 100644 --- a/.env.template +++ b/.env.template @@ -512,6 +512,9 @@ ## Additional authorization url parameters (ex: to obtain a `refresh_token` with Google Auth). # SSO_AUTHORIZE_EXTRA_PARAMS="access_type=offline&prompt=consent" +## Use the email provided during SSO logon to indicate the account to use to the OIDC provider through the `login_hint` OIDC field. +# SSO_LOGIN_HINT=true + ## Activate PKCE for the Auth Code flow. # SSO_PKCE=true diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index c7e79aed..3f4d9ba8 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -910,16 +910,21 @@ async fn get_org_details_impl( Ok(json!(ciphers_json)) } +#[derive(Deserialize)] +struct DomainSSoVerifiedData { + email: String, +} + // Returning a Domain/Organization here allow to prefill it and prevent prompting the user -// So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere -// In use since `v2025.6.0`, appears to use only the first `organizationIdentifier` -#[post("/organizations/domain/sso/verified")] -fn get_org_domain_sso_verified() -> JsonResult { - // Always return a dummy value, no matter if SSO is enabled or not +#[post("/organizations/domain/sso/verified", data = "")] +fn get_org_domain_sso_verified(data: Json) -> JsonResult { + let data = data.into_inner(); + Ok(Json(json!({ "object": "list", "data": [{ - "organizationIdentifier": FAKE_SSO_IDENTIFIER, + // The given email is passed to the identity authorize endpoint through the org identifier + "organizationIdentifier": format!("{FAKE_SSO_IDENTIFIER}+{}", data.email), // These appear to be unused "organizationName": FAKE_SSO_IDENTIFIER, "domainName": CONFIG.domain() diff --git a/src/api/identity.rs b/src/api/identity.rs index 1597698f..d89db257 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -24,8 +24,9 @@ use crate::{ master_password_policy, push::register_push_device, }, - auth, - auth::{AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure, generate_organization_api_key_login_claims}, + auth::{ + self, AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure, generate_organization_api_key_login_claims, + }, crypto, db::{ DbConn, @@ -36,8 +37,8 @@ use crate::{ }, }, error::MapResult, - mail, sso, - sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState}, + mail, + sso::{self, FAKE_SSO_IDENTIFIER, OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState}, util, }; @@ -1277,7 +1278,6 @@ struct AuthorizeData { code_challenge_method: String, #[allow(unused)] response_mode: Option, - #[allow(unused)] domain_hint: Option, #[allow(unused)] #[field(name = uncased("ssoToken"))] @@ -1293,6 +1293,7 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure, state, code_challenge, code_challenge_method, + domain_hint, .. } = data; @@ -1305,8 +1306,14 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure, let binding_token = data_encoding::BASE64URL_NOPAD.encode(&crypto::get_random_bytes::<32>()); let binding_hash = crypto::sha256_hex(binding_token.as_bytes()); + let login_hint = domain_hint.and_then(|hint| { + let (sso_org_id, email) = hint.split_once('+')?; + (sso_org_id == FAKE_SSO_IDENTIFIER && !email.is_empty()).then(|| email.to_owned()) + }); + let auth_url = - sso::authorize_url(state, code_challenge, &client_id, &redirect_uri, Some(binding_hash), conn).await?; + sso::authorize_url(state, code_challenge, &client_id, &redirect_uri, Some(binding_hash), login_hint, conn) + .await?; cookies.add( Cookie::build((SSO_BINDING_COOKIE, binding_token)) diff --git a/src/config.rs b/src/config.rs index 49281b6c..105747a8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -815,6 +815,8 @@ make_config! { sso_scopes: String, true, def, "email profile".to_owned(); /// Authorization request extra parameters sso_authorize_extra_params: String, true, def, String::new(); + /// Use login hint in authorization request |> Use the email provided during SSO logon to indicate the account to use to the OIDC provider. + sso_login_hint: bool, true, def, true; /// Use PKCE during Authorization flow sso_pkce: bool, true, def, true; /// Regex for additional trusted Id token audience |> By default only the client_id is trusted. diff --git a/src/sso.rs b/src/sso.rs index 01fbd906..8dadec63 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -191,6 +191,7 @@ pub async fn authorize_url( client_id: &str, raw_redirect_uri: &str, binding_hash: Option, + login_hint: Option, conn: DbConn, ) -> ApiResult { let redirect_uri = match client_id { @@ -209,7 +210,8 @@ pub async fn authorize_url( _ => err!(format!("Unsupported client {client_id}")), }; - let (auth_url, sso_auth) = Client::authorize_url(state, client_challenge, redirect_uri, binding_hash).await?; + let (auth_url, sso_auth) = + Client::authorize_url(state, client_challenge, redirect_uri, binding_hash, login_hint).await?; sso_auth.save(&conn).await?; Ok(auth_url) } diff --git a/src/sso_client.rs b/src/sso_client.rs index ff39b0b0..cc4ee601 100644 --- a/src/sso_client.rs +++ b/src/sso_client.rs @@ -185,6 +185,7 @@ impl Client { client_challenge: OIDCCodeChallenge, redirect_uri: String, binding_hash: Option, + login_hint: Option, ) -> ApiResult<(Url, SsoAuth)> { let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new); let base64_state = data_encoding::BASE64.encode(state.to_string().as_bytes()); @@ -206,6 +207,12 @@ impl Client { .add_extra_param("code_challenge_method", "S256"); } + if CONFIG.sso_login_hint() + && let Some(value) = login_hint + { + auth_req = auth_req.add_extra_param("login_hint", value); + } + let (auth_url, _, nonce) = auth_req.url(); Ok((auth_url, SsoAuth::new(state, client_challenge, nonce.secret().clone(), redirect_uri, binding_hash))) }