Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions DESCRIPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Source code is also available at: https://github.com/snowflakedb/snowflake-conne

# Release Notes
- v4.8.0(Sep 30,2026)
- Fixed OAuth authorization code flow replacing a custom public PKCE `client_id` with `LOCAL_APPLICATION` when `client_secret` was omitted (SNOW-4036712, #3011).
- Fixed external-browser (SSO) authentication to validate the `Origin` header on the local callback server, rejecting tokens delivered from unexpected origins. A trailing slash in the origin (e.g. `https://account.snowflakecomputing.com/`) is now accepted on par with the bare origin, matching JDBC and other driver behaviour. Preconnect probe connections (empty recv) no longer count against the retry budget and no longer abort the login flow.
- Added the `SNOWFLAKE_TLS_CIPHERS` environment variable to restrict which TLS ciphers the connector offers. It takes a colon-separated list; names beginning with `TLS_` are applied as TLS 1.3 cipher suites and the remainder as the cipher list for TLS 1.2 and below, so a single variable covers both. Leaving it unset keeps OpenSSL's defaults unchanged, and an unrecognized cipher name is rejected rather than silently ignored. The restriction covers Snowflake API traffic, cloud-storage (stage) transfers, OCSP/CRL fetches and IdP requests. Requests issued by the AWS and Azure SDKs, and asynchronous connections, are not covered — for TLS 1.3 suites specifically they cannot be, because the Python standard library exposes no API for restricting them.
- Raised the minimum `pyOpenSSL` requirement to 25.3.0, the first version providing `set_tls13_ciphersuites`. This does not narrow the set of installable versions in practice: earlier releases cap `cryptography` below 46 and so were already uninstallable alongside the connector's own `cryptography>=46.0.5` requirement.
Expand Down
27 changes: 21 additions & 6 deletions src/snowflake/connector/auth/_oauth_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,19 @@ def _do_refresh_token(self, conn: SnowflakeConnection) -> None:
# A lone remove() is safe and does not destroy macOS Keychain ACL.
self._invalidate_refresh_token()

def _oauth_credential_missing(self, value: str | None) -> bool:
return value is None or value == ""

def _is_public_oauth_client(self) -> bool:
return self._oauth_credential_missing(self._client_secret)

def _fields_for_token_request(self, fields: dict[str, str]) -> dict[str, str]:
# RFC 6749 public clients send client_id in the body and omit HTTP Basic.
if self._is_public_oauth_client() and self._client_id:
fields = dict(fields)
fields.setdefault("client_id", self._client_id)
return fields

def _get_refresh_token_response(
self, conn: SnowflakeConnection
) -> urllib3.BaseHTTPResponse | None:
Expand All @@ -490,6 +503,7 @@ def _get_refresh_token_response(
}
if self._scope:
fields["scope"] = self._scope
fields = self._fields_for_token_request(fields)
try:
# TODO(SNOW-2229411) Session manager should be used here. It may require additional security validation (since we would transition from PoolManager to requests.Session) and some parameters would be passed implicitly. OAuth token exchange must NOT reuse pooled HTTP sessions. We should create a fresh SessionManager with use_pooling=False for each call.
return self._post_token_request(conn, fields)
Expand Down Expand Up @@ -593,7 +607,7 @@ def _post_token_request(
self._token_request_url,
headers=self._create_token_request_headers(),
encode_multipart=False,
fields=fields,
fields=self._fields_for_token_request(fields),
)
except Exception as exc:
if not self._is_retryable_oauth_token_exception(exc):
Expand Down Expand Up @@ -698,14 +712,15 @@ def _is_retryable_oauth_token_exception(exc: BaseException) -> bool:
)

