Skip to content

Commit cef7b51

Browse files
Add support for new authentication type - PAT with external session ID (#2355)
1 parent da7270c commit cef7b51

File tree

7 files changed

+183
-5
lines changed

7 files changed

+183
-5
lines changed

DESCRIPTION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Source code is also available at: https://github.com/snowflakedb/snowflake-conne
1111
- Bumped numpy dependency from <2.1.0 to <=2.2.4
1212
- Added Windows support for Python 3.13.
1313
- Add `bulk_upload_chunks` parameter to `write_pandas` function. Setting this parameter to True changes the behaviour of write_pandas function to first write all the data chunks to the local disk and then perform the wildcard upload of the chunks folder to the stage. In default behaviour the chunks are being saved, uploaded and deleted one by one.
14-
14+
- Added support for new authentication mechanism PAT with external session ID
1515

1616
- v3.15.1(May 20, 2025)
1717
- Added basic arrow support for Interval types.

src/snowflake/connector/auth/by_plugin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class AuthType(Enum):
5353
PAT = "PROGRAMMATIC_ACCESS_TOKEN'"
5454
NO_AUTH = "NO_AUTH"
5555
WORKLOAD_IDENTITY = "WORKLOAD_IDENTITY"
56+
PAT_WITH_EXTERNAL_SESSION = "PAT_WITH_EXTERNAL_SESSION"
5657

5758

5859
class AuthByPlugin(ABC):

src/snowflake/connector/connection.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
OAUTH_AUTHENTICATOR,
108108
OAUTH_AUTHORIZATION_CODE,
109109
OAUTH_CLIENT_CREDENTIALS,
110+
PAT_WITH_EXTERNAL_SESSION,
110111
PROGRAMMATIC_ACCESS_TOKEN,
111112
REQUEST_ID,
112113
USR_PWD_MFA_AUTHENTICATOR,
@@ -362,6 +363,11 @@ def _get_private_bytes_from_file(
362363
True,
363364
bool,
364365
), # SNOW-XXXXX: remove the check_arrow_conversion_error_on_every_column flag
366+
"external_session_id": (
367+
None,
368+
str,
369+
# SNOW-2096721: External (Spark) session ID
370+
),
365371
}
366372

367373
APPLICATION_RE = re.compile(r"[\w\d_]+")
@@ -1265,6 +1271,15 @@ def __open_connection(self):
12651271
)
12661272
elif self._authenticator == PROGRAMMATIC_ACCESS_TOKEN:
12671273
self.auth_class = AuthByPAT(self._token)
1274+
elif self._authenticator == PAT_WITH_EXTERNAL_SESSION:
1275+
# We don't need to do a POST to /v1/login-request to get session and master tokens at the startup
1276+
# time. PAT with external (Spark) session ID creates a new session when it encounters the unique
1277+
# (PAT, external session ID) combination for the first time and then onwards use the (PAT, external
1278+
# session id) as a key to identify and authenticate the session. So we bypass actual AuthN here.
1279+
self.auth_class = AuthNoAuth()
1280+
self._rest.set_pat_and_external_session(
1281+
self._token, self._external_session_id
1282+
)
12681283
elif self._authenticator == WORKLOAD_IDENTITY_AUTHENTICATOR:
12691284
self._check_experimental_authentication_flag()
12701285
# Standardize the provider enum.
@@ -1404,6 +1419,7 @@ def __config(self, **kwargs):
14041419
OAUTH_AUTHENTICATOR,
14051420
USR_PWD_MFA_AUTHENTICATOR,
14061421
WORKLOAD_IDENTITY_AUTHENTICATOR,
1422+
PAT_WITH_EXTERNAL_SESSION,
14071423
]:
14081424
self._authenticator = auth_tmp
14091425

@@ -1419,6 +1435,7 @@ def __config(self, **kwargs):
14191435
NO_AUTH_AUTHENTICATOR,
14201436
WORKLOAD_IDENTITY_AUTHENTICATOR,
14211437
PROGRAMMATIC_ACCESS_TOKEN,
1438+
PAT_WITH_EXTERNAL_SESSION,
14221439
}
14231440

14241441
if not (self._master_token and self._session_token):
@@ -1467,6 +1484,7 @@ def __config(self, **kwargs):
14671484
KEY_PAIR_AUTHENTICATOR,
14681485
PROGRAMMATIC_ACCESS_TOKEN,
14691486
WORKLOAD_IDENTITY_AUTHENTICATOR,
1487+
PAT_WITH_EXTERNAL_SESSION,
14701488
)
14711489
and not self._password
14721490
):

src/snowflake/connector/network.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,12 @@
148148

