diff --git a/integration/tests/posit/connect/oauth/test_associations.py b/integration/tests/posit/connect/oauth/test_associations.py index 0f939d45..aecc2b9f 100644 --- a/integration/tests/posit/connect/oauth/test_associations.py +++ b/integration/tests/posit/connect/oauth/test_associations.py @@ -95,6 +95,14 @@ def test_find_by_integration(self): no_associations = self.another_integration.associations.find() assert len(no_associations) == 0 + def test_find_by_content(self): + association = self.content.oauth.associations.find_by(integration_type="custom") + assert association is not None + assert association["oauth_integration_guid"] == self.integration["guid"] + + no_association = self.content.oauth.associations.find_by(integration_type="connect") + assert no_association is None + def test_find_update_by_content(self): associations = self.content.oauth.associations.find() assert len(associations) == 1 @@ -114,3 +122,37 @@ def test_find_update_by_content(self): self.content.oauth.associations.delete() no_associations = self.content.oauth.associations.find() assert len(no_associations) == 0 + + @pytest.mark.skipif( + CONNECT_VERSION < version.parse("2025.07.0"), + reason="Multi associations not supported.", + ) + def test_find_update_by_content_multiple(self): + self.content.oauth.associations.update( + [ + self.integration["guid"], + self.another_integration["guid"], + ] + ) + updated_associations = self.content.oauth.associations.find() + assert len(updated_associations) == 2 + for assoc in updated_associations: + assert assoc["app_guid"] == self.content["guid"] + assert assoc["oauth_integration_guid"] in [ + self.integration["guid"], + self.another_integration["guid"], + ] + + associated_connect_integration = self.content.oauth.associations.find_by( + name=".*another.*" + ) + assert associated_connect_integration is not None + assert ( + associated_connect_integration["oauth_integration_guid"] + == self.another_integration["guid"] + ) + + # unset content association + self.content.oauth.associations.delete() + no_associations = self.content.oauth.associations.find() + assert len(no_associations) == 0 diff --git a/integration/tests/posit/connect/oauth/test_integrations.py b/integration/tests/posit/connect/oauth/test_integrations.py index 25720bb6..7f64174d 100644 --- a/integration/tests/posit/connect/oauth/test_integrations.py +++ b/integration/tests/posit/connect/oauth/test_integrations.py @@ -60,6 +60,22 @@ def test_find(self): assert results[0] == self.integration assert results[1] == self.another_integration + def test_find_by(self): + result = self.client.oauth.integrations.find_by( + integration_type="custom", + config={"auth_mode": "Confidential"}, + name="example integration", + ) + assert result is not None + assert result["guid"] == self.integration["guid"] + + result = self.client.oauth.integrations.find_by( + integration_type="custom", + config={"auth_mode": "Confidential"}, + name="nonexistent integration", + ) + assert result is None + def test_create_update_delete(self): # create a new integration diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index 895d1a7e..a4af912c 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -12,7 +12,7 @@ from .groups import Groups from .metrics.metrics import Metrics from .oauth.oauth import OAuth -from .oauth.types import OAuthTokenType +from .oauth.types import OAuthIntegrationType, OAuthTokenType from .resources import _PaginatedResourceSequence, _ResourceSequence from .sessions import Session from .system import System @@ -198,6 +198,10 @@ def with_user_session_token( ---------- token : str The user session token. + audience : str, optional + The audience for the token exchange. This is the integration GUID of the Connect API integration + that is associate with the content. If not provided when there are multiple integrations, the + function will attempt to determine the audience from the current content associations. Returns ------- @@ -260,6 +264,18 @@ def user_profile(): if token is None or token == "": raise ValueError("token must be set to non-empty string.") + # If the audience is not provided and there are multiple associations, + # we will try to find the Connect API integration GUID from the content resource. + current_content_associations = self.content.get().oauth.associations.find() + if audience is None and len(current_content_associations) > 1: + connect_api_integration_guids = [ + a["oauth_integration_guid"] + for a in current_content_associations + if a.get("oauth_integration_template") == OAuthIntegrationType.CONNECT + ] + if len(connect_api_integration_guids) == 1: + audience = connect_api_integration_guids[0] + visitor_credentials = self.oauth.get_credentials( token, requested_token_type=OAuthTokenType.API_KEY, diff --git a/src/posit/connect/oauth/associations.py b/src/posit/connect/oauth/associations.py index 35b78545..ff7d8dec 100644 --- a/src/posit/connect/oauth/associations.py +++ b/src/posit/connect/oauth/associations.py @@ -6,7 +6,6 @@ from typing_extensions import TYPE_CHECKING, List, Optional -from ..context import requires from ..resources import BaseResource, Resources, _matches_exact, _matches_pattern if TYPE_CHECKING: @@ -67,7 +66,6 @@ def find(self) -> List[Association]: for result in response.json() ] - @requires("2025.07.0-dev") def find_by( self, integration_type: Optional[types.OAuthIntegrationType | str] = None, diff --git a/src/posit/connect/oauth/integrations.py b/src/posit/connect/oauth/integrations.py index 08174705..0c0e32a6 100644 --- a/src/posit/connect/oauth/integrations.py +++ b/src/posit/connect/oauth/integrations.py @@ -6,7 +6,6 @@ from typing_extensions import TYPE_CHECKING, List, Optional, overload -from ..context import requires from ..resources import ( BaseResource, Resources, @@ -132,7 +131,6 @@ def find(self) -> List[Integration]: for result in response.json() ] - @requires("2025.07.0-dev") def find_by( self, integration_type: Optional[types.OAuthIntegrationType | str] = None, diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/oauth/integrations/associations.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/oauth/integrations/associations.json index 171e6c0f..b79fb810 100644 --- a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/oauth/integrations/associations.json +++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/oauth/integrations/associations.json @@ -12,7 +12,7 @@ "oauth_integration_guid": "00000000-a27b-4118-ad06-e24459b05126", "oauth_integration_name": "another integration", "oauth_integration_description": "another description", - "oauth_integration_template": "custom", + "oauth_integration_template": "connect", "created_time": "2024-10-02T18:16:09Z" } ] diff --git a/tests/posit/connect/oauth/test_associations.py b/tests/posit/connect/oauth/test_associations.py index d4754b86..75f939c2 100644 --- a/tests/posit/connect/oauth/test_associations.py +++ b/tests/posit/connect/oauth/test_associations.py @@ -220,9 +220,9 @@ def test(self): assert found["oauth_integration_name"] == "keycloak integration" # first one # by multiple criteria - found = associations.find_by(integration_type="custom", name="another integration") + found = associations.find_by(integration_type="connect", name="another integration") assert found is not None - assert found["oauth_integration_template"] == "custom" + assert found["oauth_integration_template"] == "connect" assert found["oauth_integration_name"] == "another integration" # no match diff --git a/tests/posit/connect/test_client.py b/tests/posit/connect/test_client.py index f72bf505..2eb65952 100644 --- a/tests/posit/connect/test_client.py +++ b/tests/posit/connect/test_client.py @@ -3,6 +3,7 @@ import pytest import responses +from requests.exceptions import HTTPError from posit.connect import Client @@ -85,7 +86,13 @@ def test_init( MockSession.assert_called_once() @responses.activate - @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"}) + @patch.dict( + "os.environ", + { + "RSTUDIO_PRODUCT": "CONNECT", + "CONNECT_CONTENT_GUID": "f2f37341-e21d-3d80-c698-a935ad614066", + }, + ) def test_with_user_session_token(self): api_key = "12345" url = "https://connect.example.com" @@ -111,13 +118,38 @@ def test_with_user_session_token(self): }, ) + responses.get( + "https://connect.example.com/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066", + json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json"), + ) + + responses.get( + "https://connect.example.com/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/oauth/integrations/associations", + json=[ + { + "app_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "oauth_integration_guid": "00000000-a27b-4118-ad06-e24459b05126", + "oauth_integration_name": "another integration", + "oauth_integration_description": "another description", + "oauth_integration_template": "connect", + "created_time": "2024-10-02T18:16:09Z", + }, + ], + ) + visitor_client = client.with_user_session_token("cit") assert visitor_client.cfg.url == "https://connect.example.com/__api__" assert visitor_client.cfg.api_key == "api-key" @responses.activate - @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"}) + @patch.dict( + "os.environ", + { + "RSTUDIO_PRODUCT": "CONNECT", + "CONNECT_CONTENT_GUID": "f2f37341-e21d-3d80-c698-a935ad614066", + }, + ) def test_with_user_session_token_bad_exchange_response_body(self): api_key = "12345" url = "https://connect.example.com" @@ -139,6 +171,25 @@ def test_with_user_session_token_bad_exchange_response_body(self): json={}, ) + responses.get( + "https://connect.example.com/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066", + json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json"), + ) + + responses.get( + "https://connect.example.com/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/oauth/integrations/associations", + json=[ + { + "app_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "oauth_integration_guid": "00000000-a27b-4118-ad06-e24459b05126", + "oauth_integration_name": "another integration", + "oauth_integration_description": "another description", + "oauth_integration_template": "connect", + "created_time": "2024-10-02T18:16:09Z", + }, + ], + ) + with pytest.raises(ValueError) as err: client.with_user_session_token("cit") assert str(err.value) == "Unable to retrieve token." @@ -168,6 +219,260 @@ def test_with_user_session_token_bad_token_local(self): client.with_user_session_token(None) # type: ignore assert str(e.value) == "token must be set to non-empty string." + @responses.activate + @patch.dict( + "os.environ", + { + "RSTUDIO_PRODUCT": "CONNECT", + "CONNECT_CONTENT_GUID": "f2f37341-e21d-3d80-c698-a935ad614066", + }, + ) + def test_with_user_session_token_audience_multiple_integrations(self): + api_key = "12345" + url = "https://connect.example.com" + client = Client(api_key=api_key, url=url) + client._ctx.version = None + + responses.post( + "https://connect.example.com/__api__/v1/oauth/integrations/credentials", + match=[ + responses.matchers.urlencoded_params_matcher( + { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token_type": "urn:posit:connect:user-session-token", + "subject_token": "cit", + "requested_token_type": "urn:posit:connect:api-key", + "audience": "00000000-a27b-4118-ad06-e24459b05126", + }, + ), + ], + json={ + "access_token": "api-key", + "issued_token_type": "urn:posit:connect:api-key", + "token_type": "Key", + }, + ) + + responses.get( + "https://connect.example.com/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066", + json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json"), + ) + + responses.get( + "https://connect.example.com/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/oauth/integrations/associations", + json=[ + { + "app_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "oauth_integration_guid": "22644575-a27b-4118-ad06-e24459b05126", + "oauth_integration_name": "keycloak integration", + "oauth_integration_description": "integration description", + "oauth_integration_template": "custom", + "created_time": "2024-10-01T18:16:09Z", + }, + { + "app_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "oauth_integration_guid": "00000000-a27b-4118-ad06-e24459b05126", + "oauth_integration_name": "another integration", + "oauth_integration_description": "another description", + "oauth_integration_template": "connect", + "created_time": "2024-10-02T18:16:09Z", + }, + ], + ) + + visitor_client = client.with_user_session_token( + "cit", + audience="00000000-a27b-4118-ad06-e24459b05126", + ) + + assert visitor_client.cfg.url == "https://connect.example.com/__api__" + assert visitor_client.cfg.api_key == "api-key" + + @responses.activate + @patch.dict( + "os.environ", + { + "RSTUDIO_PRODUCT": "CONNECT", + "CONNECT_CONTENT_GUID": "f2f37341-e21d-3d80-c698-a935ad614066", + }, + ) + def test_with_user_session_token_audience_does_not_exist(self): + api_key = "12345" + url = "https://connect.example.com" + client = Client(api_key=api_key, url=url) + client._ctx.version = None + + responses.post( + "https://connect.example.com/__api__/v1/oauth/integrations/credentials", + match=[ + responses.matchers.urlencoded_params_matcher( + { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token_type": "urn:posit:connect:user-session-token", + "subject_token": "cit", + "requested_token_type": "urn:posit:connect:api-key", + "audience": "00000001-a27b-4118-ad06-e24459b05126", + }, + ), + ], + json={ + "access_token": "api-key", + "issued_token_type": "urn:posit:connect:api-key", + "token_type": "Key", + }, + ) + + responses.get( + "https://connect.example.com/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066", + json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json"), + ) + + responses.get( + "https://connect.example.com/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/oauth/integrations/associations", + json=[ + { + "app_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "oauth_integration_guid": "22644575-a27b-4118-ad06-e24459b05126", + "oauth_integration_name": "keycloak integration", + "oauth_integration_description": "integration description", + "oauth_integration_template": "custom", + "created_time": "2024-10-01T18:16:09Z", + }, + ], + ) + + visitor_client = client.with_user_session_token( + "cit", audience="00000001-a27b-4118-ad06-e24459b05126" + ) + + assert visitor_client.cfg.url == "https://connect.example.com/__api__" + assert visitor_client.cfg.api_key == "api-key" + + @responses.activate + @patch.dict( + "os.environ", + { + "RSTUDIO_PRODUCT": "CONNECT", + "CONNECT_CONTENT_GUID": "f2f37341-e21d-3d80-c698-a935ad614066", + }, + ) + def test_with_user_session_token_discover_audience(self): + api_key = "12345" + url = "https://connect.example.com" + client = Client(api_key=api_key, url=url) + client._ctx.version = None + + responses.post( + "https://connect.example.com/__api__/v1/oauth/integrations/credentials", + match=[ + responses.matchers.urlencoded_params_matcher( + { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token_type": "urn:posit:connect:user-session-token", + "subject_token": "cit", + "requested_token_type": "urn:posit:connect:api-key", + "audience": "00000000-a27b-4118-ad06-e24459b05126", + }, + ), + ], + json={ + "access_token": "api-key", + "issued_token_type": "urn:posit:connect:api-key", + "token_type": "Key", + }, + ) + + responses.get( + "https://connect.example.com/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066", + json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json"), + ) + + responses.get( + "https://connect.example.com/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/oauth/integrations/associations", + json=[ + { + "app_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "oauth_integration_guid": "22644575-a27b-4118-ad06-e24459b05126", + "oauth_integration_name": "keycloak integration", + "oauth_integration_description": "integration description", + "oauth_integration_template": "custom", + "created_time": "2024-10-01T18:16:09Z", + }, + { + "app_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "oauth_integration_guid": "00000000-a27b-4118-ad06-e24459b05126", + "oauth_integration_name": "another integration", + "oauth_integration_description": "another description", + "oauth_integration_template": "connect", + "created_time": "2024-10-02T18:16:09Z", + }, + ], + ) + + visitor_client = client.with_user_session_token("cit") + + assert visitor_client.cfg.url == "https://connect.example.com/__api__" + assert visitor_client.cfg.api_key == "api-key" + + @responses.activate + @patch.dict( + "os.environ", + { + "RSTUDIO_PRODUCT": "CONNECT", + "CONNECT_CONTENT_GUID": "f2f37341-e21d-3d80-c698-a935ad614066", + }, + ) + def test_with_user_session_token_discover_audience_no_connect_integration(self): + api_key = "12345" + url = "https://connect.example.com" + client = Client(api_key=api_key, url=url) + client._ctx.version = None + + responses.post( + "https://connect.example.com/__api__/v1/oauth/integrations/credentials", + match=[ + responses.matchers.urlencoded_params_matcher( + { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token_type": "urn:posit:connect:user-session-token", + "subject_token": "cit", + "requested_token_type": "urn:posit:connect:api-key", + }, + ), + ], + status=400, + ) + + responses.get( + "https://connect.example.com/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066", + json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json"), + ) + + responses.get( + "https://connect.example.com/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/oauth/integrations/associations", + json=[ + { + "app_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "oauth_integration_guid": "22644575-a27b-4118-ad06-e24459b05126", + "oauth_integration_name": "keycloak integration", + "oauth_integration_description": "integration description", + "oauth_integration_template": "custom", + "created_time": "2024-10-01T18:16:09Z", + }, + { + "app_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "oauth_integration_guid": "00000000-a27b-4118-ad06-e24459b05126", + "oauth_integration_name": "another integration", + "oauth_integration_description": "another description", + "oauth_integration_template": "custom", + "created_time": "2024-10-02T18:16:09Z", + }, + ], + ) + + with pytest.raises(HTTPError) as err: + client.with_user_session_token("cit") + def test__del__( self, MockAuth: MagicMock, @@ -212,11 +517,11 @@ def test_connect_version(self): @responses.activate def test_me_request(self): responses.get( - "https://connect.example/__api__/v1/user", + "https://connect.example.com/__api__/v1/user", json=load_mock("v1/user.json"), ) - con = Client(api_key="12345", url="https://connect.example/") + con = Client(api_key="12345", url="https://connect.example.com/") assert con.me["username"] == "carlos12" def test_request(self, MockSession):