def _create_token_request_headers(self) -> dict[str, str]:
return {
"Authorization": "Basic "
+ base64.b64encode(
f"{self._client_id}:{self._client_secret}".encode()
).decode(),
headers = {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
}
if not self._is_public_oauth_client():
headers["Authorization"] = "Basic " + base64.b64encode(
f"{self._client_id}:{self._client_secret}".encode()
).decode()
return headers

@staticmethod
def _log_if_http_in_use(url: str) -> None:
Expand Down
20 changes: 14 additions & 6 deletions src/snowflake/connector/auth/oauth_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from ..constants import OAUTH_TYPE_AUTHORIZATION_CODE
from ..errorcode import (
ER_INVALID_VALUE,
ER_NO_CLIENT_ID,
ER_OAUTH_CALLBACK_ERROR,
ER_OAUTH_SERVER_TIMEOUT,
ER_OAUTH_STATE_CHANGED,
Expand Down Expand Up @@ -444,8 +445,8 @@ def _eligible_for_default_client_credentials(
host: str,
) -> bool:
return (
(client_id == "" or client_secret is None)
and (client_secret == "" or client_secret is None)
self._oauth_credential_missing(client_id)
and self._oauth_credential_missing(client_secret)
and self.__class__._is_snowflake_as_idp(
authorization_url, token_request_url, host
)
Expand All @@ -467,11 +468,18 @@ def _validate_client_credentials_with_defaults(
self.__class__._LOCAL_APPLICATION_CLIENT_CREDENTIALS,
self.__class__._LOCAL_APPLICATION_CLIENT_CREDENTIALS,
)
else:
self._validate_client_credentials_present(
client_id, client_secret, connection
if self._oauth_credential_missing(client_id):
Error.errorhandler_wrapper(
connection,
None,
ProgrammingError,
{
"msg": "Oauth code flow requirement 'client_id' is empty",
"errno": ER_NO_CLIENT_ID,
},
)
return client_id, client_secret
# Public PKCE clients supply a client_id and omit client_secret.
return client_id, client_secret

@staticmethod
def _validate_oauth_code_uris(
Expand Down
14 changes: 12 additions & 2 deletions test/unit/aio/test_auth_oauth_auth_code_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ def fake_get_request_token_response(_, fields: dict[str, str]):
"https://example.snowflakecomputing.com/oauth/authorize",
"https://example.snowflakecomputing.com/oauth/token",
False,
ProgrammingError,
None,
),
(
"Non-Snowflake IdP",
Expand Down Expand Up @@ -197,7 +197,17 @@ def fake_get_request_token_response(_, fields: dict[str, str]):
"https://example.snowflakecomputing.cn/oauth/authorize",
"https://example.snowflakecomputing.cn/oauth/token",
False,
ProgrammingError,
None,
),
(
"Public PKCE client with None secret keeps custom client_id",
"custom-public-client-id",
None,
"example.snowflakecomputing.com",
"https://example.snowflakecomputing.com/oauth/authorize",
"https://example.snowflakecomputing.com/oauth/token-request",
False,
None,
),
],
)
Expand Down
14 changes: 12 additions & 2 deletions test/unit/aio/test_auth_oauth_code_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ def fake_get_request_token_response(_, fields: dict[str, str]):
"https://example.snowflakecomputing.com/oauth/authorize",
"https://example.snowflakecomputing.com/oauth/token",
False,
ProgrammingError,
None,
),
(
"Non-Snowflake IdP",
Expand Down Expand Up @@ -177,7 +177,17 @@ def fake_get_request_token_response(_, fields: dict[str, str]):
"https://example.snowflakecomputing.cn/oauth/authorize",
"https://example.snowflakecomputing.cn/oauth/token",
False,
ProgrammingError,
None,
),
(
"Public PKCE client with None secret keeps custom client_id",
"custom-public-client-id",
None,
"example.snowflakecomputing.com",
"https://example.snowflakecomputing.com/oauth/authorize",
"https://example.snowflakecomputing.com/oauth/token-request",
False,
None,
),
],
)
Expand Down
50 changes: 48 additions & 2 deletions test/unit/test_auth_oauth_auth_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ def fake_get_request_token_response(_, fields: dict[str, str]):
"https://example.snowflakecomputing.com/oauth/authorize",
"https://example.snowflakecomputing.com/oauth/token",
False,
ProgrammingError,
None,
),
(
"Non-Snowflake IdP",
Expand Down Expand Up @@ -194,7 +194,7 @@ def fake_get_request_token_response(_, fields: dict[str, str]):
"https://example.snowflakecomputing.cn/oauth/authorize",
"https://example.snowflakecomputing.cn/oauth/token",
False,
ProgrammingError,
None,
),
(
"Uppercase account name — host case must not break idp detection",
Expand All @@ -216,6 +216,16 @@ def fake_get_request_token_response(_, fields: dict[str, str]):
True,
None,
),
(
"Public PKCE client with None secret keeps custom client_id",
"custom-public-client-id",
None,
"example.snowflakecomputing.com",
"https://example.snowflakecomputing.com/oauth/authorize",
"https://example.snowflakecomputing.com/oauth/token-request",
False,
None,
),
],
)
def test_eligible_for_default_client_credentials_via_constructor(
Expand Down Expand Up @@ -260,6 +270,42 @@ def assert_initialized_correctly() -> None:
assert_initialized_correctly()


def test_public_pkce_client_omits_basic_auth_and_sends_client_id():
auth = AuthByOauthCode(
application="app",
client_id="custom-public-client-id",
client_secret=None,
authentication_url="https://example.snowflakecomputing.com/oauth/authorize",
token_request_url="https://example.snowflakecomputing.com/oauth/token-request",
redirect_uri="http://127.0.0.1:8730/",
scope="session:role:TEST_ROLE",
host="example.snowflakecomputing.com",
)
headers = auth._create_token_request_headers()
assert "Authorization" not in headers
fields = auth._fields_for_token_request(
{"grant_type": "authorization_code", "code": "abc"}
)
assert fields["client_id"] == "custom-public-client-id"


def test_confidential_client_still_sends_basic_auth():
auth = AuthByOauthCode(
application="app",
client_id="testClientID",
client_secret="testClientSecret",
authentication_url="https://example.snowflakecomputing.com/oauth/authorize",
token_request_url="https://example.snowflakecomputing.com/oauth/token",
redirect_uri="https://redirectUri:{port}",
scope="scope",
host="example.snowflakecomputing.com",
)
headers = auth._create_token_request_headers()
assert headers["Authorization"].startswith("Basic ")
fields = auth._fields_for_token_request({"grant_type": "authorization_code"})
assert "client_id" not in fields


@pytest.mark.parametrize(
"authenticator", ["OAUTH_AUTHORIZATION_CODE", "oauth_authorization_code"]
)
Expand Down