diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index a9248d3a..5e02f52f 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -12,7 +12,7 @@ from .context import Context, ContextManager, requires from .groups import Groups from .metrics.metrics import Metrics -from .oauth.oauth import OAuth +from .oauth.oauth import API_KEY_TOKEN_TYPE, OAuth from .resources import _PaginatedResourceSequence, _ResourceSequence from .system import System from .tags import Tags @@ -173,6 +173,37 @@ def __init__(self, *args, **kwargs) -> None: self.session = session self._ctx = Context(self) + @requires("2025.01.0-dev") + def with_user_session_token(self, token: str) -> Client: + """Create a new Client scoped to the user specified in the user session token. + + Create a new Client instance from a user session token exchange for an api key scoped to the + user specified in the token. + + Parameters + ---------- + token : str + The user session token. + + Returns + ------- + Client + A new Client instance authenticated with an API key exchanged for the user session token. + + Examples + -------- + >>> from posit.connect import Client + >>> client = Client().with_user_session_token("my-user-session-token") + """ + viewer_credentials = self.oauth.get_credentials( + token, requested_token_type=API_KEY_TOKEN_TYPE + ) + viewer_api_key = viewer_credentials.get("access_token") + if viewer_api_key is None: + raise ValueError("Unable to retrieve viewer api key.") + + return Client(url=self.cfg.url, api_key=viewer_api_key) + @property def content(self) -> Content: """ diff --git a/src/posit/connect/oauth/oauth.py b/src/posit/connect/oauth/oauth.py index efec4395..84e31dac 100644 --- a/src/posit/connect/oauth/oauth.py +++ b/src/posit/connect/oauth/oauth.py @@ -14,6 +14,7 @@ GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" USER_SESSION_TOKEN_TYPE = "urn:posit:connect:user-session-token" CONTENT_SESSION_TOKEN_TYPE = "urn:posit:connect:content-session-token" +API_KEY_TOKEN_TYPE = "urn:posit:connect:api-key" def _get_content_session_token() -> str: @@ -51,7 +52,9 @@ def integrations(self): def sessions(self): return Sessions(self._ctx) - def get_credentials(self, user_session_token: Optional[str] = None) -> Credentials: + def get_credentials( + self, user_session_token: Optional[str] = None, requested_token_type: Optional[str] = None + ) -> Credentials: """Perform an oauth credential exchange with a user-session-token.""" # craft a credential exchange request data = {} @@ -59,6 +62,8 @@ def get_credentials(self, user_session_token: Optional[str] = None) -> Credentia data["subject_token_type"] = USER_SESSION_TOKEN_TYPE if user_session_token: data["subject_token"] = user_session_token + if requested_token_type: + data["requested_token_type"] = requested_token_type response = self._ctx.client.post(self._path, data=data) return Credentials(**response.json()) diff --git a/tests/posit/connect/oauth/test_oauth.py b/tests/posit/connect/oauth/test_oauth.py index 46cef485..c74e9a64 100644 --- a/tests/posit/connect/oauth/test_oauth.py +++ b/tests/posit/connect/oauth/test_oauth.py @@ -4,7 +4,7 @@ import responses from posit.connect import Client -from posit.connect.oauth.oauth import _get_content_session_token +from posit.connect.oauth.oauth import API_KEY_TOKEN_TYPE, _get_content_session_token class TestOAuthIntegrations: @@ -38,8 +38,34 @@ def test_get_credentials(self): c = Client(api_key="12345", url="https://connect.example/") c._ctx.version = None creds = c.oauth.get_credentials("cit") - assert "access_token" in creds - assert creds["access_token"] == "viewer-token" + assert creds.get("access_token") == "viewer-token" + + @responses.activate + def test_get_credentials_api_key(self): + responses.post( + "https://connect.example/__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", + }, + ), + ], + json={ + "access_token": "viewer-api-key", + "issued_token_type": "urn:posit:connect:api-key", + "token_type": "Key", + }, + ) + c = Client(api_key="12345", url="https://connect.example/") + c._ctx.version = None + creds = c.oauth.get_credentials("cit", API_KEY_TOKEN_TYPE) + assert creds.get("access_token") == "viewer-api-key" + assert creds.get("issued_token_type") == "urn:posit:connect:api-key" + assert creds.get("token_type") == "Key" @responses.activate def test_get_content_credentials(self): @@ -63,8 +89,7 @@ def test_get_content_credentials(self): c = Client(api_key="12345", url="https://connect.example/") c._ctx.version = None creds = c.oauth.get_content_credentials("cit") - assert "access_token" in creds - assert creds["access_token"] == "content-token" + assert creds.get("access_token") == "content-token" @patch.dict("os.environ", {"CONNECT_CONTENT_SESSION_TOKEN": "cit"}) @responses.activate @@ -89,5 +114,4 @@ def test_get_content_credentials_env_var(self): c = Client(api_key="12345", url="https://connect.example/") c._ctx.version = None creds = c.oauth.get_content_credentials() - assert "access_token" in creds - assert creds["access_token"] == "content-token" + assert creds.get("access_token") == "content-token" diff --git a/tests/posit/connect/test_client.py b/tests/posit/connect/test_client.py index 3f7e8284..c256de89 100644 --- a/tests/posit/connect/test_client.py +++ b/tests/posit/connect/test_client.py @@ -84,6 +84,62 @@ def test_init( MockConfig.assert_called_once_with(api_key=api_key, url=url) MockSession.assert_called_once() + @responses.activate + def test_with_user_session_token(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", + }, + ), + ], + json={ + "access_token": "api-key", + "issued_token_type": "urn:posit:connect:api-key", + "token_type": "Key", + }, + ) + + viewer_client = client.with_user_session_token("cit") + + assert viewer_client.cfg.url == "https://connect.example.com/__api__" + assert viewer_client.cfg.api_key == "api-key" + + @responses.activate + def test_with_user_session_token_bad_exchange(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", + }, + ), + ], + json={}, + ) + + with pytest.raises(ValueError): + client.with_user_session_token("cit") + def test__del__( self, MockAuth: MagicMock,