From 635460f923d4295cba1d2091297cb88c109861cb Mon Sep 17 00:00:00 2001 From: Jonatas Santos Date: Fri, 21 Aug 2026 15:43:27 -0300 Subject: [PATCH] Add get_profile_and_token_from_id_token for OIDC ID token exchange Backport for customers on the 4.x line who need the /sso/token token-exchange grant (native MSAL / Entra ID token exchange). Adds a new additive SSO method that posts the token-exchange grant with subject_token and organization_id, returning the standard ProfileAndToken. Bumps to 4.16.0. --- tests/test_sso.py | 34 ++++++++++++++++++++++++++++++++++ workos/__about__.py | 2 +- workos/sso.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/tests/test_sso.py b/tests/test_sso.py index 70649fd7..6bec3bf6 100644 --- a/tests/test_sso.py +++ b/tests/test_sso.py @@ -424,6 +424,40 @@ def test_get_profile_and_token_returns_expected_workosprofile_object( assert profile_and_token.access_token == "01DY34ACQTM3B1CSX1YSZ8Z00D" assert profile_and_token.profile.to_dict() == mock_profile + def test_get_profile_and_token_from_id_token_returns_expected_workosprofile_object( + self, setup_with_client_id, mock_profile, mock_request_method + ): + response_dict = { + "profile": { + "object": "profile", + "id": mock_profile["id"], + "email": mock_profile["email"], + "first_name": mock_profile["first_name"], + "groups": mock_profile["groups"], + "organization_id": mock_profile["organization_id"], + "connection_id": mock_profile["connection_id"], + "connection_type": mock_profile["connection_type"], + "last_name": mock_profile["last_name"], + "idp_id": mock_profile["idp_id"], + "raw_attributes": { + "email": mock_profile["raw_attributes"]["email"], + "first_name": mock_profile["raw_attributes"]["first_name"], + "last_name": mock_profile["raw_attributes"]["last_name"], + "groups": mock_profile["raw_attributes"]["groups"], + }, + }, + "access_token": "01DY34ACQTM3B1CSX1YSZ8Z00D", + } + + mock_request_method("post", response_dict, 200) + + profile_and_token = self.sso.get_profile_and_token_from_id_token( + "eyJhbGciOiJSUzI1NiJ9.id.token", "org_01EHQMYV6MBK39QC5PZXHY59C3" + ) + + assert profile_and_token.access_token == "01DY34ACQTM3B1CSX1YSZ8Z00D" + assert profile_and_token.profile.to_dict() == mock_profile + def test_get_profile_and_token_without_first_name_or_last_name_returns_expected_workosprofile_object( self, setup_with_client_id, mock_magic_link_profile, mock_request_method ): diff --git a/workos/__about__.py b/workos/__about__.py index 4e19203c..7664f6a0 100644 --- a/workos/__about__.py +++ b/workos/__about__.py @@ -12,7 +12,7 @@ __package_url__ = "https://github.com/workos-inc/workos-python" -__version__ = "4.15.0" +__version__ = "4.16.0" __author__ = "WorkOS" diff --git a/workos/sso.py b/workos/sso.py index cff8d9e3..d26cb2e8 100644 --- a/workos/sso.py +++ b/workos/sso.py @@ -24,6 +24,8 @@ PROFILE_PATH = "sso/profile" OAUTH_GRANT_TYPE = "authorization_code" +TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" +ID_TOKEN_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token" RESPONSE_LIMIT = 10 @@ -162,6 +164,39 @@ def get_profile_and_token(self, code): return WorkOSProfileAndToken.construct_from_response(response) + def get_profile_and_token_from_id_token(self, id_token, organization_id): + """Exchange an externally issued OIDC ID token for a Profile and Token + + For flows where the user authenticates natively with the identity + provider (for example a mobile app using MSAL against Microsoft Entra) + and no browser redirect occurs. The ID token is exchanged for the same + WorkOS profile the authorization code flow would return. The connection + must have an ID token trust configured for the token's issuer and + audience. + + Args: + id_token (str): The OIDC ID token issued to the client. + organization_id (str): The organization whose connection the ID + token should be validated against. + + Returns: + WorkOSProfileAndToken: WorkOSProfileAndToken object representing the User + """ + params = { + "client_id": workos.client_id, + "client_secret": workos.api_key, + "grant_type": TOKEN_EXCHANGE_GRANT_TYPE, + "subject_token": id_token, + "subject_token_type": ID_TOKEN_SUBJECT_TOKEN_TYPE, + "organization_id": organization_id, + } + + response = self.request_helper.request( + TOKEN_PATH, method=REQUEST_METHOD_POST, params=params + ) + + return WorkOSProfileAndToken.construct_from_response(response) + def get_connection(self, connection): """Gets details for a single Connection