Browse Source

feat(sso): implement login hint feature

pull/7461/head
Raphaël Roumezin 3 weeks ago
parent
commit
8778a6f934
  1. 3
      .env.template
  2. 17
      src/api/core/organizations.rs
  3. 19
      src/api/identity.rs
  4. 2
      src/config.rs
  5. 4
      src/sso.rs
  6. 7
      src/sso_client.rs

3
.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

17
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 = "<data>")]
fn get_org_domain_sso_verified(data: Json<DomainSSoVerifiedData>) -> 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()

19
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<String>,
#[allow(unused)]
domain_hint: Option<String>,
#[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))

2
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.

4
src/sso.rs

@ -191,6 +191,7 @@ pub async fn authorize_url(
client_id: &str,
raw_redirect_uri: &str,
binding_hash: Option<String>,
login_hint: Option<String>,
conn: DbConn,
) -> ApiResult<Url> {
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)
}

7
src/sso_client.rs

@ -185,6 +185,7 @@ impl Client {
client_challenge: OIDCCodeChallenge,
redirect_uri: String,
binding_hash: Option<String>,
login_hint: Option<String>,
) -> 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)))
}

Loading…
Cancel
Save