Skip to content

Commit 2586881

Browse files
[SNOW-2409721] Add SPCS_TOKEN to SF login requests originating from SPCS containers (#2714)
1 parent 4f129aa commit 2586881

File tree

6 files changed

+350
-1
lines changed

6 files changed

+350
-1
lines changed

DESCRIPTION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Source code is also available at: https://github.com/snowflakedb/snowflake-conne
1010
- v4.2.0(TBD)
1111
- Added support for async I/O. Asynchronous version of connector is available via `snowflake.connector.aio` module.
1212
- Added `SnowflakeCursor.stats` property to expose granular DML statistics (rows inserted, deleted, updated, and duplicates) for operations like CTAS where `rowcount` is insufficient.
13+
- Added support for injecting SPCS service identifier token (`SPCS_TOKEN`) into login requests when present in SPCS containers.
1314

1415
- v4.1.1(TBD)
1516
- Relaxed pandas dependency requirements for Python below 3.12.

src/snowflake/connector/_utils.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
from __future__ import annotations
22

3+
import logging
4+
import os
35
import string
46
from enum import Enum
57
from inspect import stack
68
from random import choice
79
from threading import Timer
810
from uuid import UUID
911

12+
logger = logging.getLogger(__name__)
13+
1014

1115
class TempObjectType(Enum):
1216
TABLE = "TABLE"
@@ -96,3 +100,30 @@ def get_application_path() -> str:
96100
return outermost_frame.filename
97101
except Exception:
98102
return "unknown"
103+
104+
105+
_SPCS_TOKEN_ENV_VAR_NAME = "SF_SPCS_TOKEN_PATH"
106+
_SPCS_TOKEN_DEFAULT_PATH = "/snowflake/session/spcs_token"
107+
108+
109+
def get_spcs_token() -> str | None:
110+
"""Return the SPCS token read from the configured path, or None.
111+
112+
The path is determined by the SF_SPCS_TOKEN_PATH environment variable,
113+
falling back to ``/snowflake/session/spcs_token`` when unset.
114+
115+
Any I/O errors or missing/empty files are treated as \"no token\" and
116+
will not cause authentication to fail.
117+
"""
118+
path = os.getenv(_SPCS_TOKEN_ENV_VAR_NAME) or _SPCS_TOKEN_DEFAULT_PATH
119+
try:
120+
if not os.path.isfile(path):
121+
return None
122+
with open(path, encoding="utf-8") as f:
123+
token = f.read().strip()
124+
if not token:
125+
return None
126+
return token
127+
except Exception as exc: # pragma: no cover - best-effort logging only
128+
logger.debug("Failed to read SPCS token from %s: %s", path, exc)
129+
return None

src/snowflake/connector/aio/auth/_auth.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ async def authenticate(
108108
)
109109

110110
body = copy.deepcopy(body_template)
111+
# Add SPCS token if present, independent of authenticator type.
112+
self._add_spcs_token_to_body(body)
111113
# updating request body
112114
await auth_instance.update_body(body)
113115

@@ -221,6 +223,8 @@ async def post_request_wrapper(self, url, headers, body) -> None:
221223
):
222224
body = copy.deepcopy(body_template)
223225
body["inFlightCtx"] = ret["data"].get("inFlightCtx")
226+
# Add SPCS token to the follow-up login request as well.
227+
self._add_spcs_token_to_body(body)
224228
# final request to get tokens
225229
ret = await self._rest._post_request(
226230
url,
@@ -261,6 +265,8 @@ async def post_request_wrapper(self, url, headers, body) -> None:
261265
else None
262266
)
263267
body["data"]["CHOSEN_NEW_PASSWORD"] = password_callback()
268+
# Add SPCS token to the password change login request as well.
269+
self._add_spcs_token_to_body(body)
264270
# New Password input
265271
ret = await self._rest._post_request(
266272
url,

src/snowflake/connector/auth/_auth.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
load_pem_private_key,
1818
)
1919

