Skip to content

Commit 201ddc5

Browse files
Merge branch 'main' into zyao-SNOW-2483517-sproc-continuous-integration
2 parents 840feb5 + 11803c5 commit 201ddc5

23 files changed

+790
-76
lines changed

DESCRIPTION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Source code is also available at: https://github.com/snowflakedb/snowflake-conne
1414
- Pin lower versions of dependencies to oldest version without vulnerabilities.
1515
- Added no_proxy parameter for proxy configuration without using environmental variables.
1616
- Added OAUTH_AUTHORIZATION_CODE and OAUTH_CLIENT_CREDENTIALS to list of authenticators that don't require user to be set
17+
- Added `oauth_socket_uri` connection parameter allowing to separate server and redirect URIs for local OAuth server.
1718

1819
- v4.0.0(October 09,2025)
1920
- Added support for checking certificates revocation using revocation lists (CRLs)
-3 Bytes
Binary file not shown.
-2 Bytes
Binary file not shown.

ci/wif/parameters/rsa_wif_gcp.gpg

-14 Bytes
Binary file not shown.

src/snowflake/connector/auth/_auth.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,16 @@ def read_temporary_credentials(
514514
user: str,
515515
session_parameters: dict[str, Any],
516516
) -> None:
517+
"""Attempt to load cached credentials to skip interactive authentication.
518+
519+
SSO (ID_TOKEN): If present, avoids opening browser for external authentication.
520+
Controlled by client_store_temporary_credential parameter.
521+
522+
MFA (MFA_TOKEN): If present, skips MFA prompt on next connection.
523+
Controlled by client_request_mfa_token parameter.
524+
525+
If cached tokens are expired/invalid, they're deleted and normal auth proceeds.
526+
"""
517527
if session_parameters.get(PARAMETER_CLIENT_STORE_TEMPORARY_CREDENTIAL, False):
518528
self._rest.id_token = self._read_temporary_credential(
519529
host,
@@ -549,6 +559,13 @@ def write_temporary_credentials(
549559
session_parameters: dict[str, Any],
550560
response: dict[str, Any],
551561
) -> None:
562+
"""Cache credentials received from successful authentication for future use.
563+
564+
Tokens are only cached if:
565+
1. Server returned the token in response (server-side caching must be enabled)
566+
2. Client has caching enabled via session parameters
567+
3. User consented to caching (consent_cache_id_token for ID tokens)
568+
"""
552569
if (
553570
self._rest._connection.auth_class.consent_cache_id_token
554571
and session_parameters.get(

src/snowflake/connector/auth/_http_server.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,10 @@ def __init__(
7070
self,
7171
uri: str,
7272
buf_size: int = 16384,
73+
redirect_uri: str | None = None,
7374
) -> None:
7475
parsed_uri = urllib.parse.urlparse(uri)
76+
parsed_redirect = urllib.parse.urlparse(redirect_uri) if redirect_uri else None
7577
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
7678
self.buf_size = buf_size
7779
if os.getenv("SNOWFLAKE_AUTH_SOCKET_REUSE_PORT", "False").lower() == "true":
@@ -82,30 +84,34 @@ def __init__(
8284
else:
8385
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
8486

85-
port = parsed_uri.port or 0
87+
if parsed_redirect and self._is_local_uri(parsed_redirect):
88+
server_port = parsed_redirect.port or 0
89+
else:
90+
server_port = parsed_uri.port or 0
91+
8692
for attempt in range(1, self.DEFAULT_MAX_ATTEMPTS + 1):
8793
try:
8894
self._socket.bind(
8995
(
9096
parsed_uri.hostname,
91-
port,
97+
server_port,
9298
)
9399
)
94100
break
95101
except socket.gaierror as ex:
96102
logger.error(
97-
f"Failed to bind authorization callback server to port {port}: {ex}"
103+
f"Failed to bind authorization callback server to port {server_port}: {ex}"
98104
)
99105
raise
100106
except OSError as ex:
101107
if attempt == self.DEFAULT_MAX_ATTEMPTS:
102108
logger.error(
103-
f"Failed to bind authorization callback server to port {port}: {ex}"
109+
f"Failed to bind authorization callback server to port {server_port}: {ex}"
104110
)
105111
raise
106112
logger.warning(
107113
f"Attempt {attempt}/{self.DEFAULT_MAX_ATTEMPTS}. "
108-
f"Failed to bind authorization callback server to port {port}: {ex}"
114+
f"Failed to bind authorization callback server to port {server_port}: {ex}"
109115
)
110116
time.sleep(self.PORT_BIND_TIMEOUT / self.PORT_BIND_MAX_ATTEMPTS)
111117
try:
@@ -114,16 +120,47 @@ def __init__(
114120
logger.error(f"Failed to start listening for auth callback: {ex}")
115121
self.close()
116122
raise
117-
port = self._socket.getsockname()[1]
123+
124+
server_port = self._socket.getsockname()[1]
118125
self._uri = urllib.parse.ParseResult(
119126
scheme=parsed_uri.scheme,
120-
netloc=parsed_uri.hostname + ":" + str(port),
127+
netloc=parsed_uri.hostname + ":" + str(server_port),
121128
path=parsed_uri.path,
122129
params=parsed_uri.params,
123130
query=parsed_uri.query,
124131
fragment=parsed_uri.fragment,
125132
)
126133

134+
if parsed_redirect:
135+
if (
136+
self._is_local_uri(parsed_redirect)
137+
and server_port != parsed_redirect.port
138+
):
139+
logger.debug(
140+
f"Updating redirect port {parsed_redirect.port} to match the server port {server_port}."
141+
)
142+
self._redirect_uri = urllib.parse.ParseResult(
143+
scheme=parsed_redirect.scheme,
144+
netloc=parsed_redirect.hostname + ":" + str(server_port),
145+
path=parsed_redirect.path,
146+
params=parsed_redirect.params,
147+
query=parsed_redirect.query,
148+
fragment=parsed_redirect.fragment,
149+
)
150+
else:
151+
self._redirect_uri = parsed_redirect
152+
else:
153+
# For backwards compatibility
154+
self._redirect_uri = self._uri
155+
156+
@staticmethod
157+
def _is_local_uri(uri):
158+
return uri.hostname in ("localhost", "127.0.0.1")
159+
160+
@property
161+
def redirect_uri(self) -> str | None:
162+
return self._redirect_uri.geturl()
163+
127164
@property
128165
def url(self) -> str:
129166
return self._uri.geturl()

src/snowflake/connector/auth/_oauth_base.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@
3535

3636

3737
class _OAuthTokensMixin:
38+
"""Manages OAuth token caching to avoid repeated browser authentication flows.
39+
40+
Access tokens: Short-lived (typically 10 minutes), cached to avoid immediate re-auth.
41+
Refresh tokens: Long-lived (hours/days), used to obtain new access tokens silently.
42+
43+
Tokens are cached per (user, IDP host) to support multiple OAuth providers/accounts.
44+
"""
45+
3846
def __init__(
3947
self,
4048
token_cache: TokenCache | None,
@@ -77,12 +85,18 @@ def _pop_cached_token(self, key: TokenKey | None) -> str | None:
7785
return self._token_cache.retrieve(key)
7886

7987
def _pop_cached_access_token(self) -> bool:
80-
"""Retrieves OAuth access token from the token cache if enabled"""
88+
"""Retrieves OAuth access token from the token cache if enabled, available and still valid.
89+
90+
Returns True if cached token found, allowing authentication to skip OAuth flow.
91+
"""
8192
self._access_token = self._pop_cached_token(self._get_access_token_cache_key())
8293
return self._access_token is not None
8394

8495
def _pop_cached_refresh_token(self) -> bool:
85-
"""Retrieves OAuth refresh token from the token cache if enabled"""
96+
"""Retrieves OAuth refresh token from the token cache (if enabled) to silently obtain new access token.
97+
98+
Returns True if refresh token found, enabling automatic token renewal without user interaction.
99+
"""
86100
if self._refresh_token_enabled:
87101
self._refresh_token = self._pop_cached_token(
88102
self._get_refresh_token_cache_key()

src/snowflake/connector/auth/oauth_code.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def __init__(
6565
external_browser_timeout: int | None = None,
6666
enable_single_use_refresh_tokens: bool = False,
6767
connection: SnowflakeConnection | None = None,
68+
uri: str | None = None,
6869
**kwargs,
6970
) -> None:
7071
authentication_url, redirect_uri = self._validate_oauth_code_uris(
@@ -92,6 +93,7 @@ def __init__(
9293
self._origin: str | None = None
9394
self._authentication_url = authentication_url
9495
self._redirect_uri = redirect_uri
96+
self._uri = uri
9597
self._state = secrets.token_urlsafe(43)
9698
logger.debug("chose oauth state: %s", "".join("*" for _ in self._state))
9799
self._protocol = "http"
@@ -117,7 +119,10 @@ def _request_tokens(
117119
) -> (str | None, str | None):
118120
"""Web Browser based Authentication."""
119121
logger.debug("authenticating with OAuth authorization code flow")
120-
with AuthHttpServer(self._redirect_uri) as callback_server:
122+
with AuthHttpServer(
123+
redirect_uri=self._redirect_uri,
124+
uri=self._uri or self._redirect_uri, # for backward compatibility
125+
) as callback_server:
121126
code = self._do_authorization_request(callback_server, conn)
122127
return self._do_token_request(code, callback_server, conn)
123128

@@ -260,7 +265,7 @@ def _do_authorization_request(
260265
connection: SnowflakeConnection,
261266
) -> str | None:
262267
authorization_request = self._construct_authorization_request(
263-
callback_server.url
268+
callback_server.redirect_uri
264269
)
265270
logger.debug("step 1: going to open authorization URL")
266271
print(
@@ -315,7 +320,7 @@ def _do_token_request(
315320
fields = {
316321
"grant_type": "authorization_code",
317322
"code": code,
318-
"redirect_uri": callback_server.url,
323+
"redirect_uri": callback_server.redirect_uri,
319324
}
320325
if self._enable_single_use_refresh_tokens:
321326
fields["enable_single_use_refresh_tokens"] = "true"

src/snowflake/connector/connection.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -280,8 +280,13 @@ def _get_private_bytes_from_file(
280280
"support_negative_year": (True, bool), # snowflake
281281
"log_max_query_length": (LOG_MAX_QUERY_LENGTH, int), # snowflake
282282
"disable_request_pooling": (False, bool), # snowflake
283-
# enable temporary credential file for Linux, default false. Mac/Win will overlook this
283+
# Cache SSO ID tokens to avoid repeated browser popups. Must be enabled on the server-side.
284+
# Storage: keyring (macOS/Windows), file (Linux). Auto-enabled on macOS/Windows.
285+
# Sets session PARAMETER_CLIENT_STORE_TEMPORARY_CREDENTIAL as well
284286
"client_store_temporary_credential": (False, bool),
287+
# Cache MFA tokens to skip MFA prompts on reconnect. Must be enabled on the server-side.
288+
# Storage: keyring (macOS/Windows), file (Linux). Auto-enabled on macOS/Windows.
289+
# In driver, we extract this from session using PARAMETER_CLIENT_REQUEST_MFA_TOKEN.
285290
"client_request_mfa_token": (False, bool),
286291
"use_openssl_only": (
287292
True,
@@ -384,6 +389,11 @@ def _get_private_bytes_from_file(
384389
# SNOW-1825621: OAUTH implementation
385390
),
386391
"oauth_redirect_uri": ("http://127.0.0.1", str),
392+
"oauth_socket_uri": (
393+
"http://127.0.0.1",
394+
str,
395+
# SNOW-2194055: Separate server and redirect URIs in AuthHttpServer
396+
),
387397
"oauth_scope": (
388398
"",
389399
str,
@@ -1082,22 +1092,23 @@ def connect(self, **kwargs) -> None:
10821092

10831093
self._crl_config: CRLConfig = CRLConfig.from_connection(self)
10841094

1095+
no_proxy_csv_str = (
1096+
",".join(str(x) for x in self.no_proxy)
1097+
if (
1098+
self.no_proxy is not None
1099+
and isinstance(self.no_proxy, Iterable)
1100+
and not isinstance(self.no_proxy, (str, bytes))
1101+
)
1102+
else self.no_proxy
1103+
)
10851104
self._http_config = HttpConfig(
10861105
adapter_factory=ProxySupportAdapterFactory(),
10871106
use_pooling=(not self.disable_request_pooling),
10881107
proxy_host=self.proxy_host,
10891108
proxy_port=self.proxy_port,
10901109
proxy_user=self.proxy_user,
10911110
proxy_password=self.proxy_password,
1092-
no_proxy=(
1093-
",".join(str(x) for x in self.no_proxy)
1094-
if (
1095-
self.no_proxy is not None
1096-
and isinstance(self.no_proxy, Iterable)
1097-
and not isinstance(self.no_proxy, (str, bytes))
1098-
)
1099-
else self.no_proxy
1100-
),
1111+
no_proxy=no_proxy_csv_str,
11011112
)
11021113
self._session_manager = SessionManagerFactory.get_manager(self._http_config)
11031114

@@ -1157,7 +1168,8 @@ def close(self, retry: bool = True) -> None:
11571168

11581169
# close telemetry first, since it needs rest to send remaining data
11591170
logger.debug("closed")
1160-
self._telemetry.close(send_on_close=bool(retry and self.telemetry_enabled))
1171+
if self.telemetry_enabled:
1172+
self._telemetry.close(retry=retry)
11611173
if (
11621174
self._all_async_queries_finished()
11631175
and not self._server_session_keep_alive
@@ -1390,9 +1402,11 @@ def __open_connection(self):
13901402
backoff_generator=self._backoff_generator,
13911403
)
13921404
elif self._authenticator == EXTERNAL_BROWSER_AUTHENTICATOR:
1405+
# Enable SSO credential caching
13931406
self._session_parameters[
13941407
PARAMETER_CLIENT_STORE_TEMPORARY_CREDENTIAL
13951408
] = (self._client_store_temporary_credential if IS_LINUX else True)
1409+
# Try to load cached ID token to avoid browser popup
13961410
auth.read_temporary_credentials(
13971411
self.host,
13981412
self.user,
@@ -1456,6 +1470,7 @@ def __open_connection(self):
14561470
host=self.host, port=self.port
14571471
),
14581472
redirect_uri=self._oauth_redirect_uri,
1473+
uri=self._oauth_socket_uri,
14591474
scope=self._oauth_scope,
14601475
pkce_enabled=not self._oauth_disable_pkce,
14611476
token_cache=(
@@ -1483,9 +1498,11 @@ def __open_connection(self):
14831498
connection=self,
14841499
)
14851500
elif self._authenticator == USR_PWD_MFA_AUTHENTICATOR:
1501+
# Enable MFA token caching
14861502
self._session_parameters[PARAMETER_CLIENT_REQUEST_MFA_TOKEN] = (
14871503
self._client_request_mfa_token if IS_LINUX else True
14881504
)
1505+
# Try to load cached MFA token to skip MFA prompt
14891506
if self._session_parameters[PARAMETER_CLIENT_REQUEST_MFA_TOKEN]:
14901507
auth.read_temporary_credentials(
14911508
self.host,

src/snowflake/connector/constants.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,6 @@ class IterUnit(Enum):
442442
ENV_VAR_PARTNER = "SF_PARTNER"
443443
ENV_VAR_TEST_MODE = "SNOWFLAKE_TEST_MODE"
444444

445-
446445
_DOMAIN_NAME_MAP = {_DEFAULT_HOSTNAME_TLD: "GLOBAL", _CHINA_HOSTNAME_TLD: "CHINA"}
447446

448447
_CONNECTIVITY_ERR_MSG = (

0 commit comments

Comments
 (0)