Skip to content

Commit 6b9654f

Browse files
added external provider for connect api
1 parent feb90c7 commit 6b9654f

File tree

3 files changed

+112
-1
lines changed

3 files changed

+112
-1
lines changed
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Connect API integration.
2+
3+
Connect API key exchange implementation which supports interacting with Posit OAuth integrations on Connect.
4+
5+
Notes
6+
-----
7+
The APIs in this module are provided as a convenience and are subject to breaking changes.
8+
"""
9+
10+
from typing_extensions import Optional
11+
12+
from ..oauth.oauth import API_KEY_TOKEN_TYPE
13+
14+
from ..client import Client
15+
from .external import is_local
16+
17+
18+
class ConnectAPIKeyProvider:
19+
"""
20+
Provider for exchanging Connect User Session Token for Connect API Key that acts on behalf of the user.
21+
This is an ephemeral API key that adheres to typical ephemeral API key clean up processes.
22+
23+
Examples
24+
--------
25+
```python
26+
from shiny import App, render, ui, reactive, req
27+
from posit.connect import Client
28+
from posit.connect.external.connect_api import ConnectAPIKeyProvider
29+
30+
client = Client()
31+
32+
app_ui = ui.page_fixed(
33+
ui.h1("My Shiny App"),
34+
# ...
35+
)
36+
37+
def server(input, output, session):
38+
user_session_token = session.http_conn.headers.get("Posit-Connect-User-Session-Token")
39+
provider = ConnectAPIKeyProvider(client, user_session_token)
40+
viewer_api_key = provider.viewer_key
41+
42+
# your app logic...
43+
44+
app = App(app_ui, server)
45+
```
46+
"""
47+
48+
def __init__(
49+
self,
50+
client: Optional[Client] = None,
51+
user_session_token: Optional[str] = None,
52+
):
53+
self._client = client
54+
self._user_session_token = user_session_token
55+
56+
@property
57+
def viewer_key(self) -> Optional[str]:
58+
"""
59+
The viewer key is retrieved through an OAuth exchange process using the user session token.
60+
The issued API key is associated with the viewer of your app and can be used on their behalf
61+
to interact with the Connect API.
62+
"""
63+
if is_local():
64+
return None
65+
66+
# If the user-session-token wasn't provided and we're running on Connect then we raise an exception.
67+
# user_session_token is required to impersonate the viewer.
68+
if self._user_session_token is None:
69+
raise ValueError("The user-session-token is required for viewer API key authorization.")
70+
71+
if self._client is None:
72+
self._client = Client()
73+
74+
credentials = self._client.oauth.get_credentials(
75+
user_session_token=self._user_session_token,
76+
requested_token_type=API_KEY_TOKEN_TYPE,
77+
)
78+
return credentials.get("access_token")

src/posit/connect/oauth/oauth.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
1515
USER_SESSION_TOKEN_TYPE = "urn:posit:connect:user-session-token"
1616
CONTENT_SESSION_TOKEN_TYPE = "urn:posit:connect:content-session-token"
17+
API_KEY_TOKEN_TYPE = "urn:posit:connect:api-key"
1718

1819

1920
def _get_content_session_token() -> str:
@@ -51,14 +52,16 @@ def integrations(self):
5152
def sessions(self):
5253
return Sessions(self._ctx)
5354

54-
def get_credentials(self, user_session_token: Optional[str] = None) -> Credentials:
55+
def get_credentials(self, user_session_token: Optional[str] = None, requested_token_type: Optional[str] = None) -> Credentials:
5556
"""Perform an oauth credential exchange with a user-session-token."""
5657
# craft a credential exchange request
5758
data = {}
5859
data["grant_type"] = GRANT_TYPE
5960
data["subject_token_type"] = USER_SESSION_TOKEN_TYPE
6061
if user_session_token:
6162
data["subject_token"] = user_session_token
63+
if requested_token_type:
64+
data["requested_token_type"] = requested_token_type
6265

6366
response = self._ctx.client.post(self._path, data=data)
6467
return Credentials(**response.json())

tests/posit/connect/oauth/test_oauth.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,36 @@ def test_get_credentials(self):
4141
assert "access_token" in creds
4242
assert creds["access_token"] == "viewer-token"
4343

44+
@responses.activate
45+
def test_get_credentials_api_key(self):
46+
responses.post(
47+
"https://connect.example/__api__/v1/oauth/integrations/credentials",
48+
match=[
49+
responses.matchers.urlencoded_params_matcher(
50+
{
51+
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
52+
"subject_token_type": "urn:posit:connect:user-session-token",
53+
"subject_token": "cit",
54+
"requested_token_type": "urn:posit:connect:api-key"
55+
},
56+
),
57+
],
58+
json={
59+
"access_token": "viewer-api-key",
60+
"issued_token_type": "urn:posit:connect:api-key",
61+
"token_type": "Key",
62+
},
63+
)
64+
c = Client(api_key="12345", url="https://connect.example/")
65+
c._ctx.version = None
66+
creds = c.oauth.get_credentials("cit")
67+
assert "access_token" in creds
68+
assert creds["access_token"] == "viewer-api-key"
69+
assert "issued_token_type" in creds
70+
assert creds["issued_token_type"] == "urn:posit:connect:api-key"
71+
assert "token_type" in creds
72+
assert creds["token_type"] == "Key"
73+
4474
@responses.activate
4575
def test_get_content_credentials(self):
4676
responses.post(

0 commit comments

Comments
 (0)