Skip to content
33 changes: 32 additions & 1 deletion src/posit/connect/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
"""
Expand Down
7 changes: 6 additions & 1 deletion src/posit/connect/oauth/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -51,14 +52,18 @@ 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 = {}
data["grant_type"] = GRANT_TYPE
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())
Expand Down
38 changes: 31 additions & 7 deletions tests/posit/connect/oauth/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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"
56 changes: 56 additions & 0 deletions tests/posit/connect/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading