diff --git a/docs/_quarto.yml b/docs/_quarto.yml index f77c78ba..2de729b1 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -107,3 +107,7 @@ quartodoc: contents: - connect.metrics - connect.metrics.usage + - title: External Integrations + contents: + - connect.external.databricks + - connect.external.snowflake diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 404867b7..b1b9cd4d 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -755,8 +755,8 @@ def find_by( ------- Optional[ContentItem] - Example - ------- + Examples + -------- >>> find_by(name="example-content-name") """ attr_items = attrs.items() diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index a76ede39..e73c5f71 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -1,13 +1,21 @@ +""" +Databricks SDK credentials implementations which support interacting with Posit OAuth integrations on Connect. + +NOTE: These APIs are provided as a convenience and are subject to breaking changes: +https://github.com/databricks/databricks-sdk-py#interface-stability +""" + import abc from typing import Callable, Dict, Optional +import requests + from ..client import Client +from ..oauth import Credentials from .external import is_local -""" -NOTE: These APIs are provided as a convenience and are subject to breaking changes: -https://github.com/databricks/databricks-sdk-py#interface-stability -""" +POSIT_OAUTH_INTEGRATION_AUTH_TYPE = "posit-oauth-integration" +POSIT_LOCAL_CLIENT_CREDENTIALS_AUTH_TYPE = "posit-local-client-credentials" # The Databricks SDK CredentialsProvider == Databricks SQL HeaderFactory CredentialsProvider = Callable[[], Dict[str, str]] @@ -16,8 +24,10 @@ class CredentialsStrategy(abc.ABC): """Maintain compatibility with the Databricks SQL/SDK client libraries. - https://github.com/databricks/databricks-sql-python/blob/v3.3.0/src/databricks/sql/auth/authenticators.py#L19-L33 - https://github.com/databricks/databricks-sdk-py/blob/v0.29.0/databricks/sdk/credentials_provider.py#L44-L54 + See Also + -------- + * https://github.com/databricks/databricks-sql-python/blob/v3.3.0/src/databricks/sql/auth/authenticators.py#L19-L33 + * https://github.com/databricks/databricks-sdk-py/blob/v0.29.0/databricks/sdk/credentials_provider.py#L44-L54 """ @abc.abstractmethod @@ -29,29 +39,269 @@ def __call__(self, *args, **kwargs) -> CredentialsProvider: raise NotImplementedError +def _new_bearer_authorization_header(credentials: Credentials) -> Dict[str, str]: + """Helper to transform an Credentials object into the Bearer auth header consumed by databricks. + + Raises + ------ + ValueError: If provided Credentials object does not contain an access token + + Returns + ------- + Dict[str, str] + """ + access_token = credentials.get("access_token") + if access_token is None: + raise ValueError("Missing value for field 'access_token' in credentials.") + return {"Authorization": f"Bearer {access_token}"} + + +def _get_auth_type(local_auth_type: str) -> str: + """Returns the auth type currently in use. + + The databricks-sdk client uses the configured auth_type to create + a user-agent string which is used for attribution. We should only + overwrite the auth_type if we are using the PositCredentialsStrategy (non-local), + otherwise, we should return the auth_type of the configured local_strategy instead + to avoid breaking someone elses attribution. + + NOTE: The databricks-sql client does not use auth_type to set the user-agent. + https://github.com/databricks/databricks-sql-python/blob/v3.3.0/src/databricks/sql/client.py#L214-L219 + + See Also + -------- + * https://github.com/databricks/databricks-sdk-py/blob/v0.29.0/databricks/sdk/config.py#L261-L269 + + Returns + ------- + str + """ + if is_local(): + return local_auth_type + + return POSIT_OAUTH_INTEGRATION_AUTH_TYPE + + +class PositLocalContentCredentialsProvider: + """`CredentialsProvider` implementation which provides a fallback for local development using a client credentials flow. + + There is an open issue against the Databricks CLI which prevents it from returning service principal access tokens. + https://github.com/databricks/cli/issues/1939 + + Until the CLI issue is resolved, this CredentialsProvider implements the approach described in the Databricks documentation + for manually generating a workspace-level access token using OAuth M2M authentication. Once it has acquired an access token, + it returns it as a Bearer authorization header like other `CredentialsProvider` implementations. + + See Also + -------- + * https://docs.databricks.com/en/dev-tools/auth/oauth-m2m.html#manually-generate-a-workspace-level-access-token + """ + + def __init__(self, token_endpoint_url: str, client_id: str, client_secret: str): + self._token_endpoint_url = token_endpoint_url + self._client_id = client_id + self._client_secret = client_secret + + def __call__(self) -> Dict[str, str]: + response = requests.post( + self._token_endpoint_url, + auth=(self._client_id, self._client_secret), + data={ + "grant_type": "client_credentials", + "scope": "all-apis", + }, + ) + response.raise_for_status() + + credentials = Credentials(**response.json()) + return _new_bearer_authorization_header(credentials) + + +class PositContentCredentialsProvider: + """`CredentialsProvider` implementation which initiates a credential exchange using a content-session-token. + + The content-session-token is provided by Connect through the environment variable `CONNECT_CONTENT_SESSION_TOKEN`. + + See Also + -------- + * https://github.com/posit-dev/posit-sdk-py/blob/main/src/posit/connect/oauth/oauth.py + + """ + + def __init__(self, client: Client): + self._client = client + + def __call__(self) -> Dict[str, str]: + credentials = self._client.oauth.get_content_credentials() + return _new_bearer_authorization_header(credentials) + + class PositCredentialsProvider: + """`CredentialsProvider` implementation which initiates a credential exchange using a user-session-token. + + The user-session-token is provided by Connect through the HTTP session header + `Posit-Connect-User-Session-Token`. + + See Also + -------- + * https://github.com/posit-dev/posit-sdk-py/blob/main/src/posit/connect/oauth/oauth.py + + """ + def __init__(self, client: Client, user_session_token: str): self._client = client self._user_session_token = user_session_token def __call__(self) -> Dict[str, str]: credentials = self._client.oauth.get_credentials(self._user_session_token) - access_token = credentials.get("access_token") - if access_token is None: - raise ValueError("Missing value for field 'access_token' in credentials.") - return {"Authorization": f"Bearer {access_token}"} + return _new_bearer_authorization_header(credentials) -class PositCredentialsStrategy(CredentialsStrategy): +class PositLocalContentCredentialsStrategy(CredentialsStrategy): + """`CredentialsStrategy` implementation which supports local development using OAuth M2M authentication against Databricks. + + There is an open issue against the Databricks CLI which prevents it from returning service principal access tokens. + https://github.com/databricks/cli/issues/1939 + + Until the CLI issue is resolved, this CredentialsStrategy provides a drop-in replacement as a local_strategy that can be used + to develop applications which target Service Account OAuth integrations on Connect. + + Examples + -------- + In the example below, the `PositContentCredentialsStrategy` can be initialized anywhere that + the Python process can read environment variables. + + CLIENT_ID and CLIENT_SECRET are credentials associated with the Databricks service principal. + + ```python + from posit.connect.external.databricks import ( + PositContentCredentialsStrategy, + PositLocalContentCredentialsStrategy, + ) + + import pandas as pd + from databricks import sql + from databricks.sdk.core import ApiClient, Config + from databricks.sdk.service.iam import CurrentUserAPI + + DATABRICKS_HOST = "" + DATABRICKS_HOST_URL = f"https://{DATABRICKS_HOST}" + SQL_HTTP_PATH = "" + TOKEN_ENDPOINT_URL = f"https://{DATABRICKS_HOST}/oidc/v1/token" + + CLIENT_ID = "" + CLIENT_SECRET = "" + + # Rather than relying on the Databricks CLI as a local strategy, we use + # PositLocalContentCredentialsStrategy as a drop-in replacement. + # Can be replaced with the Databricks CLI implementation when + # https://github.com/databricks/cli/issues/1939 is resolved. + local_strategy = PositLocalContentCredentialsStrategy( + token_endpoint_url=TOKEN_ENDPOINT_URL, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + + posit_strategy = PositContentCredentialsStrategy(local_strategy=local_strategy) + + cfg = Config(host=DATABRICKS_HOST_URL, credentials_strategy=posit_strategy) + + databricks_user_info = CurrentUserAPI(ApiClient(cfg)).me() + print(f"Hello, {databricks_user_info.display_name}!") + + query = "SELECT * FROM samples.nyctaxi.trips LIMIT 10;" + with sql.connect( + server_hostname=DATABRICKS_HOST, + http_path=SQL_HTTP_PATH, + credentials_provider=posit_strategy.sql_credentials_provider(cfg), + ) as connection: + with connection.cursor() as cursor: + cursor.execute(query) + rows = cursor.fetchall() + print(pd.DataFrame([row.asDict() for row in rows])) + ``` + + See Also + -------- + * https://docs.databricks.com/en/dev-tools/auth/oauth-m2m.html#manually-generate-a-workspace-level-access-token + """ + + def __init__(self, token_endpoint_url: str, client_id: str, client_secret: str): + self._token_endpoint_url = token_endpoint_url + self._client_id = client_id + self._client_secret = client_secret + + def sql_credentials_provider(self, *args, **kwargs): + return lambda: self.__call__(*args, **kwargs) + + def auth_type(self) -> str: + return POSIT_LOCAL_CLIENT_CREDENTIALS_AUTH_TYPE + + def __call__(self, *args, **kwargs) -> CredentialsProvider: # noqa: ARG002 + return PositLocalContentCredentialsProvider( + self._token_endpoint_url, + self._client_id, + self._client_secret, + ) + + +class PositContentCredentialsStrategy(CredentialsStrategy): + """`CredentialsStrategy` implementation which supports interacting with Service Account OAuth integrations on Connect. + + This strategy callable class returns a `PositContentCredentialsProvider` when hosted on Connect, and + its `local_strategy` strategy otherwise. + + Examples + -------- + NOTE: in the example below, the `PositContentCredentialsStrategy` can be initialized anywhere that + the Python process can read environment variables. + + ```python + from posit.connect.external.databricks import PositContentCredentialsStrategy + + import pandas as pd + from databricks import sql + from databricks.sdk.core import ApiClient, Config, databricks_cli + from databricks.sdk.service.iam import CurrentUserAPI + + DATABRICKS_HOST = "" + DATABRICKS_HOST_URL = f"https://{DATABRICKS_HOST}" + SQL_HTTP_PATH = "" + + # NOTE: currently the databricks_cli local strategy only supports auth code OAuth flows. + # https://github.com/databricks/cli/issues/1939 + # + # This means that the databricks_cli supports local development using the developer's + # databricks credentials, but not the credentials for a service principal. + # To fallback to service principal credentials in local development, use + # `PositLocalContentCredentialsStrategy` as a drop-in replacement. + posit_strategy = PositContentCredentialsStrategy(local_strategy=databricks_cli) + + cfg = Config(host=DATABRICKS_HOST_URL, credentials_strategy=posit_strategy) + + databricks_user_info = CurrentUserAPI(ApiClient(cfg)).me() + print(f"Hello, {databricks_user_info.display_name}!") + + query = "SELECT * FROM samples.nyctaxi.trips LIMIT 10;" + with sql.connect( + server_hostname=DATABRICKS_HOST, + http_path=SQL_HTTP_PATH, + credentials_provider=posit_strategy.sql_credentials_provider(cfg), + ) as connection: + with connection.cursor() as cursor: + cursor.execute(query) + rows = cursor.fetchall() + print(pd.DataFrame([row.asDict() for row in rows])) + ``` + """ + def __init__( self, local_strategy: CredentialsStrategy, client: Optional[Client] = None, - user_session_token: Optional[str] = None, ): self._local_strategy = local_strategy self._client = client - self._user_session_token = user_session_token def sql_credentials_provider(self, *args, **kwargs): """The sql connector attempts to call the credentials provider w/o any args. @@ -66,23 +316,106 @@ def sql_credentials_provider(self, *args, **kwargs): return lambda: self.__call__(*args, **kwargs) def auth_type(self) -> str: - """Returns the auth type currently in use. + return _get_auth_type(self._local_strategy.auth_type()) + + def __call__(self, *args, **kwargs) -> CredentialsProvider: + # If the content is not running on Connect then fall back to local_strategy + if is_local(): + return self._local_strategy(*args, **kwargs) + + if self._client is None: + self._client = Client() + + return PositContentCredentialsProvider(self._client) + + +class PositCredentialsStrategy(CredentialsStrategy): + """`CredentialsStrategy` implementation which supports interacting with Viewer OAuth integrations on Connect. + + This strategy callable class returns a `PositCredentialsProvider` when hosted on Connect, and + its `local_strategy` strategy otherwise. - The databricks-sdk client uses the configurated auth_type to create - a user-agent string which is used for attribution. We should only - overwrite the auth_type if we are using the PositCredentialsStrategy (non-local), - otherwise, we should return the auth_type of the configured local_strategy instead - to avoid breaking someone elses attribution. + Examples + -------- + NOTE: In the example below, the PositCredentialsProvider *must* be initialized within the context of the + shiny `server` function, which provides access to the HTTP session headers. - https://github.com/databricks/databricks-sdk-py/blob/v0.29.0/databricks/sdk/config.py#L261-L269 + ```python + import os - NOTE: The databricks-sql client does not use auth_type to set the user-agent. - https://github.com/databricks/databricks-sql-python/blob/v3.3.0/src/databricks/sql/client.py#L214-L219 + import pandas as pd + from databricks import sql + from databricks.sdk.core import ApiClient, Config, databricks_cli + from databricks.sdk.service.iam import CurrentUserAPI + from posit.connect.external.databricks import PositCredentialsStrategy + from shiny import App, Inputs, Outputs, Session, render, ui + + DATABRICKS_HOST = "" + DATABRICKS_HOST_URL = f"https://{DATABRICKS_HOST}" + SQL_HTTP_PATH = "" + + app_ui = ui.page_fluid(ui.output_text("text"), ui.output_data_frame("result")) + + + def server(i: Inputs, o: Outputs, session: Session): + # HTTP session headers are available in this context. + session_token = session.http_conn.headers.get("Posit-Connect-User-Session-Token") + posit_strategy = PositCredentialsStrategy( + local_strategy=databricks_cli, user_session_token=session_token + ) + cfg = Config(host=DATABRICKS_HOST_URL, credentials_strategy=posit_strategy) + + @render.data_frame + def result(): + query = "SELECT * FROM samples.nyctaxi.trips LIMIT 10;" + + with sql.connect( + server_hostname=DATABRICKS_HOST, + http_path=SQL_HTTP_PATH, + credentials_provider=posit_strategy.sql_credentials_provider(cfg), + ) as connection: + with connection.cursor() as cursor: + cursor.execute(query) + rows = cursor.fetchall() + df = pd.DataFrame(rows, columns=[col[0] for col in cursor.description]) + return df + + @render.text + def text(): + databricks_user_info = CurrentUserAPI(ApiClient(cfg)).me() + return f"Hello, {databricks_user_info.display_name}!" + + + app = App(app_ui, server) + ``` + """ + + def __init__( + self, + local_strategy: CredentialsStrategy, + client: Optional[Client] = None, + user_session_token: Optional[str] = None, + ): + self._local_strategy = local_strategy + self._client = client + self._user_session_token = user_session_token + + def sql_credentials_provider(self, *args, **kwargs): + """The sql connector attempts to call the credentials provider w/o any args. + + The SQL client's `ExternalAuthProvider` is not compatible w/ the SDK's implementation of + `CredentialsProvider`, so create a no-arg lambda that wraps the args defined by the real caller. + This way we can pass in a databricks `Config` object required by most of the SDK's `CredentialsProvider` + implementations from where `sql.connect` is called. + + See Also + -------- + * https://github.com/databricks/databricks-sql-python/issues/148#issuecomment-2271561365 """ - if is_local(): - return self._local_strategy.auth_type() - else: - return "posit-oauth-integration" + return lambda: self.__call__(*args, **kwargs) + + def auth_type(self) -> str: + return _get_auth_type(self._local_strategy.auth_type()) def __call__(self, *args, **kwargs) -> CredentialsProvider: # If the content is not running on Connect then fall back to local_strategy diff --git a/src/posit/connect/external/snowflake.py b/src/posit/connect/external/snowflake.py index 197efa3a..7f5ec923 100644 --- a/src/posit/connect/external/snowflake.py +++ b/src/posit/connect/external/snowflake.py @@ -1,14 +1,65 @@ +"""Snowflake SDK credentials implementations which support interacting with Posit OAuth integrations on Connect. + +NOTE: The APIs in this module are provided as a convenience and are subject to breaking changes. +""" + from typing import Optional from ..client import Client from .external import is_local -""" -NOTE: The APIs in this module are provided as a convenience and are subject to breaking changes. -""" - class PositAuthenticator: + """ + Authenticator for Snowflake SDK which supports Posit OAuth integrations on Connect. + + Examples + -------- + ```python + import os + + import pandas as pd + import snowflake.connector + import streamlit as st + + from posit.connect.external.snowflake import PositAuthenticator + + ACCOUNT = os.getenv("SNOWFLAKE_ACCOUNT") + WAREHOUSE = os.getenv("SNOWFLAKE_WAREHOUSE") + + # USER is only required when running the example locally with external browser auth + USER = os.getenv("SNOWFLAKE_USER") + + # https://docs.snowflake.com/en/user-guide/sample-data-using + DATABASE = os.getenv("SNOWFLAKE_DATABASE", "snowflake_sample_data") + SCHEMA = os.getenv("SNOWFLAKE_SCHEMA", "tpch_sf1") + TABLE = os.getenv("SNOWFLAKE_TABLE", "lineitem") + + session_token = st.context.headers.get("Posit-Connect-User-Session-Token") + auth = PositAuthenticator( + local_authenticator="EXTERNALBROWSER", user_session_token=session_token + ) + + con = snowflake.connector.connect( + user=USER, + account=ACCOUNT, + warehouse=WAREHOUSE, + database=DATABASE, + schema=SCHEMA, + authenticator=auth.authenticator, + token=auth.token, + ) + + snowflake_user = con.cursor().execute("SELECT CURRENT_USER()").fetchone() + st.write(f"Hello, {snowflake_user[0]}!") + + with st.spinner("Loading data from Snowflake..."): + df = pd.read_sql_query(f"SELECT * FROM {TABLE} LIMIT 10", con) + + st.dataframe(df) + ``` + """ + def __init__( self, local_authenticator: Optional[str] = None, diff --git a/src/posit/connect/oauth/__init__.py b/src/posit/connect/oauth/__init__.py index 15274e67..453aafb7 100644 --- a/src/posit/connect/oauth/__init__.py +++ b/src/posit/connect/oauth/__init__.py @@ -1 +1,2 @@ +from .oauth import Credentials as Credentials from .oauth import OAuth as OAuth diff --git a/src/posit/connect/oauth/oauth.py b/src/posit/connect/oauth/oauth.py index 306170b8..6d53eeb6 100644 --- a/src/posit/connect/oauth/oauth.py +++ b/src/posit/connect/oauth/oauth.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from typing import Optional from typing_extensions import TypedDict @@ -8,12 +9,40 @@ from .integrations import Integrations from .sessions import Sessions +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" + + +def _get_content_session_token() -> str: + """Return the content session token. + + Reads the environment variable 'CONNECT_CONTENT_SESSION_TOKEN'. + + Raises + ------ + ValueError: If CONNECT_CONTENT_SESSION_TOKEN is not set or invalid + + Returns + ------- + str + """ + value = os.environ.get("CONNECT_CONTENT_SESSION_TOKEN") + if not value: + raise ValueError( + "Invalid value for 'CONNECT_CONTENT_SESSION_TOKEN': Must be a non-empty string." + ) + return value + class OAuth(Resources): def __init__(self, params: ResourceParameters, api_key: str) -> None: super().__init__(params) self.api_key = api_key + def _get_credentials_url(self) -> str: + return self.params.url + "v1/oauth/integrations/credentials" + @property def integrations(self): return Integrations(self.params) @@ -23,16 +52,26 @@ def sessions(self): return Sessions(self.params) def get_credentials(self, user_session_token: Optional[str] = None) -> Credentials: - url = self.params.url + "v1/oauth/integrations/credentials" - + """Perform an oauth credential exchange with a user-session-token.""" # craft a credential exchange request data = {} - data["grant_type"] = "urn:ietf:params:oauth:grant-type:token-exchange" - data["subject_token_type"] = "urn:posit:connect:user-session-token" + data["grant_type"] = GRANT_TYPE + data["subject_token_type"] = USER_SESSION_TOKEN_TYPE if user_session_token: data["subject_token"] = user_session_token - response = self.params.session.post(url, data=data) + response = self.params.session.post(self._get_credentials_url(), data=data) + return Credentials(**response.json()) + + def get_content_credentials(self, content_session_token: Optional[str] = None) -> Credentials: + """Perform an oauth credential exchange with a content-session-token.""" + # craft a credential exchange request + data = {} + data["grant_type"] = GRANT_TYPE + data["subject_token_type"] = CONTENT_SESSION_TOKEN_TYPE + data["subject_token"] = content_session_token or _get_content_session_token() + + response = self.params.session.post(self._get_credentials_url(), data=data) return Credentials(**response.json()) diff --git a/tests/posit/connect/external/test_databricks.py b/tests/posit/connect/external/test_databricks.py index 4cb83fd1..9861b907 100644 --- a/tests/posit/connect/external/test_databricks.py +++ b/tests/posit/connect/external/test_databricks.py @@ -1,15 +1,25 @@ +import base64 from typing import Dict from unittest.mock import patch +import pytest import responses from posit.connect import Client from posit.connect.external.databricks import ( + POSIT_OAUTH_INTEGRATION_AUTH_TYPE, CredentialsProvider, CredentialsStrategy, + PositContentCredentialsProvider, + PositContentCredentialsStrategy, PositCredentialsProvider, PositCredentialsStrategy, + PositLocalContentCredentialsProvider, + PositLocalContentCredentialsStrategy, + _get_auth_type, + _new_bearer_authorization_header, ) +from posit.connect.oauth import Credentials class mock_strategy(CredentialsStrategy): @@ -42,8 +52,84 @@ def register_mocks(): }, ) + 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:content-session-token", + "subject_token": "cit", + }, + ), + ], + json={ + "access_token": "content-access-token", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + }, + ) + class TestPositCredentialsHelpers: + def test_new_bearer_authorization_header(self): + credential = Credentials() + credential["token_type"] = "token_type" + credential["issued_token_type"] = "issued_token_type" + + with pytest.raises(ValueError): + _new_bearer_authorization_header(credential) + + credential["access_token"] = "access_token" + result = _new_bearer_authorization_header(credential) + assert result == {"Authorization": "Bearer access_token"} + + def test_get_auth_type_local(self): + assert _get_auth_type("local-auth") == "local-auth" + + @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"}) + def test_get_auth_type_connect(self): + assert _get_auth_type("local-auth") == POSIT_OAUTH_INTEGRATION_AUTH_TYPE + + @responses.activate + def test_local_content_credentials_provider(self): + token_url = "https://my-token/url" + client_id = "client_id" + client_secret = "client_secret_123" + basic_auth = f"{client_id}:{client_secret}" + b64_basic_auth = base64.b64encode(basic_auth.encode("utf-8")).decode("utf-8") + + responses.post( + token_url, + match=[ + responses.matchers.urlencoded_params_matcher( + { + "grant_type": "client_credentials", + "scope": "all-apis", + }, + ), + responses.matchers.header_matcher({"Authorization": f"Basic {b64_basic_auth}"}), + ], + json={ + "access_token": "oauth2-m2m-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + cp = PositLocalContentCredentialsProvider(token_url, client_id, client_secret) + assert cp() == {"Authorization": "Bearer oauth2-m2m-access-token"} + + @patch.dict("os.environ", {"CONNECT_CONTENT_SESSION_TOKEN": "cit"}) + @responses.activate + def test_posit_content_credentials_provider(self): + register_mocks() + + client = Client(api_key="12345", url="https://connect.example/") + client._ctx.version = None + cp = PositContentCredentialsProvider(client=client) + assert cp() == {"Authorization": "Bearer content-access-token"} + @responses.activate def test_posit_credentials_provider(self): register_mocks() @@ -53,6 +139,57 @@ def test_posit_credentials_provider(self): cp = PositCredentialsProvider(client=client, user_session_token="cit") assert cp() == {"Authorization": "Bearer dynamic-viewer-access-token"} + @responses.activate + def test_local_content_credentials_strategy(self): + token_url = "https://my-token/url" + client_id = "client_id" + client_secret = "client_secret_123" + basic_auth = f"{client_id}:{client_secret}" + b64_basic_auth = base64.b64encode(basic_auth.encode("utf-8")).decode("utf-8") + + responses.post( + token_url, + match=[ + responses.matchers.urlencoded_params_matcher( + { + "grant_type": "client_credentials", + "scope": "all-apis", + }, + ), + responses.matchers.header_matcher({"Authorization": f"Basic {b64_basic_auth}"}), + ], + json={ + "access_token": "oauth2-m2m-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + cs = PositLocalContentCredentialsStrategy( + token_url, + client_id, + client_secret, + ) + cp = cs() + assert cs.auth_type() == "posit-local-client-credentials" + assert cp() == {"Authorization": "Bearer oauth2-m2m-access-token"} + + @patch.dict("os.environ", {"CONNECT_CONTENT_SESSION_TOKEN": "cit"}) + @responses.activate + @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"}) + def test_posit_content_credentials_strategy(self): + register_mocks() + + client = Client(api_key="12345", url="https://connect.example/") + client._ctx.version = None + cs = PositContentCredentialsStrategy( + local_strategy=mock_strategy(), + client=client, + ) + cp = cs() + assert cs.auth_type() == "posit-oauth-integration" + assert cp() == {"Authorization": "Bearer content-access-token"} + @responses.activate @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"}) def test_posit_credentials_strategy(self): @@ -69,6 +206,17 @@ def test_posit_credentials_strategy(self): assert cs.auth_type() == "posit-oauth-integration" assert cp() == {"Authorization": "Bearer dynamic-viewer-access-token"} + def test_posit_content_credentials_strategy_fallback(self): + # local_strategy is used when the content is running locally + client = Client(api_key="12345", url="https://connect.example/") + cs = PositContentCredentialsStrategy( + local_strategy=mock_strategy(), + client=client, + ) + cp = cs() + assert cs.auth_type() == "local" + assert cp() == {"Authorization": "Bearer static-pat-token"} + def test_posit_credentials_strategy_fallback(self): # local_strategy is used when the content is running locally client = Client(api_key="12345", url="https://connect.example/") diff --git a/tests/posit/connect/oauth/test_oauth.py b/tests/posit/connect/oauth/test_oauth.py index 702f6716..46cef485 100644 --- a/tests/posit/connect/oauth/test_oauth.py +++ b/tests/posit/connect/oauth/test_oauth.py @@ -1,9 +1,21 @@ +from unittest.mock import patch + +import pytest import responses from posit.connect import Client +from posit.connect.oauth.oauth import _get_content_session_token class TestOAuthIntegrations: + @patch.dict("os.environ", {"CONNECT_CONTENT_SESSION_TOKEN": "cit"}) + def test_get_content_session_token_success(self): + assert _get_content_session_token() == "cit" + + def test_get_content_session_token_failure(self): + with pytest.raises(ValueError): + _get_content_session_token() + @responses.activate def test_get_credentials(self): responses.post( @@ -28,3 +40,54 @@ def test_get_credentials(self): creds = c.oauth.get_credentials("cit") assert "access_token" in creds assert creds["access_token"] == "viewer-token" + + @responses.activate + def test_get_content_credentials(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:content-session-token", + "subject_token": "cit", + }, + ), + ], + json={ + "access_token": "content-token", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + }, + ) + 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" + + @patch.dict("os.environ", {"CONNECT_CONTENT_SESSION_TOKEN": "cit"}) + @responses.activate + def test_get_content_credentials_env_var(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:content-session-token", + "subject_token": "cit", + }, + ), + ], + json={ + "access_token": "content-token", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + }, + ) + 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"