20-
from .._utils import get_application_path
20+
from .._utils import get_application_path, get_spcs_token
2121
from ..compat import urlencode
2222
from ..constants import (
2323
DAY_IN_SECONDS,
@@ -95,6 +95,17 @@ def __init__(self, rest) -> None:
9595
self._rest = rest
9696
self._token_cache: TokenCache | None = None
9797

98+
def _add_spcs_token_to_body(self, body: dict[Any, Any]) -> None:
99+
"""Inject SPCS_TOKEN into the login request body when available.
100+
101+
This reads the SPCS token from the path specified by SF_SPCS_TOKEN_PATH,
102+
or from ``/snowflake/session/spcs_token`` when the env var is unset.
103+
"""
104+
spcs_token = get_spcs_token()
105+
if spcs_token is not None:
106+
# Ensure the \"data\" envelope exists and add the token.
107+
body.setdefault("data", {})["SPCS_TOKEN"] = spcs_token
108+
98109
@staticmethod
99110
def base_auth_data(
100111
user,
@@ -205,6 +216,8 @@ def authenticate(
205216
)
206217

207218
body = copy.deepcopy(body_template)
219+
# Add SPCS token if present, independent of authenticator type.
220+
self._add_spcs_token_to_body(body)
208221
# updating request body
209222
auth_instance.update_body(body)
210223

@@ -323,6 +336,8 @@ def post_request_wrapper(self, url, headers, body) -> None:
323336
):
324337
body = copy.deepcopy(body_template)
325338
body["inFlightCtx"] = ret["data"].get("inFlightCtx")
339+
# Add SPCS token to the follow-up login request as well.
340+
self._add_spcs_token_to_body(body)
326341
# final request to get tokens
327342
ret = self._rest._post_request(
328343
url,
@@ -363,6 +378,8 @@ def post_request_wrapper(self, url, headers, body) -> None:
363378
else None
364379
)
365380
body["data"]["CHOSEN_NEW_PASSWORD"] = password_callback()
381+
# Add SPCS token to the password change login request as well.
382+
self._add_spcs_token_to_body(body)
366383
# New Password input
367384
ret = self._rest._post_request(
368385
url,
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from unittest import mock
5+
6+
import pytest
7+
8+
import snowflake.connector.aio
9+
10+
11+
@pytest.mark.skipolddriver
12+
async def test_spcs_token_included_in_login_request_async(monkeypatch):
13+
"""Verify that SPCS_TOKEN is injected into async login request body when present."""
14+
15+
custom_path = "/custom/path/to/spcs_token"
16+
monkeypatch.setenv("SF_SPCS_TOKEN_PATH", custom_path)
17+
monkeypatch.setattr(
18+
"snowflake.connector._utils.os.path.isfile",
19+
lambda path: path == custom_path,
20+
raising=False,
21+
)
22+
mock_open = mock.mock_open(read_data="TEST_SPCS_TOKEN_ASYNC")
23+
monkeypatch.setattr("snowflake.connector._utils.open", mock_open, raising=False)
24+
25+
captured_requests: list[tuple[str, dict]] = []
26+
27+
async def mock_post_request(url, headers, body, **kwargs):
28+
captured_requests.append((url, json.loads(body)))
29+
return {
30+
"success": True,
31+
"message": None,
32+
"data": {
33+
"token": "TOKEN_ASYNC",
34+
"masterToken": "MASTER_TOKEN_ASYNC",
35+
},
36+
}
37+
38+
with mock.patch(
39+
"snowflake.connector.aio._network.SnowflakeRestful._post_request",
40+
side_effect=mock_post_request,
41+
):
42+
conn = snowflake.connector.aio.SnowflakeConnection(
43+
account="testaccount",
44+
user="testuser",
45+
password="testpwd",
46+
host="testaccount.snowflakecomputing.com",
47+
)
48+
await conn.connect()
49+
assert conn._rest.token == "TOKEN_ASYNC"
50+
assert conn._rest.master_token == "MASTER_TOKEN_ASYNC"
51+
await conn.close()
52+
53+
# Exactly one login-request should have been sent for this simple flow
54+
login_bodies = [body for (url, body) in captured_requests if "login-request" in url]
55+
assert len(login_bodies) == 1
56+
body = login_bodies[0]
57+
assert body["data"]["SPCS_TOKEN"] == "TEST_SPCS_TOKEN_ASYNC"
58+
59+
60+
@pytest.mark.skipolddriver
61+
async def test_spcs_token_not_included_when_file_missing_async(monkeypatch):
62+
"""Verify that SPCS_TOKEN is not added to async login request when file does not exist."""
63+
64+
monkeypatch.delenv("SF_SPCS_TOKEN_PATH", raising=False)
65+
66+
captured_requests: list[tuple[str, dict]] = []
67+
68+
async def mock_post_request(url, headers, body, **kwargs):
69+
captured_requests.append((url, json.loads(body)))
70+
return {
71+
"success": True,
72+
"message": None,
73+
"data": {
74+
"token": "TOKEN_ASYNC",
75+
"masterToken": "MASTER_TOKEN_ASYNC",
76+
},
77+
}
78+
79+
with mock.patch(
80+
"snowflake.connector.aio._network.SnowflakeRestful._post_request",
81+
side_effect=mock_post_request,
82+
):
83+
conn = snowflake.connector.aio.SnowflakeConnection(
84+
account="testaccount",
85+
user="testuser",
86+
password="testpwd",
87+
host="testaccount.snowflakecomputing.com",
88+
)
89+
await conn.connect()
90+
assert conn._rest.token == "TOKEN_ASYNC"
91+
assert conn._rest.master_token == "MASTER_TOKEN_ASYNC"
92+
await conn.close()
93+
94+
# Exactly one login-request should have been sent for this simple flow
95+
login_bodies = [body for (url, body) in captured_requests if "login-request" in url]
96+
assert len(login_bodies) == 1
97+
body = login_bodies[0]
98+
assert "SPCS_TOKEN" not in body["data"]
99+
100+
101+
@pytest.mark.skipolddriver
102+
async def test_spcs_token_default_path_used_when_env_unset_async(
103+
monkeypatch,
104+
):
105+
"""When SF_SPCS_TOKEN_PATH is not set, default path should be used (async)."""
106+
107+
monkeypatch.delenv("SF_SPCS_TOKEN_PATH", raising=False)
108+
109+
default_path = "/snowflake/session/spcs_token"
110+
monkeypatch.setattr(
111+
"snowflake.connector._utils.os.path.isfile",
112+
lambda path: path == default_path,
113+
raising=False,
114+
)
115+
mock_open = mock.mock_open(read_data="DEFAULT_PATH_SPCS_TOKEN_ASYNC")
116+
monkeypatch.setattr("snowflake.connector._utils.open", mock_open, raising=False)
117+
118+
captured_requests: list[tuple[str, dict]] = []
119+
120+
async def mock_post_request(url, headers, body, **kwargs):
121+
captured_requests.append((url, json.loads(body)))
122+
return {
123+
"success": True,
124+
"message": None,
125+
"data": {
126+
"token": "TOKEN_ASYNC",
127+
"masterToken": "MASTER_TOKEN_ASYNC",
128+
},
129+
}
130+
131+
with mock.patch(
132+
"snowflake.connector.aio._network.SnowflakeRestful._post_request",
133+
side_effect=mock_post_request,
134+
):
135+
conn = snowflake.connector.aio.SnowflakeConnection(
136+
account="testaccount",
137+
user="testuser",
138+
password="testpwd",
139+
host="testaccount.snowflakecomputing.com",
140+
)
141+
await conn.connect()
142+
assert conn._rest.token == "TOKEN_ASYNC"
143+
assert conn._rest.master_token == "MASTER_TOKEN_ASYNC"
144+
await conn.close()
145+
146+
# Exactly one login-request should have been sent for this simple flow
147+
login_bodies = [body for (url, body) in captured_requests if "login-request" in url]
148+
assert len(login_bodies) == 1
149+
body = login_bodies[0]
150+
assert body["data"]["SPCS_TOKEN"] == "DEFAULT_PATH_SPCS_TOKEN_ASYNC"

0 commit comments

Comments
 (0)