Skip to content

Commit 1c486fb

Browse files
author
Siting Ren
authored
OAuth token refresh (Part 1) (#547)
1 parent 3610648 commit 1c486fb

File tree

14 files changed

+367
-34
lines changed

14 files changed

+367
-34
lines changed

.github/workflows/ci.yaml

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,20 @@ jobs:
88
runs-on: ubuntu-latest
99
strategy:
1010
matrix:
11-
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy3.9']
11+
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy3.10']
12+
13+
env:
14+
REALM: test
15+
USER: oauth_user
16+
PASSWORD: password
17+
CLIENT_ID: vertica
18+
CLIENT_SECRET: P9f8350QQIUhFfK1GF5sMhq4Dm3P6Sbs
1219

1320
steps:
1421
- name: Check out repository
15-
uses: actions/checkout@v3
22+
uses: actions/checkout@v4
1623
- name: Set up Python ${{ matrix.python-version }}
17-
uses: actions/setup-python@v4
24+
uses: actions/setup-python@v5
1825
with:
1926
python-version: ${{ matrix.python-version }}
2027
- name: Set up a Keycloak docker container
@@ -47,12 +54,6 @@ jobs:
4754
echo "Wait for keycloak ready ..."
4855
bash -c 'while true; do curl -s localhost:8080 &>/dev/null; ret=$?; [[ $ret -eq 0 ]] && break; echo "..."; sleep 3; done'
4956
50-
REALM="test"
51-
USER="oauth_user"
52-
PASSWORD="password"
53-
CLIENT_ID="vertica"
54-
CLIENT_SECRET="P9f8350QQIUhFfK1GF5sMhq4Dm3P6Sbs"
55-
5657
docker exec -i keycloak /bin/bash <<EOF
5758
/opt/keycloak/bin/kcadm.sh config credentials --server http://localhost:8080 --realm master --user admin --password admin
5859
/opt/keycloak/bin/kcadm.sh create realms -s realm=${REALM} -s enabled=true
@@ -74,6 +75,7 @@ jobs:
7475
--data-urlencode "client_secret=${CLIENT_SECRET}" \
7576
--data-urlencode 'grant_type=password' -o oauth.json
7677
cat oauth.json | python3 -c 'import json,sys;obj=json.load(sys.stdin);print(obj["access_token"])' > access_token.txt
78+
cat oauth.json | python3 -c 'import json,sys;obj=json.load(sys.stdin);print(obj["refresh_token"])' > refresh_token.txt
7779
7880
docker exec -u dbadmin vertica_docker /opt/vertica/bin/vsql -c "CREATE AUTHENTICATION v_oauth METHOD 'oauth' HOST '0.0.0.0/0';"
7981
docker exec -u dbadmin vertica_docker /opt/vertica/bin/vsql -c "ALTER AUTHENTICATION v_oauth SET client_id = '${CLIENT_ID}';"
@@ -95,5 +97,10 @@ jobs:
9597
run: |
9698
export VP_TEST_USER=dbadmin
9799
export VP_TEST_OAUTH_ACCESS_TOKEN=`cat access_token.txt`
98-
export VP_TEST_OAUTH_USER=oauth_user
100+
export VP_TEST_OAUTH_REFRESH_TOKEN=`cat refresh_token.txt`
101+
export VP_TEST_OAUTH_USER=${USER}
102+
export VP_TEST_OAUTH_CLIENT_ID=${CLIENT_ID}
103+
export VP_TEST_OAUTH_CLIENT_SECRET=${CLIENT_SECRET}
104+
export VP_TEST_OAUTH_TOKEN_URL="http://`hostname`:8080/realms/${REALM}/protocol/openid-connect/token"
105+
export VP_TEST_OAUTH_DISCOVERY_URL="http://`hostname`:8080/realms/${REALM}/.well-known/openid-configuration"
99106
tox -e py

README.md

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ with vertica_python.connect(**conn_info) as connection:
9090
| ------------- | ------------- |
9191
| host | The server host of the connection. This can be a host name or an IP address. <br>**_Default_**: "localhost" |
9292
| port | The port of the connection. <br>**_Default_**: 5433 |
93-
| user | The database user name to use to connect to the database. <br>**_Default_**: OS login user name |
93+
| user | The database user name to use to connect to the database. <br>**_Default_**:<br>&nbsp;&nbsp;&nbsp;&nbsp;(for non-OAuth connections) OS login user name <br>&nbsp;&nbsp;&nbsp;&nbsp;(for OAuth connections) "" |
9494
| password | The password to use to log into the database. <br>**_Default_**: "" |
9595
| database | The database name. <br>**_Default_**: "" |
9696
| autocommit | See [Autocommit](#autocommit). <br>**_Default_**: False |
@@ -103,7 +103,9 @@ with vertica_python.connect(**conn_info) as connection:
103103
| kerberos_service_name | See [Kerberos Authentication](#kerberos-authentication). <br>**_Default_**: "vertica" |
104104
| log_level | See [Logging](#logging). |
105105
| log_path | See [Logging](#logging). |
106-
| oauth_access_token | To authenticate via OAuth, provide an OAuth Access Token that authorizes a user to the database. <br>**_Default_**: "" |
106+
| oauth_access_token | See [OAuth Authentication](#oauth-authentication). <br>**_Default_**: "" |
107+
| oauth_refresh_token | See [OAuth Authentication](#oauth-authentication). <br>**_Default_**: "" |
108+
| oauth_config | See [OAuth Authentication](#oauth-authentication). <br>**_Default_**: {} |
107109
| request_complex_types | See [SQL Data conversion to Python objects](#sql-data-conversion-to-python-objects). <br>**_Default_**: True |
108110
| session_label | Sets a label for the connection on the server. This value appears in the client_label column of the _v_monitor.sessions_ system table. <br>**_Default_**: an auto-generated label with format of `vertica-python-{version}-{random_uuid}` |
109111
| ssl | See [TLS/SSL](#tlsssl). <br>**_Default_**: False (disabled) |
@@ -141,7 +143,7 @@ with vertica_python.connect(dsn=connection_str, **additional_info) as conn:
141143
```
142144

143145
#### TLS/SSL
144-
You can pass `True` to `ssl` to enable TLS/SSL connection (Internally [ssl.wrap_socket(sock)](https://docs.python.org/3/library/ssl.html#ssl.wrap_socket) is called).
146+
You can pass `True` to `ssl` to enable TLS/SSL connection (equivalent to TLSMode=require).
145147

146148
```python
147149
import vertica_python
@@ -258,6 +260,50 @@ with vertica_python.connect(**conn_info) as conn:
258260
# do things
259261
```
260262

263+
#### OAuth Authentication
264+
To authenticate via OAuth, one way is to provide an `oauth_access_token` that authorizes a user to the database.
265+
```python
266+
import vertica_python
267+
268+
conn_info = {'host': '127.0.0.1',
269+
'port': 5433,
270+
'database': 'a_database',
271+
# valid OAuth access token
272+
'oauth_access_token': 'xxxxxx'}
273+
274+
with vertica_python.connect(**conn_info) as conn:
275+
# do things
276+
```
277+
In cases where `oauth_access_token` is not set or introspection fails (e.g. when the access token expires), the client can do a token refresh when both `oauth_refresh_token` and `oauth_config` are set. The client will retrieve a new access token from the identity provider and use it to connect with the database.
278+
```python
279+
import vertica_python
280+
281+
conn_info = {'host': '127.0.0.1',
282+
'port': 5433,
283+
'database': 'a_database',
284+
# OAuth refresh token and configurations
285+
'oauth_refresh_token': 'xxxxxx',
286+
'oauth_config': {
287+
'client_secret': 'wK3SqFbExDS',
288+
'client_id': 'vertica',
289+
'token_url': 'https://example.com:8443/realms/master/protocol/openid-connect/token',
290+
}
291+
}
292+
293+
with vertica_python.connect(**conn_info) as conn:
294+
# do things
295+
```
296+
The following table lists the `oauth_config` parameters used to configure OAuth token refresh:
297+
298+
| Parameter | Description |
299+
| ------------- | ------------- |
300+
| client_id | The client ID of the client application registered in the identity provider. |
301+
| client_secret | The client secret of the client application registered in the identity provider.|
302+
| token_url | The endpoint to which token refresh requests are sent. The format for this depends on your provider. For examples, see the [Keycloak](https://www.keycloak.org/docs/latest/securing_apps/#token-endpoint) and [Okta](https://developer.okta.com/docs/reference/api/oidc/#token) documentation.|
303+
| discovery_url | Also known as the [OpenID Provider Configuration Document](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest), this endpoint contains a list of all other endpoints supported by the identity provider. If set, *token_url* do not need to be specified.<br>If you set both *discovery_url* and *token_url*, then *token_url* takes precedence.|
304+
| scope | The requested OAuth scopes, delimited with spaces. These scopes define the extent of access to the resource server (in this case, Vertica) granted to the client by the access token. For details, see the [OAuth documentation](https://www.oauth.com/oauth2-servers/scope/defining-scopes/). |
305+
306+
261307
#### Logging
262308
Logging is disabled by default if neither ```log_level``` or ```log_path``` are set. Passing value to at least one of those options to enable logging.
263309

requirements-dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ pytest
22
pytest-timeout
33
python-dateutil
44
six
5+
requests
56
tox
67
#kerberos

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@
5959
python_requires=">=3.7",
6060
install_requires=[
6161
'python-dateutil>=1.5',
62-
'six>=1.10.0'
62+
'six>=1.10.0',
63+
'requests>=2.26.0'
6364
],
6465
classifiers=[
6566
"Development Status :: 5 - Production/Stable",

vertica_python/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@
5959
version_info = (1, 3, 8)
6060
__version__ = '.'.join(map(str, version_info))
6161

62-
# The protocol version (3.15) implemented in this library.
63-
PROTOCOL_VERSION = 3 << 16 | 15
62+
# The protocol version (3.16) implemented in this library.
63+
PROTOCOL_VERSION = 3 << 16 | 16
6464

6565
apilevel = 2.0
6666
threadsafety = 1 # Threads may share the module, but not connections!

vertica_python/errors.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@ class KerberosError(ConnectionError):
103103
class SSLNotSupported(ConnectionError):
104104
pass
105105

106+
class OAuthConfigurationError(ConnectionError):
107+
pass
108+
109+
class OAuthEndpointDiscoveryError(ConnectionError):
110+
pass
111+
112+
class OAuthTokenRefreshError(ConnectionError):
113+
pass
106114

107115
class MessageError(InternalError):
108116
pass

vertica_python/tests/common/base.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@
5656
'password': '',
5757
'database': '',
5858
'oauth_access_token': '',
59+
'oauth_refresh_token': '',
60+
'oauth_client_id': '',
61+
'oauth_client_secret': '',
62+
'oauth_token_url': '',
63+
'oauth_discovery_url': '',
5964
'oauth_user': '',
6065
}
6166

vertica_python/tests/common/vp_test.conf.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,9 @@ VP_TEST_LOG_DIR=mylog/vp_tox_tests_log
1818
# OAuth authentication information
1919
#VP_TEST_OAUTH_USER=<can be ignored if VP_TEST_DATABASE is set>
2020
#VP_TEST_OAUTH_ACCESS_TOKEN=******
21+
#VP_TEST_OAUTH_REFRESH_TOKEN=******
22+
#VP_TEST_OAUTH_CLIENT_ID=vertica
23+
#VP_TEST_OAUTH_CLIENT_SECRET=******
24+
#VP_TEST_OAUTH_TOKEN_URL=http://hostname:8080/realms/test/protocol/openid-connect/token
25+
#VP_TEST_OAUTH_DISCOVERY_URL=http://hostname:8080/realms/test/.well-known/openid-configuration
2126

vertica_python/tests/integration_tests/base.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@ class VerticaPythonIntegrationTestCase(VerticaPythonTestCase):
5555
def setUpClass(cls):
5656
config_list = ['log_dir', 'log_level', 'host', 'port',
5757
'user', 'password', 'database',
58-
'oauth_access_token', 'oauth_user',]
58+
'oauth_access_token', 'oauth_refresh_token',
59+
'oauth_client_secret', 'oauth_client_id',
60+
'oauth_token_url', 'oauth_discovery_url',
61+
'oauth_user',]
5962
cls.test_config = cls._load_test_config(config_list)
6063

6164
# Test logger
@@ -76,6 +79,11 @@ def setUpClass(cls):
7679
}
7780
cls._oauth_info = {
7881
'access_token': cls.test_config['oauth_access_token'],
82+
'refresh_token': cls.test_config['oauth_refresh_token'],
83+
'client_secret': cls.test_config['oauth_client_secret'],
84+
'client_id': cls.test_config['oauth_client_id'],
85+
'token_url': cls.test_config['oauth_token_url'],
86+
'discovery_url': cls.test_config['oauth_discovery_url'],
7987
'user': cls.test_config['oauth_user'],
8088
}
8189
cls.db_node_num = cls._get_node_num()

vertica_python/tests/integration_tests/test_authentication.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import print_function, division, absolute_import, annotations
1616

1717
from .base import VerticaPythonIntegrationTestCase
18+
from ...errors import OAuthTokenRefreshError
1819

1920

2021
class AuthenticationTestCase(VerticaPythonIntegrationTestCase):
@@ -28,6 +29,10 @@ def tearDown(self):
2829
self._conn_info['password'] = self._password
2930
if 'oauth_access_token' in self._conn_info:
3031
del self._conn_info['oauth_access_token']
32+
if 'oauth_refresh_token' in self._conn_info:
33+
del self._conn_info['oauth_refresh_token']
34+
if 'oauth_config' in self._conn_info:
35+
del self._conn_info['oauth_config']
3136
super(AuthenticationTestCase, self).tearDown()
3237

3338
def test_SHA512(self):
@@ -109,10 +114,12 @@ def test_password_expire(self):
109114
cur.execute("DROP AUTHENTICATION IF EXISTS testIPv6hostHash CASCADE")
110115
cur.execute("DROP AUTHENTICATION IF EXISTS testlocalHash CASCADE")
111116

112-
def test_oauth(self):
117+
def test_oauth_access_token(self):
113118
self.require_protocol_at_least(3 << 16 | 11)
114119
if not self._oauth_info['access_token']:
115-
self.skipTest('OAuth not set')
120+
self.skipTest('OAuth access token not set')
121+
if not self._oauth_info['user'] and not self._conn_info['database']:
122+
self.skipTest('Both database and oauth_user are not set')
116123

117124
self._conn_info['user'] = self._oauth_info['user']
118125
self._conn_info['oauth_access_token'] = self._oauth_info['access_token']
@@ -122,5 +129,67 @@ def test_oauth(self):
122129
res = cur.fetchone()
123130
self.assertEqual(res[0], 'OAuth')
124131

132+
def _test_oauth_refresh(self, access_token):
133+
self.require_protocol_at_least(3 << 16 | 11)
134+
if not self._oauth_info['refresh_token']:
135+
self.skipTest('OAuth refresh token not set')
136+
if not (self._oauth_info['client_secret'] and self._oauth_info['client_id'] and self._oauth_info['token_url']):
137+
self.skipTest('One or more OAuth config (client_id, client_secret, token_url) not set')
138+
if not self._oauth_info['user'] and not self._conn_info['database']:
139+
self.skipTest('Both database and oauth_user are not set')
140+
141+
if access_token is not None:
142+
self._conn_info['oauth_access_token'] = access_token
143+
self._conn_info['user'] = self._oauth_info['user']
144+
self._conn_info['oauth_refresh_token'] = self._oauth_info['refresh_token']
145+
self._conn_info['oauth_config'] = {
146+
'client_secret': self._oauth_info['client_secret'],
147+
'client_id': self._oauth_info['client_id'],
148+
'token_url': self._oauth_info['token_url'],
149+
}
150+
with self._connect() as conn:
151+
cur = conn.cursor()
152+
cur.execute("SELECT authentication_method FROM sessions WHERE session_id=(SELECT current_session())")
153+
res = cur.fetchone()
154+
self.assertEqual(res[0], 'OAuth')
155+
156+
def test_oauth_token_refresh_with_access_token_not_set(self):
157+
self._test_oauth_refresh(access_token=None)
158+
159+
def test_oauth_token_refresh_with_invalid_access_token(self):
160+
self._test_oauth_refresh(access_token='invalid_value')
161+
162+
def test_oauth_token_refresh_with_empty_access_token(self):
163+
self._test_oauth_refresh(access_token='')
164+
165+
def test_oauth_token_refresh_with_discovery_url(self):
166+
self.require_protocol_at_least(3 << 16 | 11)
167+
if not self._oauth_info['refresh_token']:
168+
self.skipTest('OAuth refresh token not set')
169+
if not (self._oauth_info['client_secret'] and self._oauth_info['client_id'] and self._oauth_info['discovery_url']):
170+
self.skipTest('One or more OAuth config (client_id, client_secret, discovery_url) not set')
171+
if not self._oauth_info['user'] and not self._conn_info['database']:
172+
self.skipTest('Both database and oauth_user are not set')
173+
174+
self._conn_info['user'] = self._oauth_info['user']
175+
self._conn_info['oauth_refresh_token'] = self._oauth_info['refresh_token']
176+
msg = 'Token URL or Discovery URL must be set.'
177+
self.assertConnectionFail(err_type=OAuthTokenRefreshError, err_msg=msg)
178+
179+
self._conn_info['oauth_config'] = {
180+
'client_secret': self._oauth_info['client_secret'],
181+
'client_id': self._oauth_info['client_id'],
182+
'discovery_url': self._oauth_info['discovery_url'],
183+
}
184+
with self._connect() as conn:
185+
cur = conn.cursor()
186+
cur.execute("SELECT authentication_method FROM sessions WHERE session_id=(SELECT current_session())")
187+
res = cur.fetchone()
188+
self.assertEqual(res[0], 'OAuth')
189+
190+
# Token URL takes precedence over Discovery URL
191+
self._conn_info['oauth_config']['token_url'] = 'invalid_value'
192+
self.assertConnectionFail(err_type=OAuthTokenRefreshError, err_msg='Failed getting OAuth access token from a refresh token.')
193+
125194

126195
exec(AuthenticationTestCase.createPrepStmtClass())

0 commit comments

Comments
 (0)