From 1a3c4602992ebc484849b988ff21275a6a1f788d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABlle=20Huisman?= Date: Thu, 20 Nov 2025 10:11:02 +0100 Subject: [PATCH] feat(user-management): add authenticate with organization selection --- src/user_management/operations.rs | 2 + .../authenticate_with_email_verification.rs | 2 +- ...uthenticate_with_organization_selection.rs | 331 ++++++++++++++++++ .../operations/authenticate_with_totp.rs | 2 +- 4 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 src/user_management/operations/authenticate_with_organization_selection.rs diff --git a/src/user_management/operations.rs b/src/user_management/operations.rs index 4366375..17013ec 100644 --- a/src/user_management/operations.rs +++ b/src/user_management/operations.rs @@ -3,6 +3,7 @@ mod authenticate_with_code; mod authenticate_with_device_code; mod authenticate_with_email_verification; mod authenticate_with_magic_auth; +mod authenticate_with_organization_selection; mod authenticate_with_password; mod authenticate_with_refresh_token; mod authenticate_with_totp; @@ -46,6 +47,7 @@ pub use authenticate_with_code::*; pub use authenticate_with_device_code::*; pub use authenticate_with_email_verification::*; pub use authenticate_with_magic_auth::*; +pub use authenticate_with_organization_selection::*; pub use authenticate_with_password::*; pub use authenticate_with_refresh_token::*; pub use authenticate_with_totp::*; diff --git a/src/user_management/operations/authenticate_with_email_verification.rs b/src/user_management/operations/authenticate_with_email_verification.rs index 1598ea5..e2e393a 100644 --- a/src/user_management/operations/authenticate_with_email_verification.rs +++ b/src/user_management/operations/authenticate_with_email_verification.rs @@ -315,7 +315,7 @@ mod test { "The code '123456' has expired or is invalid." ); } else { - panic!("expected authenticate_with_magic_auth to return an error") + panic!("expected authenticate_with_email_verification to return an error") } } } diff --git a/src/user_management/operations/authenticate_with_organization_selection.rs b/src/user_management/operations/authenticate_with_organization_selection.rs new file mode 100644 index 0000000..47a432b --- /dev/null +++ b/src/user_management/operations/authenticate_with_organization_selection.rs @@ -0,0 +1,331 @@ +use std::net::IpAddr; + +use async_trait::async_trait; +use serde::Serialize; + +use crate::organizations::OrganizationId; +use crate::sso::ClientId; +use crate::user_management::{ + AuthenticateError, AuthenticationResponse, HandleAuthenticateError, PendingAuthenticationToken, + UserManagement, +}; +use crate::{ApiKey, WorkOsResult}; + +/// The parameters for [`AuthenticateWithOrganizationSelection`]. +#[derive(Debug, Serialize)] +pub struct AuthenticateWithOrganizationSelectionParams<'a> { + /// Identifies the application making the request to the WorkOS server. + pub client_id: &'a ClientId, + + /// The authentication token returned from a failed authentication attempt due to the corresponding error. + pub pending_authentication_token: &'a PendingAuthenticationToken, + + /// The organization the user selected to sign in to. + pub organization_id: &'a OrganizationId, + + /// The IP address of the request from the user who is attempting to authenticate. + pub ip_address: Option<&'a IpAddr>, + + /// The user agent of the request from the user who is attempting to authenticate. + pub user_agent: Option<&'a str>, +} + +#[derive(Serialize)] +struct AuthenticateWithOrganizationSelectionBody<'a> { + /// Authenticates the application making the request to the WorkOS server. + client_secret: &'a ApiKey, + + /// A string constant that distinguishes the method by which your application will receive an access token. + grant_type: &'a str, + + #[serde(flatten)] + params: &'a AuthenticateWithOrganizationSelectionParams<'a>, +} + +/// [WorkOS Docs: Authenticate with organization selection](https://workos.com/docs/reference/user-management/authentication/organization-selection) +#[async_trait] +pub trait AuthenticateWithOrganizationSelection { + /// Authenticates a user into an organization they are a member of. + /// + /// [WorkOS Docs: Authenticate with organization selection](https://workos.com/docs/reference/user-management/authentication/organization-selection) + /// + /// # Examples + /// + /// ``` + /// # use std::{net::IpAddr, str::FromStr}; + /// + /// # use workos::WorkOsResult; + /// # use workos::organizations::OrganizationId; + /// # use workos::sso::ClientId; + /// # use workos::user_management::*; + /// use workos::{ApiKey, WorkOs}; + /// + /// # async fn run() -> WorkOsResult<(), AuthenticateError> { + /// let workos = WorkOs::new(&ApiKey::from("sk_example_123456789")); + /// + /// let AuthenticationResponse { user, .. } = workos + /// .user_management() + /// .authenticate_with_organization_selection(&AuthenticateWithOrganizationSelectionParams { + /// client_id: &ClientId::from("client_123456789"), + /// pending_authentication_token: &PendingAuthenticationToken::from("ql1AJgNoLN1tb9llaQ8jyC2dn"), + /// organization_id: &OrganizationId::from("org_01H93Z2SYX1D3NJ536M94T8SHP"), + /// ip_address: Some(&IpAddr::from_str("192.0.2.1")?), + /// user_agent: Some("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"), + /// }) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + async fn authenticate_with_organization_selection( + &self, + params: &AuthenticateWithOrganizationSelectionParams<'_>, + ) -> WorkOsResult; +} + +#[async_trait] +impl AuthenticateWithOrganizationSelection for UserManagement<'_> { + async fn authenticate_with_organization_selection( + &self, + params: &AuthenticateWithOrganizationSelectionParams<'_>, + ) -> WorkOsResult { + let url = self + .workos + .base_url() + .join("/user_management/authenticate")?; + + let body = AuthenticateWithOrganizationSelectionBody { + client_secret: self.workos.key(), + grant_type: "urn:workos:oauth:grant-type:organization-selection", + params, + }; + + let authenticate_with_organization_selection_response = self + .workos + .client() + .post(url) + .json(&body) + .send() + .await? + .handle_authenticate_error() + .await? + .json::() + .await?; + + Ok(authenticate_with_organization_selection_response) + } +} + +#[cfg(test)] +mod test { + use matches::assert_matches; + use mockito::Matcher; + use serde_json::json; + use tokio; + + use crate::sso::AccessToken; + use crate::user_management::{RefreshToken, UserId}; + use crate::{ApiKey, WorkOs, WorkOsError}; + + use super::*; + + #[tokio::test] + async fn it_calls_the_token_endpoint() { + let mut server = mockito::Server::new_async().await; + + let workos = WorkOs::builder(&ApiKey::from("sk_example_123456789")) + .base_url(&server.url()) + .unwrap() + .build(); + + server + .mock("POST", "/user_management/authenticate") + .match_body(Matcher::PartialJson(json!({ + "client_id": "client_123456789", + "client_secret": "sk_example_123456789", + "grant_type": "urn:workos:oauth:grant-type:organization-selection", + "pending_authentication_token": "ql1AJgNoLN1tb9llaQ8jyC2dn", + "organization_id": "org_01H93Z2SYX1D3NJ536M94T8SHP" + }))) + .with_status(200) + .with_body( + json!({ + "user": { + "object": "user", + "id": "user_01E4ZCR3C56J083X43JQXF3JK5", + "email": "marcelina.davis@example.com", + "first_name": "Marcelina", + "last_name": "Davis", + "email_verified": true, + "profile_picture_url": "https://workoscdn.com/images/v1/123abc", + "metadata": {}, + "created_at": "2021-06-25T19:07:33.155Z", + "updated_at": "2021-06-25T19:07:33.155Z" + }, + "organization_id": "org_01H945H0YD4F97JN9MATX7BYAG", + "access_token": "eyJhb.nNzb19vaWRjX2tleV9.lc5Uk4yWVk5In0", + "refresh_token": "yAjhKk123NLIjdrBdGZPf8pLIDvK", + "authentication_method": "Password" + }) + .to_string(), + ) + .create_async() + .await; + + let response = workos + .user_management() + .authenticate_with_organization_selection( + &AuthenticateWithOrganizationSelectionParams { + client_id: &ClientId::from("client_123456789"), + pending_authentication_token: &PendingAuthenticationToken::from( + "ql1AJgNoLN1tb9llaQ8jyC2dn", + ), + organization_id: &OrganizationId::from("org_01H93Z2SYX1D3NJ536M94T8SHP"), + ip_address: None, + user_agent: None, + }, + ) + .await + .unwrap(); + + assert_eq!( + response.access_token, + AccessToken::from("eyJhb.nNzb19vaWRjX2tleV9.lc5Uk4yWVk5In0") + ); + assert_eq!( + response.refresh_token, + RefreshToken::from("yAjhKk123NLIjdrBdGZPf8pLIDvK") + ); + assert_eq!( + response.user.id, + UserId::from("user_01E4ZCR3C56J083X43JQXF3JK5") + ) + } + + #[tokio::test] + async fn it_returns_an_unauthorized_error_with_an_invalid_client() { + let mut server = mockito::Server::new_async().await; + + let workos = WorkOs::builder(&ApiKey::from("sk_example_123456789")) + .base_url(&server.url()) + .unwrap() + .build(); + + server + .mock("POST", "/user_management/authenticate") + .with_status(400) + .with_body( + json!({ + "error": "invalid_client", + "error_description": "Invalid client ID." + }) + .to_string(), + ) + .create_async() + .await; + + let result = workos + .user_management() + .authenticate_with_organization_selection( + &AuthenticateWithOrganizationSelectionParams { + client_id: &ClientId::from("client_123456789"), + pending_authentication_token: &PendingAuthenticationToken::from( + "ql1AJgNoLN1tb9llaQ8jyC2dn", + ), + organization_id: &OrganizationId::from("org_01H93Z2SYX1D3NJ536M94T8SHP"), + ip_address: None, + user_agent: None, + }, + ) + .await; + + assert_matches!(result, Err(WorkOsError::Unauthorized)) + } + + #[tokio::test] + async fn it_returns_an_unauthorized_error_with_an_unauthorized_client() { + let mut server = mockito::Server::new_async().await; + + let workos = WorkOs::builder(&ApiKey::from("sk_example_123456789")) + .base_url(&server.url()) + .unwrap() + .build(); + + server + .mock("POST", "/user_management/authenticate") + .with_status(400) + .with_body( + json!({ + "error": "unauthorized_client", + "error_description": "Unauthorized" + }) + .to_string(), + ) + .create_async() + .await; + + let result = workos + .user_management() + .authenticate_with_organization_selection( + &AuthenticateWithOrganizationSelectionParams { + client_id: &ClientId::from("client_123456789"), + pending_authentication_token: &PendingAuthenticationToken::from( + "ql1AJgNoLN1tb9llaQ8jyC2dn", + ), + organization_id: &OrganizationId::from("org_01H93Z2SYX1D3NJ536M94T8SHP"), + ip_address: None, + user_agent: None, + }, + ) + .await; + + assert_matches!(result, Err(WorkOsError::Unauthorized)) + } + + #[tokio::test] + async fn it_returns_an_error_when_the_authorization_code_is_invalid() { + let mut server = mockito::Server::new_async().await; + + let workos = WorkOs::builder(&ApiKey::from("sk_example_123456789")) + .base_url(&server.url()) + .unwrap() + .build(); + + server + .mock("POST", "/user_management/authenticate") + .with_status(400) + .with_body( + json!({ + "error": "invalid_grant", + "error_description": "The code '123456' has expired or is invalid." + }) + .to_string(), + ) + .create_async() + .await; + + let result = workos + .user_management() + .authenticate_with_organization_selection( + &AuthenticateWithOrganizationSelectionParams { + client_id: &ClientId::from("client_123456789"), + pending_authentication_token: &PendingAuthenticationToken::from( + "ql1AJgNoLN1tb9llaQ8jyC2dn", + ), + organization_id: &OrganizationId::from("org_01H93Z2SYX1D3NJ536M94T8SHP"), + ip_address: None, + user_agent: None, + }, + ) + .await; + + if let Err(WorkOsError::Operation(AuthenticateError::WithError(error))) = result { + assert_eq!(error.error(), "invalid_grant"); + assert_eq!( + error.error_description(), + "The code '123456' has expired or is invalid." + ); + } else { + panic!("expected authenticate_with_organization_selection to return an error") + } + } +} diff --git a/src/user_management/operations/authenticate_with_totp.rs b/src/user_management/operations/authenticate_with_totp.rs index 1e924cc..9b25f0a 100644 --- a/src/user_management/operations/authenticate_with_totp.rs +++ b/src/user_management/operations/authenticate_with_totp.rs @@ -334,7 +334,7 @@ mod test { "The code '123456' has expired or is invalid." ); } else { - panic!("expected authenticate_with_magic_auth to return an error") + panic!("expected authenticate_with_totp to return an error") } } }