From 7df3485a7f1dc5dc0b67acc4a0be7ea8388998c7 Mon Sep 17 00:00:00 2001 From: Hashim1999164 <64767361+Hashim1999164@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:31:33 +0500 Subject: [PATCH 1/2] Keep custom public PKCE client_id in OAuth authorization code. --- src/snowflake/connector/auth/_oauth_base.py | 27 +++++++--- src/snowflake/connector/auth/oauth_code.py | 20 +++++--- .../aio/test_auth_oauth_auth_code_async.py | 14 +++++- test/unit/aio/test_auth_oauth_code_async.py | 14 +++++- test/unit/test_auth_oauth_auth_code.py | 50 ++++++++++++++++++- 5 files changed, 107 insertions(+), 18 deletions(-) diff --git a/src/snowflake/connector/auth/_oauth_base.py b/src/snowflake/connector/auth/_oauth_base.py index 85341b5c56..e432a52cd6 100644 --- a/src/snowflake/connector/auth/_oauth_base.py +++ b/src/snowflake/connector/auth/_oauth_base.py @@ -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: @@ -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) @@ -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): @@ -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: diff --git a/src/snowflake/connector/auth/oauth_code.py b/src/snowflake/connector/auth/oauth_code.py index 32933ba882..ef180227bc 100644 --- a/src/snowflake/connector/auth/oauth_code.py +++ b/src/snowflake/connector/auth/oauth_code.py @@ -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, @@ -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 ) @@ -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( diff --git a/test/unit/aio/test_auth_oauth_auth_code_async.py b/test/unit/aio/test_auth_oauth_auth_code_async.py index 2a149f92af..6cd644a6be 100644 --- a/test/unit/aio/test_auth_oauth_auth_code_async.py +++ b/test/unit/aio/test_auth_oauth_auth_code_async.py @@ -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", @@ -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, ), ], ) diff --git a/test/unit/aio/test_auth_oauth_code_async.py b/test/unit/aio/test_auth_oauth_code_async.py index 85f7984e0a..d97d515c1f 100644 --- a/test/unit/aio/test_auth_oauth_code_async.py +++ b/test/unit/aio/test_auth_oauth_code_async.py @@ -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", @@ -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, ), ], ) diff --git a/test/unit/test_auth_oauth_auth_code.py b/test/unit/test_auth_oauth_auth_code.py index 40ddf037a5..bd5f66b698 100644 --- a/test/unit/test_auth_oauth_auth_code.py +++ b/test/unit/test_auth_oauth_auth_code.py @@ -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", @@ -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", @@ -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( @@ -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"] ) From ecefd6f1efbb905962ff711d7f6be97bd9c94a0f Mon Sep 17 00:00:00 2001 From: Hashim1999164 <64767361+Hashim1999164@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:34:01 +0500 Subject: [PATCH 2/2] Note the public PKCE client_id fix in DESCRIPTION.md. --- DESCRIPTION.md | 1 + 1 file changed, 1 insertion(+) diff --git a/DESCRIPTION.md b/DESCRIPTION.md index bba69e9a7f..6482cad094 100644 --- a/DESCRIPTION.md +++ b/DESCRIPTION.md @@ -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.