149149
HEADER_AUTHORIZATION_KEY = "Authorization"
150150
HEADER_SNOWFLAKE_TOKEN = 'Snowflake Token="{token}"'
151+
HEADER_EXTERNAL_SESSION_KEY = "X-Snowflake-External-Session-ID"
151152

152153
REQUEST_ID = "requestId"
153154
REQUEST_GUID = "request_guid"
154155
SNOWFLAKE_HOST_SUFFIX = ".snowflakecomputing.com"
155156

156-
157157
SNOWFLAKE_CONNECTOR_VERSION = SNOWFLAKE_CONNECTOR_VERSION
158158
PYTHON_VERSION = PYTHON_VERSION
159159
OPERATING_SYSTEM = OPERATING_SYSTEM
@@ -166,6 +166,7 @@
166166
PYTHON_CONNECTOR_USER_AGENT = f"{CLIENT_NAME}/{SNOWFLAKE_CONNECTOR_VERSION} ({PLATFORM}) {IMPLEMENTATION}/{PYTHON_VERSION}"
167167

168168
NO_TOKEN = "no-token"
169+
NO_EXTERNAL_SESSION_ID = "no-external-session-id"
169170

170171
STATUS_TO_EXCEPTION: dict[int, type[Error]] = {
171172
INTERNAL_SERVER_ERROR: InternalServerError,
@@ -189,6 +190,7 @@
189190
PROGRAMMATIC_ACCESS_TOKEN = "PROGRAMMATIC_ACCESS_TOKEN"
190191
NO_AUTH_AUTHENTICATOR = "NO_AUTH"
191192
WORKLOAD_IDENTITY_AUTHENTICATOR = "WORKLOAD_IDENTITY"
193+
PAT_WITH_EXTERNAL_SESSION = "PAT_WITH_EXTERNAL_SESSION"
192194

193195

194196
def is_retryable_http_code(code: int) -> bool:
@@ -313,6 +315,25 @@ def __call__(self, r: PreparedRequest) -> PreparedRequest:
313315
return r
314316

315317

318+
class PATWithExternalSessionAuth(AuthBase):
319+
"""Attaches HTTP Authorization headers for PAT with External Session."""
320+
321+
def __init__(self, token, external_session_id) -> None:
322+
# setup any auth-related data here
323+
self.token = token
324+
self.external_session_id = external_session_id
325+
326+
def __call__(self, r: PreparedRequest) -> PreparedRequest:
327+
"""Modifies and returns the request."""
328+
if HEADER_AUTHORIZATION_KEY in r.headers:
329+
del r.headers[HEADER_AUTHORIZATION_KEY]
330+
if self.token != NO_TOKEN:
331+
r.headers[HEADER_AUTHORIZATION_KEY] = "Bearer " + self.token
332+
if self.external_session_id != NO_EXTERNAL_SESSION_ID:
333+
r.headers[HEADER_EXTERNAL_SESSION_KEY] = self.external_session_id
334+
return r
335+
336+
316337
class SessionPool:
317338
def __init__(self, rest: SnowflakeRestful) -> None:
318339
# A stack of the idle sessions
@@ -404,6 +425,12 @@ def __init__(
404425
def token(self) -> str | None:
405426
return self._token if hasattr(self, "_token") else None
406427

428+
@property
429+
def external_session_id(self) -> str | None:
430+
return (
431+
self._external_session_id if hasattr(self, "_external_session_id") else None
432+
)
433+
407434
@property
408435
def master_token(self) -> str | None:
409436
return self._master_token if hasattr(self, "_master_token") else None
@@ -513,6 +540,7 @@ def request(
513540
headers,
514541
json.dumps(body, cls=SnowflakeRestfulJsonEncoder),
515542
token=self.token,
543+
external_session_id=self.external_session_id,
516544
_no_results=_no_results,
517545
timeout=timeout,
518546
_include_retry_params=_include_retry_params,
@@ -523,6 +551,7 @@ def request(
523551
url,
524552
headers,
525553
token=self.token,
554+
external_session_id=self.external_session_id,
526555
timeout=timeout,
527556
)
528557

@@ -542,6 +571,17 @@ def update_tokens(
542571
self._mfa_token = mfa_token
543572
self._master_validity_in_seconds = master_validity_in_seconds
544573

574+
def set_pat_and_external_session(
575+
self,
576+
personal_access_token,
577+
external_session_id,
578+
) -> None:
579+
"""Updates session and master tokens and optionally temporary credential."""
580+
with self._lock_token:
581+
self._personal_access_token = personal_access_token
582+
self._token = personal_access_token
583+
self._external_session_id = external_session_id
584+
545585
def _renew_session(self):
546586
"""Renew a session and master token."""
547587
return self._token_request(REQUEST_TYPE_RENEW)
@@ -698,6 +738,7 @@ def _get_request(
698738
url: str,
699739
headers: dict[str, str],
700740
token: str = None,
741+
external_session_id: str = None,
701742
timeout: int | None = None,
702743
is_fetch_query_status: bool = False,
703744
) -> dict[str, Any]:
@@ -713,9 +754,13 @@ def _get_request(
713754
headers,
714755
timeout=timeout,
715756
token=token,
757+
external_session_id=external_session_id,
716758
is_fetch_query_status=is_fetch_query_status,
717759
)
718-
if ret.get("code") == SESSION_EXPIRED_GS_CODE:
760+
if (
761+
ret.get("code") == SESSION_EXPIRED_GS_CODE
762+
and self._connection._authenticator != PAT_WITH_EXTERNAL_SESSION
763+
):
719764
try:
720765
ret = self._renew_session()
721766
except ReauthenticationRequest as ex:
@@ -743,6 +788,7 @@ def _post_request(
743788
headers,
744789
body,
745790
token=None,
791+
external_session_id: str | None = None,
746792
timeout: int | None = None,
747793
socket_timeout: int | None = None,
748794
_no_results: bool = False,
@@ -763,6 +809,7 @@ def _post_request(
763809
data=body,
764810
timeout=timeout,
765811
token=token,
812+
external_session_id=external_session_id,
766813
no_retry=no_retry,
767814
_include_retry_params=_include_retry_params,
768815
socket_timeout=socket_timeout,
@@ -775,7 +822,10 @@ def _post_request(
775822

776823
if ret.get("code") == MASTER_TOKEN_EXPIRED_GS_CODE:
777824
self._connection.expired = True
778-
elif ret.get("code") == SESSION_EXPIRED_GS_CODE:
825+
elif (
826+
ret.get("code") == SESSION_EXPIRED_GS_CODE
827+
and self._connection._authenticator != PAT_WITH_EXTERNAL_SESSION
828+
):
779829
try:
780830
ret = self._renew_session()
781831
except ReauthenticationRequest as ex:
@@ -900,6 +950,7 @@ def _request_exec_wrapper(
900950
retry_ctx,
901951
no_retry: bool = False,
902952
token=NO_TOKEN,
953+
external_session_id=NO_EXTERNAL_SESSION_ID,
903954
**kwargs,
904955
):
905956
conn = self._connection
@@ -928,6 +979,7 @@ def _request_exec_wrapper(
928979
headers=headers,
929980
data=data,
930981
token=token,
982+
external_session_id=external_session_id,
931983
raise_raw_http_failure=raise_raw_http_failure,
932984
**kwargs,
933985
)
@@ -1061,6 +1113,7 @@ def _request_exec(
10611113
headers,
10621114
data,
10631115
token,
1116+
external_session_id=None,
10641117
catch_okta_unauthorized_error: bool = False,
10651118
is_raw_text: bool = False,
10661119
is_raw_binary: bool = False,
@@ -1088,6 +1141,11 @@ def _request_exec(
10881141
# socket timeout is constant. You should be able to receive
10891142
# the response within the time. If not, ConnectReadTimeout or
10901143
# ReadTimeout is raised.
1144+
auth = (
1145+
PATWithExternalSessionAuth(token, external_session_id)
1146+
if (external_session_id is not None and token is not None)
1147+
else SnowflakeAuth(token)
1148+
)
10911149
raw_ret = session.request(
10921150
method=method,
10931151
url=full_url,
@@ -1096,7 +1154,7 @@ def _request_exec(
10961154
timeout=socket_timeout,
10971155
verify=True,
10981156
stream=is_raw_binary,
1099-
auth=SnowflakeAuth(token),
1157+
auth=auth,
11001158
)
11011159
download_end_time = get_time_millis()
11021160

test/auth/authorization_parameters.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,14 @@ def get_pat_connection_parameters(self) -> dict[str, str]:
216216
config["user"] = _get_env_variable("SNOWFLAKE_AUTH_TEST_BROWSER_USER")
217217

218218
return config
219+
220+
def get_pat_with_external_session_connection_parameters(
221+
self, external_session_id: str
222+
) -> dict[str, str]:
223+
config = self.basic_config.copy()
224+
225+
config["authenticator"] = "PROGRAMMATIC_ACCESS_TOKEN_WITH_EXTERNAL_SESSION"
226+
config["user"] = _get_env_variable("SNOWFLAKE_AUTH_TEST_BROWSER_USER")
227+
config["external_session_id"] = external_session_id
228+
229+
return config

test/auth/authorization_test_helper.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,33 @@ def connect_and_execute_simple_query(self):
106106
logger.error(e)
107107
return False
108108

109+
def connect_and_execute_set_session_state(self, key: str, value: str):
110+
try:
111+
logger.info("Trying to connect to Snowflake")
112+
with snowflake.connector.connect(**self.configuration) as con:
113+
result = con.cursor().execute(f"SET {key} = '{value}'")
114+
logger.debug(result.fetchall())
115+
logger.info("Successfully SET session variable")
116+
return True
117+
except Exception as e:
118+
self.error_msg = e
119+
logger.error(e)
120+
return False
121+
122+
def connect_and_execute_check_session_state(self, key: str):
123+
try:
124+
logger.info("Trying to connect to Snowflake")
125+
with snowflake.connector.connect(**self.configuration) as con:
126+
result = con.cursor().execute(f"SELECT 1, ${key}")
127+
value = result.fetchone()[1]
128+
logger.debug(value)
129+
logger.info("Successfully READ session variable")
130+
return value
131+
except Exception as e:
132+
self.error_msg = e
133+
logger.error(e)
134+
return False
135+
109136
def _provide_credentials(self, scenario: Scenario, login: str, password: str):
110137
try:
111138
webbrowser.register("xdg-open", None, webbrowser.GenericBrowser("xdg-open"))
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import uuid
2+
from test.auth.authorization_parameters import (
3+
AuthConnectionParameters,
4+
get_pat_setup_command_variables,
5+
)
6+
7+
import pytest
8+
from authorization_test_helper import AuthorizationTestHelper
9+
from test_pat import get_pat_token, remove_pat_token
10+
11+
EXTERNAL_SESSION_ID = str(uuid.uuid4())
12+
SESSION_VAR_KEY = "PAT_WITH_EXTERNAL_SESSION_TEST_KEY"
13+
SESSION_VAR_VALUE = "PAT_WITH_EXTERNAL_SESSION_TEST_VALUE"
14+
15+
16+
@pytest.mark.auth
17+
def test_pat_with_external_session_authN_success() -> None:
18+
pat_command_variables = get_pat_setup_command_variables()
19+
connection_parameters = AuthConnectionParameters().get_pat_connection_parameters()
20+
try:
21+
pat_command_variables = get_pat_token(pat_command_variables)
22+
connection_parameters["token"] = pat_command_variables["token"]
23+
connection_parameters["external_session_id"] = EXTERNAL_SESSION_ID
24+
connection_parameters["authenticator"] = "PAT_WITH_EXTERNAL_SESSION"
25+
test_helper = AuthorizationTestHelper(connection_parameters)
26+
test_helper.connect_and_execute_set_session_state(
27+
SESSION_VAR_KEY, SESSION_VAR_VALUE
28+
)
29+
ret = test_helper.connect_and_execute_check_session_state(SESSION_VAR_KEY)
30+
assert ret == SESSION_VAR_VALUE
31+
finally:
32+
remove_pat_token(pat_command_variables)
33+
assert test_helper.get_error_msg() == "", "Error message should be empty"
34+
35+
36+
@pytest.mark.auth
37+
def test_pat_with_external_session_authN_fail() -> None:
38+
pat_command_variables = get_pat_setup_command_variables()
39+
try:
40+
pat_command_variables = get_pat_token(pat_command_variables)
41+
connection_parameters = (
42+
AuthConnectionParameters().get_pat_connection_parameters()
43+
)
44+
connection_parameters["token"] = pat_command_variables["token"]
45+
connection_parameters["external_session_id"] = EXTERNAL_SESSION_ID
46+
connection_parameters["authenticator"] = "PAT_WITH_EXTERNAL_SESSION"
47+
test_helper = AuthorizationTestHelper(connection_parameters)
48+
test_helper.connect_and_execute_set_session_state(
49+
SESSION_VAR_KEY, SESSION_VAR_VALUE
50+
)
51+
connection_parameters["external_session_id"] = str(
52+
uuid.uuid4()
53+
) # User different external session
54+
test_helper = AuthorizationTestHelper(connection_parameters)
55+
ret = test_helper.connect_and_execute_check_session_state(SESSION_VAR_KEY)
56+
assert ret != SESSION_VAR_VALUE
57+
finally:
58+
remove_pat_token(pat_command_variables)
59+
print(test_helper.get_error_msg())
60+
assert (
61+
f"Session variable '${SESSION_VAR_KEY}' does not exist"
62+
in test_helper.get_error_msg()
63+
)

0 commit comments

Comments
 (0)