Skip to content

Commit b134c8e

Browse files
feat: add support for content session tokens in snowflake authenticator (#437)
This is now relevant and needed given Snowflake adding support for oauth federation through an external idp: https://community.snowflake.com/s/article/Create-External-OAuth-Token-Using-Azure-AD-For-The-OAuth-Client-Itself Simple change to support both session token types.
1 parent eee7985 commit b134c8e

File tree

2 files changed

+81
-7
lines changed

2 files changed

+81
-7
lines changed

src/posit/connect/external/snowflake.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,13 @@ def __init__(
6969
local_authenticator: Optional[str] = None,
7070
client: Optional[Client] = None,
7171
user_session_token: Optional[str] = None,
72+
content_session_token: Optional[str] = None,
7273
audience: Optional[str] = None,
7374
):
7475
self._local_authenticator = local_authenticator
7576
self._client = client
7677
self._user_session_token = user_session_token
78+
self._content_session_token = content_session_token
7779
self._audience = audience
7880

7981
@property
@@ -87,16 +89,26 @@ def token(self) -> Optional[str]:
8789
if is_local():
8890
return None
8991

90-
# If the user-session-token wasn't provided and we're running on Connect then we raise an exception.
92+
# If a session token wasn't provided and we're running on Connect then we raise an exception.
9193
# user_session_token is required to impersonate the viewer.
92-
if self._user_session_token is None:
93-
raise ValueError("The user-session-token is required for viewer authentication.")
94+
# content_session_token is required for service account access.
95+
if self._user_session_token is None and self._content_session_token is None:
96+
raise ValueError(
97+
"A user-session-token or content-session-token is required for authentication."
98+
)
9499

95100
if self._client is None:
96101
self._client = Client()
97102

98-
credentials = self._client.oauth.get_credentials(
99-
self._user_session_token,
100-
audience=self._audience,
101-
)
103+
if self._user_session_token is not None:
104+
credentials = self._client.oauth.get_credentials(
105+
self._user_session_token,
106+
audience=self._audience,
107+
)
108+
else:
109+
credentials = self._client.oauth.get_content_credentials(
110+
self._content_session_token,
111+
audience=self._audience,
112+
)
113+
102114
return credentials.get("access_token")

tests/posit/connect/external/test_snowflake.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from unittest.mock import patch
22

3+
import pytest
34
import responses
45

56
from posit.connect import Client
@@ -25,6 +26,24 @@ def register_mocks():
2526
},
2627
)
2728

29+
responses.post(
30+
"https://connect.example/__api__/v1/oauth/integrations/credentials",
31+
match=[
32+
responses.matchers.urlencoded_params_matcher(
33+
{
34+
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
35+
"subject_token_type": "urn:posit:connect:content-session-token",
36+
"subject_token": "content-token-123",
37+
},
38+
),
39+
],
40+
json={
41+
"access_token": "service-account-access-token",
42+
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
43+
"token_type": "Bearer",
44+
},
45+
)
46+
2847

2948
class TestPositAuthenticator:
3049
@responses.activate
@@ -42,6 +61,21 @@ def test_posit_authenticator(self):
4261
assert auth.authenticator == "oauth"
4362
assert auth.token == "dynamic-viewer-access-token"
4463

64+
@responses.activate
65+
@patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"})
66+
def test_posit_authenticator_content_token(self):
67+
register_mocks()
68+
69+
client = Client(api_key="12345", url="https://connect.example/")
70+
client._ctx.version = None
71+
auth = PositAuthenticator(
72+
local_authenticator="SNOWFLAKE",
73+
content_session_token="content-token-123",
74+
client=client,
75+
)
76+
assert auth.authenticator == "oauth"
77+
assert auth.token == "service-account-access-token"
78+
4579
def test_posit_authenticator_fallback(self):
4680
# local_authenticator is used when the content is running locally
4781
client = Client(api_key="12345", url="https://connect.example/")
@@ -53,3 +87,31 @@ def test_posit_authenticator_fallback(self):
5387
)
5488
assert auth.authenticator == "SNOWFLAKE"
5589
assert auth.token is None
90+
91+
def test_posit_authenticator_content_token_fallback(self):
92+
# local_authenticator is used when the content is running locally
93+
client = Client(api_key="12345", url="https://connect.example/")
94+
client._ctx.version = None
95+
auth = PositAuthenticator(
96+
local_authenticator="SNOWFLAKE",
97+
content_session_token="content-token-123",
98+
client=client,
99+
)
100+
assert auth.authenticator == "SNOWFLAKE"
101+
assert auth.token is None
102+
103+
@patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"})
104+
def test_posit_authenticator_missing_tokens(self):
105+
# Should raise an error when running on Connect without any session token
106+
client = Client(api_key="12345", url="https://connect.example/")
107+
client._ctx.version = None
108+
auth = PositAuthenticator(
109+
local_authenticator="SNOWFLAKE",
110+
client=client,
111+
)
112+
assert auth.authenticator == "oauth"
113+
114+
with pytest.raises(
115+
ValueError, match="A user-session-token or content-session-token is required"
116+
):
117+
_ = auth.token

0 commit comments

Comments
 (0)