Skip to content

Commit cb65adc

Browse files
SNOW-2110470: Support for local application OAuth by default (#2329)
1 parent 0641261 commit cb65adc

File tree

9 files changed

+406
-59
lines changed

9 files changed

+406
-59
lines changed

.github/workflows/build_test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ jobs:
172172
run: python -m pip install tox>=4
173173
- name: Run tests
174174
# To run a single test on GHA use the below command:
175-
# run: python -m tox run -e `echo py${PYTHON_VERSION/\./}-single-ci | sed 's/ /,/g'`
175+
# run: python -m tox run -e `echo py${PYTHON_VERSION/\./}-single-ci | sed 's/ /,/g'`
176176
run: python -m tox run -e `echo py${PYTHON_VERSION/\./}-{extras,unit,integ,pandas,sso}-ci | sed 's/ /,/g'`
177177

178178
env:
@@ -181,7 +181,7 @@ jobs:
181181
PYTEST_ADDOPTS: --color=yes --tb=short
182182
TOX_PARALLEL_NO_SPINNER: 1
183183
# To specify the test name (in single test mode) pass this env variable:
184-
# SINGLE_TEST_NAME: test/file/path::test_name
184+
# SINGLE_TEST_NAME: test/path/filename.py::test_name
185185
shell: bash
186186
- name: Combine coverages
187187
run: python -m tox run -e coverage --skip-missing-interpreters false

DESCRIPTION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Source code is also available at: https://github.com/snowflakedb/snowflake-conne
1818
- Fix `write_pandas` special characters usage in the location name.
1919
- Fix usage of `use_virtual_url` when building the location for gcs storage client.
2020
- Bind cryptography to <=44.0.3 to avoid issues with 45.0.0.
21+
- Added support for Snowflake OAuth for local applications.
2122

2223
- v3.15.0(Apr 29,2025)
2324
- Bumped up min boto and botocore version to 1.24.

src/snowflake/connector/auth/_oauth_base.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@
1212
from typing import TYPE_CHECKING, Any
1313
from urllib.error import HTTPError, URLError
1414

15-
from ..errorcode import ER_FAILED_TO_REQUEST, ER_IDP_CONNECTION_ERROR
15+
from ..errorcode import (
16+
ER_FAILED_TO_REQUEST,
17+
ER_IDP_CONNECTION_ERROR,
18+
ER_NO_CLIENT_ID,
19+
ER_NO_CLIENT_SECRET,
20+
)
21+
from ..errors import Error, ProgrammingError
1622
from ..network import OAUTH_AUTHENTICATOR
1723
from ..secret_detector import SecretDetector
1824
from ..token_cache import TokenCache, TokenKey, TokenType
@@ -185,6 +191,33 @@ def assertion_content(self) -> str:
185191
"""Returns the token."""
186192
return self._access_token or ""
187193

194+
@staticmethod
195+
def _validate_client_credentials_present(
196+
client_id: str, client_secret: str, connection: SnowflakeConnection
197+
) -> tuple[str, str]:
198+
if client_id is None or client_id == "":
199+
Error.errorhandler_wrapper(
200+
connection,
201+
None,
202+
ProgrammingError,
203+
{
204+
"msg": "Oauth code flow requirement 'client_id' is empty",
205+
"errno": ER_NO_CLIENT_ID,
206+
},
207+
)
208+
if client_secret is None or client_secret == "":
209+
Error.errorhandler_wrapper(
210+
connection,
211+
None,
212+
ProgrammingError,
213+
{
214+
"msg": "Oauth code flow requirement 'client_secret' is empty",
215+
"errno": ER_NO_CLIENT_SECRET,
216+
},
217+
)
218+
219+
return client_id, client_secret
220+
188221
def reauthenticate(
189222
self,
190223
*,

src/snowflake/connector/auth/oauth_code.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818
from ..compat import parse_qs, urlparse, urlsplit
1919
from ..constants import OAUTH_TYPE_AUTHORIZATION_CODE
2020
from ..errorcode import (
21+
ER_INVALID_VALUE,
2122
ER_OAUTH_CALLBACK_ERROR,
2223
ER_OAUTH_SERVER_TIMEOUT,
2324
ER_OAUTH_STATE_CHANGED,
2425
ER_UNABLE_TO_OPEN_BROWSER,
2526
)
27+
from ..errors import Error, ProgrammingError
2628
from ..token_cache import TokenCache
2729
from ._http_server import AuthHttpServer
2830
from ._oauth_base import AuthByOAuthBase
@@ -45,6 +47,8 @@ def _get_query_params(
4547
class AuthByOauthCode(AuthByOAuthBase):
4648
"""Authenticates user by OAuth code flow."""
4749

50+
_LOCAL_APPLICATION_CLIENT_CREDENTIALS = "LOCAL_APPLICATION"
51+
4852
def __init__(
4953
self,
5054
application: str,
@@ -54,13 +58,27 @@ def __init__(
5458
token_request_url: str,
5559
redirect_uri: str,
5660
scope: str,
61+
host: str,
5762
pkce_enabled: bool = True,
5863
token_cache: TokenCache | None = None,
5964
refresh_token_enabled: bool = False,
6065
external_browser_timeout: int | None = None,
6166
enable_single_use_refresh_tokens: bool = False,
67+
connection: SnowflakeConnection | None = None,
6268
**kwargs,
6369
) -> None:
70+
authentication_url, redirect_uri = self._validate_oauth_code_uris(
71+
authentication_url, redirect_uri, connection
72+
)
73+
client_id, client_secret = self._validate_client_credentials_with_defaults(
74+
client_id,
75+
client_secret,
76+
authentication_url,
77+
token_request_url,
78+
host,
79+
connection,
80+
)
81+
6482
super().__init__(
6583
client_id=client_id,
6684
client_secret=client_secret,
@@ -385,3 +403,77 @@ def _parse_authorization_redirected_request(
385403
},
386404
)
387405
return parsed.get("code", [None])[0], parsed.get("state", [None])[0]
406+
407+
@staticmethod
408+
def _is_snowflake_as_idp(
409+
authentication_url: str, token_request_url: str, host: str
410+
) -> bool:
411+
return (authentication_url == "" or host in authentication_url) and (
412+
token_request_url == "" or host in token_request_url
413+
)
414+
415+
def _eligible_for_default_client_credentials(
416+
self,
417+
client_id: str,
418+
client_secret: str,
419+
authorization_url: str,
420+
token_request_url: str,
421+
host: str,
422+
) -> bool:
423+
return (
424+
(client_id == "" or client_secret is None)
425+
and (client_secret == "" or client_secret is None)
426+
and self.__class__._is_snowflake_as_idp(
427+
authorization_url, token_request_url, host
428+
)
429+
)
430+
431+
def _validate_client_credentials_with_defaults(
432+
self,
433+
client_id: str,
434+
client_secret: str,
435+
authorization_url: str,
436+
token_request_url: str,
437+
host: str,
438+
connection: SnowflakeConnection,
439+
) -> tuple[str, str] | None:
440+
if self._eligible_for_default_client_credentials(
441+
client_id, client_secret, authorization_url, token_request_url, host
442+
):
443+
return (
444+
self.__class__._LOCAL_APPLICATION_CLIENT_CREDENTIALS,
445+
self.__class__._LOCAL_APPLICATION_CLIENT_CREDENTIALS,
446+
)
447+
else:
448+
self._validate_client_credentials_present(
449+
client_id, client_secret, connection
450+
)
451+
return client_id, client_secret
452+
453+
@staticmethod
454+
def _validate_oauth_code_uris(
455+
authorization_url: str, redirect_uri: str, connection: SnowflakeConnection
456+
) -> tuple[str, str]:
457+
if authorization_url and not authorization_url.startswith("https://"):
458+
Error.errorhandler_wrapper(
459+
connection,
460+
None,
461+
ProgrammingError,
462+
{
463+
"msg": "OAuth supports only authorization urls that use 'https' scheme",
464+
"errno": ER_INVALID_VALUE,
465+
},
466+
)
467+
if redirect_uri and not (
468+
redirect_uri.startswith("http://") or redirect_uri.startswith("https://")
469+
):
470+
Error.errorhandler_wrapper(
471+
connection,
472+
None,
473+
ProgrammingError,
474+
{
475+
"msg": "OAuth supports only authorization urls that use 'http(s)' scheme",
476+
"errno": ER_INVALID_VALUE,
477+
},
478+
)
479+
return authorization_url, redirect_uri

src/snowflake/connector/auth/oauth_credentials.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ def __init__(
2929
scope: str,
3030
token_cache: TokenCache | None = None,
3131
refresh_token_enabled: bool = False,
32+
connection: SnowflakeConnection | None = None,
3233
**kwargs,
3334
) -> None:
35+
self._validate_client_credentials_present(client_id, client_secret, connection)
3436
super().__init__(
3537
client_id=client_id,
3638
client_secret=client_secret,

src/snowflake/connector/connection.py

Lines changed: 2 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,6 @@
9292
ER_INVALID_VALUE,
9393
ER_INVALID_WIF_SETTINGS,
9494
ER_NO_ACCOUNT_NAME,
95-
ER_NO_CLIENT_ID,
96-
ER_NO_CLIENT_SECRET,
9795
ER_NO_NUMPY,
9896
ER_NO_PASSWORD,
9997
ER_NO_USER,
@@ -1203,14 +1201,14 @@ def __open_connection(self):
12031201
backoff_generator=self._backoff_generator,
12041202
)
12051203
elif self._authenticator == OAUTH_AUTHORIZATION_CODE:
1206-
self._check_oauth_parameters()
12071204
if self._role and (self._oauth_scope == ""):
12081205
# if role is known then let's inject it into scope
12091206
self._oauth_scope = _OAUTH_DEFAULT_SCOPE.format(role=self._role)
12101207
self.auth_class = AuthByOauthCode(
12111208
application=self.application,
12121209
client_id=self._oauth_client_id,
12131210
client_secret=self._oauth_client_secret,
1211+
host=self.host,
12141212
authentication_url=self._oauth_authorization_url.format(
12151213
host=self.host, port=self.port
12161214
),
@@ -1230,7 +1228,6 @@ def __open_connection(self):
12301228
enable_single_use_refresh_tokens=self._oauth_enable_single_use_refresh_tokens,
12311229
)
12321230
elif self._authenticator == OAUTH_CLIENT_CREDENTIALS:
1233-
self._check_oauth_parameters()
12341231
if self._role and (self._oauth_scope == ""):
12351232
# if role is known then let's inject it into scope
12361233
self._oauth_scope = _OAUTH_DEFAULT_SCOPE.format(role=self._role)
@@ -1248,6 +1245,7 @@ def __open_connection(self):
12481245
else None
12491246
),
12501247
refresh_token_enabled=self._oauth_enable_refresh_tokens,
1248+
connection=self,
12511249
)
12521250
elif self._authenticator == USR_PWD_MFA_AUTHENTICATOR:
12531251
self._session_parameters[PARAMETER_CLIENT_REQUEST_MFA_TOKEN] = (
@@ -2235,54 +2233,6 @@ def _check_experimental_authentication_flag(self) -> None:
22352233
},
22362234
)
22372235

2238-
def _check_oauth_parameters(self) -> None:
2239-
if self._oauth_client_id is None:
2240-
Error.errorhandler_wrapper(
2241-
self,
2242-
None,
2243-
ProgrammingError,
2244-
{
2245-
"msg": "Oauth code flow requirement 'client_id' is empty",
2246-
"errno": ER_NO_CLIENT_ID,
2247-
},
2248-
)
2249-
if self._oauth_client_secret is None:
2250-
Error.errorhandler_wrapper(
2251-
self,
2252-
None,
2253-
ProgrammingError,
2254-
{
2255-
"msg": "Oauth code flow requirement 'client_secret' is empty",
2256-
"errno": ER_NO_CLIENT_SECRET,
2257-
},
2258-
)
2259-
if (
2260-
self._oauth_authorization_url
2261-
and not self._oauth_authorization_url.startswith("https://")
2262-
):
2263-
Error.errorhandler_wrapper(
2264-
self,
2265-
None,
2266-
ProgrammingError,
2267-
{
2268-
"msg": "OAuth supports only authorization urls that use 'https' scheme",
2269-
"errno": ER_INVALID_VALUE,
2270-
},
2271-
)
2272-
if self._oauth_redirect_uri and not (
2273-
self._oauth_redirect_uri.startswith("http://")
2274-
or self._oauth_redirect_uri.startswith("https://")
2275-
):
2276-
Error.errorhandler_wrapper(
2277-
self,
2278-
None,
2279-
ProgrammingError,
2280-
{
2281-
"msg": "OAuth supports only authorization urls that use 'http(s)' scheme",
2282-
"errno": ER_INVALID_VALUE,
2283-
},
2284-
)
2285-
22862236
@staticmethod
22872237
def _detect_application() -> None | str:
22882238
if ENV_VAR_PARTNER in os.environ.keys():
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
{
2+
"mappings": [
3+
{
4+
"scenarioName": "Custom urls OAuth authorization code flow local application",
5+
"requiredScenarioState": "Started",
6+
"newScenarioState": "Authorized",
7+
"request": {
8+
"urlPathPattern": "/authorization",
9+
"method": "GET",
10+
"queryParameters": {
11+
"response_type": {
12+
"equalTo": "code"
13+
},
14+
"scope": {
15+
"equalTo": "session:role:ANALYST"
16+
},
17+
"code_challenge_method": {
18+
"equalTo": "S256"
19+
},
20+
"redirect_uri": {
21+
"equalTo": "http://localhost:8009/snowflake/oauth-redirect"
22+
},
23+
"code_challenge": {
24+
"matches": ".*"
25+
},
26+
"state": {
27+
"matches": ".*"
28+
},
29+
"client_id": {
30+
"equalTo": "LOCAL_APPLICATION"
31+
}
32+
}
33+
},
34+
"response": {
35+
"status": 302,
36+
"headers": {
37+
"Location": "http://localhost:8009/snowflake/oauth-redirect?code=123&state=abc123"
38+
}
39+
}
40+
},
41+
{
42+
"scenarioName": "Custom urls OAuth authorization code flow local application",
43+
"requiredScenarioState": "Authorized",
44+
"newScenarioState": "Acquired access token",
45+
"request": {
46+
"urlPathPattern": "/tokenrequest.*",
47+
"method": "POST",
48+
"headers": {
49+
"Authorization": {
50+
"contains": "Basic"
51+
},
52+
"Content-Type": {
53+
"contains": "application/x-www-form-urlencoded; charset=UTF-8"
54+
}
55+
},
56+
"bodyPatterns": [
57+
{
58+
"contains": "grant_type=authorization_code&code=123&redirect_uri=http%3A%2F%2Flocalhost%3A8009%2Fsnowflake%2Foauth-redirect&code_verifier="
59+
}
60+
]
61+
},
62+
"response": {
63+
"status": 200,
64+
"jsonBody": {
65+
"access_token": "access-token-123",
66+
"refresh_token": "123",
67+
"token_type": "Bearer",
68+
"username": "user",
69+
"scope": "refresh_token session:role:ANALYST",
70+
"expires_in": 600,
71+
"refresh_token_expires_in": 86399,
72+
"idpInitiated": false
73+
}
74+
}
75+
}
76+
]
77+
}

0 commit comments

Comments
 (0)