Skip to content

Commit c147ef4

Browse files
authored
Merge pull request #181 from bkemper24/main
Add support for Proof Key for Code Exchange (PKCE)
2 parents 5f81609 + 41619cc commit c147ef4

File tree

4 files changed

+107
-7
lines changed

4 files changed

+107
-7
lines changed

doc/source/getting-started.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,28 @@ The authentication code can also be specified using one of the following environ
345345
- CASAUTHCODE
346346
- VIYAAUTHCODE
347347

348+
Beginning with release v1.14.0, the SWAT package supports using Proof Key for Code Exchange ( PKCE )
349+
when using authentication codes to obtain an OAuth token with HTTP. Python 3.6 or later is required
350+
for PKCE.
351+
352+
To use PKCE, specify the pkce=True parameter in the :class:`CAS` constructor. When specifying pkce=True,
353+
do not specify the authcode parameter. You will be provided a URL to use to obtain the
354+
authentication code and prompted to enter the authentication code obtained from that URL.
355+
356+
.. ipython:: python
357+
:verbatim:
358+
359+
conn = swat.CAS('https://my-cas-host.com:443/cas-shared-default-http/',
360+
pkce=True)
361+
362+
363+
The pkce parameter can also be specified using one of the following environment variables
364+
365+
- CAS_PKCE
366+
- VIYA_PKCE
367+
- CASPKCE
368+
- VIYAPKCE
369+
348370
Kerberos
349371
~~~~~~~~~~~~~~~~~~~~~
350372

swat/cas/connection.py

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ class CAS(object):
184184
The path to the SSL certificates for the CAS server.
185185
authcode : string, optional
186186
Authorization code from SASLogon used to retrieve an OAuth token.
187+
pkce : boolean, optional
188+
Use Proof Key for Code Exchange to obtain the Authorization code
187189
**kwargs : any, optional
188190
Arbitrary keyword arguments used for internal purposes only.
189191
@@ -353,7 +355,7 @@ def _get_connection_info(cls, hostname, port, username, password, protocol, path
353355
def __init__(self, hostname=None, port=None, username=None, password=None,
354356
session=None, locale=None, nworkers=None, name=None,
355357
authinfo=None, protocol=None, path=None, ssl_ca_list=None,
356-
authcode=None, **kwargs):
358+
authcode=None, pkce=False, **kwargs):
357359

358360
# Filter session options allowed as parameters
359361
_kwargs = {}
@@ -399,11 +401,23 @@ def __init__(self, hostname=None, port=None, username=None, password=None,
399401
soptions = a2n(getsoptions(session=session, locale=locale,
400402
nworkers=nworkers, protocol=protocol))
401403

404+
# Check for Proof Key for Code Exchange
405+
pkce = pkce or cf.get_option('cas.pkce')
402406
# Check for authcode authentication
403407
authcode = authcode or cf.get_option('cas.authcode')
404-
if protocol in ['http', 'https'] and authcode:
408+
if protocol in ['http', 'https'] and (authcode or pkce):
405409
username = None
406-
password = type(self)._get_token(authcode=authcode, url=hostname)
410+
verifystring = None
411+
if pkce:
412+
if authcode:
413+
# User will be prompted for authcode,
414+
# do not enter it in CAS() when using pkce
415+
raise SWATError('Do not specify authcode with pkce')
416+
# Get the authcode from SASLogon using Proof Key for Code Exchange
417+
authcode, verifystring = type(self)._get_authcode(url=hostname)
418+
# Get the OAuth token from SASLogon
419+
password = type(self)._get_token(authcode=authcode, url=hostname,
420+
verifystring=verifystring, pkce=pkce)
407421

408422
# Create error handler
409423
try:
@@ -538,7 +552,8 @@ def _id_generator():
538552

539553
@classmethod
540554
def _get_token(cls, username=None, password=None, authcode=None,
541-
client_id=None, client_secret=None, url=None):
555+
client_id=None, client_secret=None, url=None,
556+
verifystring=None, pkce=False):
542557
''' Retrieve token from Viya installation '''
543558
from .rest.connection import _print_request, _setup_ssl
544559

@@ -552,10 +567,21 @@ def _get_token(cls, username=None, password=None, authcode=None,
552567
client_id = client_id or cf.get_option('cas.client_id') or 'SWAT'
553568

554569
authcode = authcode or cf.get_option('cas.authcode')
570+
pkce = pkce or cf.get_option('cas.pkce')
571+
555572
if authcode:
556573
client_secret = client_secret or cf.get_option('cas.client_secret') or ''
557-
body = {'grant_type': 'authorization_code', 'code': authcode,
558-
'client_id': client_id, 'client_secret': client_secret}
574+
575+
if pkce:
576+
if verifystring is None:
577+
raise SWATError('A code verifier must be supplied for pkce')
578+
579+
body = {'grant_type': 'authorization_code',
580+
'code': authcode, 'code_verifier': verifystring,
581+
'client_id': client_id, 'client_secret': client_secret}
582+
else:
583+
body = {'grant_type': 'authorization_code', 'code': authcode,
584+
'client_id': client_id, 'client_secret': client_secret}
559585
else:
560586
username = username or cf.get_option('cas.username')
561587
password = password or cf.get_option('cas.token')
@@ -567,11 +593,58 @@ def _get_token(cls, username=None, password=None, authcode=None,
567593
data=urlencode(body))
568594

569595
if resp.status_code >= 300:
596+
logger.debug('Token request resulted in status code %d : \n %s',
597+
resp.status_code, resp.json())
570598
raise SWATError('Token request resulted in a status of %s' %
571599
resp.status_code)
572600

573601
return resp.json()['access_token']
574602

603+
@classmethod
604+
def _get_authcode(cls, url=None, client_id=None, client_secret=None):
605+
'''
606+
Generate the Proof Key for Code Exchange URL to retrieve the authentication code
607+
from the Viya installation.
608+
Wait for the user to provide the authentication code
609+
'''
610+
try:
611+
# The secrets package was introduced in Python 3.6
612+
import secrets
613+
except ImportError:
614+
raise SWATError("Python 3.6 or later is required for "
615+
"Proof Key for Code Exchange.")
616+
617+
import hashlib
618+
import base64
619+
620+
client_id = client_id or cf.get_option('cas.client_id') or 'SWAT'
621+
client_secret = client_secret or cf.get_option('cas.client_secret') or ''
622+
623+
# Generate the URL for the authcode request
624+
cv = secrets.token_urlsafe(32)
625+
cvh = hashlib.sha256(cv.encode('ascii')).digest()
626+
cvhe = base64.urlsafe_b64encode(cvh)
627+
cc = cvhe.decode('ascii')[:-1]
628+
# Note, for pkce "cc" is provided in the authcode request
629+
# and "cv" is provided in the OAuth token request
630+
purl = ("/SASLogon/oauth/authorize?client_id={}&response_type=code"
631+
"&code_challenge_method=S256&code_challenge={}").format(client_id, cc)
632+
authurl = urljoin(url, purl)
633+
634+
# Display the URL to the user and wait while they go off and get the authcode
635+
# to respond to the prompt
636+
msg = ("Please enter the authorization code obtained from the following url : "
637+
"\n {} \n").format(authurl)
638+
authcode = input(msg)
639+
640+
# trim leading trailing whitespace and verify something was entered
641+
authcode = authcode.strip()
642+
if len(authcode) == 0:
643+
raise SWATError(
644+
"You must provide an authorization code to connect to the CAS server")
645+
646+
return authcode, cv
647+
575648
def _gen_id(self):
576649
''' Generate an ID unique to the session '''
577650
import numpy

swat/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,11 @@ def check_tz(value):
197197
'Using "http" or "https" will use the REST interface.',
198198
environ='CAS_PROTOCOL')
199199

200+
register_option('cas.pkce', 'boolean', check_boolean, False,
201+
'Indicates whether or not Proof Key for Code Exchange should\n'
202+
'be used to obtain an authorization code.',
203+
environ=['CAS_PKCE', 'VIYA_PKCE'])
204+
200205

201206
def get_default_cafile():
202207
''' Retrieve the default CA file in the ssl module '''

swat/tests/test_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ def test_suboptions(self):
259259
'connection_retries', 'connection_retry_interval',
260260
'dataset', 'debug', 'exception_on_severity',
261261
'hostname', 'missing',
262-
'port', 'print_messages', 'protocol',
262+
'pkce', 'port', 'print_messages', 'protocol',
263263
'reflection_levels', 'ssl_ca_list', 'token',
264264
'trace_actions', 'trace_ui_actions', 'username'])
265265

0 commit comments

Comments
 (0)