Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Support For Passing Nonce and Configurable JWT Expiry #22

Merged
merged 3 commits into from
Feb 2, 2024
Merged
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
48 changes: 36 additions & 12 deletions duo_universal/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
CLIENT_ID_LENGTH = 20
CLIENT_SECRET_LENGTH = 40
JTI_LENGTH = 36
MINIMUM_STATE_LENGTH = 22
MINIMUM_STATE_LENGTH = 16
MAXIMUM_STATE_LENGTH = 1024
STATE_LENGTH = 36
SUCCESS_STATUS_CODE = 200
Expand All @@ -33,6 +33,12 @@
MIN=MINIMUM_STATE_LENGTH,
MAX=MAXIMUM_STATE_LENGTH
)
ERR_NONCE_LEN = ('Nonce must be at least {MIN} characters long and no longer than {MAX} characters').format(
MIN=MINIMUM_STATE_LENGTH,
MAX=MAXIMUM_STATE_LENGTH
)
ERR_EXP_SECONDS_TOO_LONG = 'Client may not be configured for a JWT expiry longer than five minutes.'
ERR_EXP_SECONDS_TOO_SHORT = 'Invalid JWT expiry duration.'

API_HOST_URI_FORMAT = "https://{}"
OAUTH_V1_HEALTH_CHECK_ENDPOINT = "https://{}/oauth/v1/health_check"
Expand All @@ -48,6 +54,9 @@ class DuoException(Exception):


class Client:
@property
def _clamped_expiry_duration(self):
return max(min(FIVE_MINUTES_IN_SECONDS, self._exp_seconds), 1)

def _generate_rand_alphanumeric(self, length):
"""
Expand All @@ -71,7 +80,7 @@ def _generate_rand_alphanumeric(self, length):
return ''.join(generator.choice(characters) for i in range(length))

def _validate_init_config(self, client_id, client_secret,
api_host, redirect_uri):
api_host, redirect_uri, exp_seconds):
"""
Verifies __init__ parameters

Expand All @@ -81,6 +90,7 @@ def _validate_init_config(self, client_id, client_secret,
client_secret -- Client secret for the application in Duo
host -- Duo api host
redirect_uri -- Uri to redirect to after a successful auth
exp_seconds -- The JWT expiry window

Raises:

Expand All @@ -94,8 +104,14 @@ def _validate_init_config(self, client_id, client_secret,
raise DuoException(ERR_API_HOST)
if not redirect_uri:
raise DuoException(ERR_REDIRECT_URI)

def _validate_create_auth_url_inputs(self, username, state):
if exp_seconds > FIVE_MINUTES_IN_SECONDS:
raise DuoException(ERR_EXP_SECONDS_TOO_LONG)
elif exp_seconds < 0:
raise DuoException(ERR_EXP_SECONDS_TOO_SHORT)

def _validate_create_auth_url_inputs(self, username, state, nonce=None):
if nonce and (MINIMUM_STATE_LENGTH >= len(nonce) or len(nonce) >= MAXIMUM_STATE_LENGTH):
raise DuoException(ERR_NONCE_LEN)
if not state or not (MINIMUM_STATE_LENGTH <= len(state) <= MAXIMUM_STATE_LENGTH):
raise DuoException(ERR_STATE_LEN)
if not username:
Expand All @@ -106,14 +122,15 @@ def _create_jwt_args(self, endpoint):
'iss': self._client_id,
'sub': self._client_id,
'aud': endpoint,
'exp': time.time() + FIVE_MINUTES_IN_SECONDS,
'exp': time.time() + self._clamped_expiry_duration,
'jti': self._generate_rand_alphanumeric(JTI_LENGTH)
}

return jwt_args

def __init__(self, client_id, client_secret, host,
redirect_uri, duo_certs=DEFAULT_CA_CERT_PATH, use_duo_code_attribute=True, http_proxy=None):
redirect_uri, duo_certs=DEFAULT_CA_CERT_PATH, use_duo_code_attribute=True, http_proxy=None,
exp_seconds=FIVE_MINUTES_IN_SECONDS):
"""
Initializes instance of Client class

Expand All @@ -126,12 +143,14 @@ def __init__(self, client_id, client_secret, host,
duo_certs -- (Optional) Provide custom CA certs
use_duo_code_attribute -- (Optional: default true) Flag to use `duo_code` instead of `code` for returned authorization parameter
http_proxy -- (Optional) HTTP proxy to tunnel requests through
exp_seconds -- (Optional) The number of seconds used for JWT expiry. Must be be at most 5 minutes.
"""

self._validate_init_config(client_id,
client_secret,
host,
redirect_uri)
redirect_uri,
exp_seconds)

self._client_id = client_id
self._client_secret = client_secret
Expand All @@ -153,6 +172,7 @@ def __init__(self, client_id, client_secret, host,
self._http_proxy = {'https': http_proxy}
else:
self._http_proxy = None
self._exp_seconds = exp_seconds

def generate_state(self):
"""
Expand Down Expand Up @@ -198,21 +218,23 @@ def health_check(self):

return res

def create_auth_url(self, username, state):
def create_auth_url(self, username, state, nonce=None):
"""Generate uri to Duo's prompt

Arguments:

username -- username trying to authenticate with Duo
state -- Randomly generated character string of at least 22
chars returned to the integration by Duo after 2FA
state -- Randomly generated character string of at least 16
and at most 1024 characters returned to the integration by Duo after 2FA
nonce -- Randomly generated character string of at least 16
AaronAtDuo marked this conversation as resolved.
Show resolved Hide resolved
and at most 1024 characters used as the nonce for the underlying OIDC flow

Returns:

Authorization uri to redirect to for the Duo prompt
"""

self._validate_create_auth_url_inputs(username, state)
self._validate_create_auth_url_inputs(username, state, nonce=nonce)

authorize_endpoint = OAUTH_V1_AUTHORIZE_ENDPOINT.format(self._api_host)

Expand All @@ -222,7 +244,7 @@ def create_auth_url(self, username, state):
'client_id': self._client_id,
'iss': self._client_id,
'aud': API_HOST_URI_FORMAT.format(self._api_host),
'exp': time.time() + FIVE_MINUTES_IN_SECONDS,
'exp': time.time() + self._clamped_expiry_duration,
'state': state,
'response_type': 'code',
'duo_uname': username,
Expand All @@ -237,6 +259,8 @@ def create_auth_url(self, username, state):
'client_id': self._client_id,
'request': request_jwt,
}
if nonce:
all_args['nonce'] = nonce

query_string = urlencode(all_args)
authorization_uri = "{}?{}".format(authorize_endpoint, query_string)
Expand Down
38 changes: 29 additions & 9 deletions tests/test_setup_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
REDIRECT_URI = "https://www.example.com"
CA_CERT_NEW = "/path/to/cert/ca_cert_new.pem"
PROXY_HOST = "http://proxy.example.com:8001"
EXP_SECONDS = client.FIVE_MINUTES_IN_SECONDS
NONE = None


Expand All @@ -26,7 +27,7 @@ def test_short_client_id(self):
"""
with self.assertRaises(client.DuoException) as e:
self.client._validate_init_config(SHORT_CLIENT_ID, CLIENT_SECRET,
HOST, REDIRECT_URI)
HOST, REDIRECT_URI, EXP_SECONDS)
self.assertEqual(e, client.ERR_CLIENT_ID)

def test_long_client_id(self):
Expand All @@ -35,7 +36,7 @@ def test_long_client_id(self):
"""
with self.assertRaises(client.DuoException) as e:
self.client._validate_init_config(LONG_CLIENT_ID, CLIENT_SECRET,
HOST, REDIRECT_URI)
HOST, REDIRECT_URI, EXP_SECONDS)
self.assertEqual(e, client.ERR_CLIENT_ID)

def test_no_client_id(self):
Expand All @@ -44,7 +45,7 @@ def test_no_client_id(self):
"""
with self.assertRaises(client.DuoException) as e:
self.client._validate_init_config(NONE, CLIENT_SECRET,
HOST, REDIRECT_URI)
HOST, REDIRECT_URI, EXP_SECONDS)
self.assertEqual(e, client.ERR_CLIENT_ID)

def test_short_client_secret(self):
Expand All @@ -53,7 +54,7 @@ def test_short_client_secret(self):
"""
with self.assertRaises(client.DuoException) as e:
self.client._validate_init_config(CLIENT_ID, SHORT_CLIENT_SECRET,
HOST, REDIRECT_URI)
HOST, REDIRECT_URI, EXP_SECONDS)
self.assertEqual(e, client.ERR_CLIENT_SECRET)

def test_long_client_secret(self):
Expand All @@ -62,7 +63,7 @@ def test_long_client_secret(self):
"""
with self.assertRaises(client.DuoException) as e:
self.client._validate_init_config(CLIENT_ID, LONG_CLIENT_SECRET,
HOST, REDIRECT_URI)
HOST, REDIRECT_URI, EXP_SECONDS)
self.assertEqual(e, client.ERR_CLIENT_SECRET)

def test_no_client_secret(self):
Expand All @@ -71,7 +72,7 @@ def test_no_client_secret(self):
"""
with self.assertRaises(client.DuoException) as e:
self.client._validate_init_config(CLIENT_ID, NONE,
HOST, REDIRECT_URI)
HOST, REDIRECT_URI, EXP_SECONDS)
self.assertEqual(e, client.ERR_CLIENT_SECRET)

def test_no_host(self):
Expand All @@ -80,7 +81,7 @@ def test_no_host(self):
"""
with self.assertRaises(client.DuoException) as e:
self.client._validate_init_config(CLIENT_ID, CLIENT_SECRET,
NONE, REDIRECT_URI)
NONE, REDIRECT_URI, EXP_SECONDS)
self.assertEqual(e, client.ERR_API_HOST)

def test_no_redirect_uri(self):
Expand All @@ -89,15 +90,34 @@ def test_no_redirect_uri(self):
"""
with self.assertRaises(client.DuoException) as e:
self.client._validate_init_config(CLIENT_ID, CLIENT_SECRET,
HOST, NONE)
HOST, NONE, EXP_SECONDS)
self.assertEqual(e, client.ERR_REDIRECT_URI)

def test_exp_seconds_too_long(self):
"""
Test to validate that a user can't set an expiry longer than 5 minutes.
"""
with self.assertRaises(client.DuoException) as e:
self.client._validate_init_config(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI, 2 * EXP_SECONDS)
self.assertEqual(e, client.ERR_EXP_SECONDS_TOO_LONG)
# Even if the end user forcefully sets the expiry, ensure the clamped value is in spec.
self.client._exp_seconds = 2 * EXP_SECONDS
self.assertEqual(self.client._clamped_expiry_duration, client.FIVE_MINUTES_IN_SECONDS)

def test_exp_seconds_too_short(self):
"""
Test to validate that a user can't set an expiry shorter than 0.
"""
with self.assertRaises(client.DuoException) as e:
self.client._validate_init_config(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI, -1)
self.assertEqual(e, client.ERR_EXP_SECONDS_TOO_SHORT)

def test_successful(self):
"""
Test successful _validate_init_config
"""
self.client._validate_init_config(CLIENT_ID, CLIENT_SECRET,
HOST, REDIRECT_URI)
HOST, REDIRECT_URI, EXP_SECONDS)

def test_no_duo_cert(self):
self.assertEqual(self.client._duo_certs, client.DEFAULT_CA_CERT_PATH)
Expand Down
25 changes: 25 additions & 0 deletions tests/test_validate_create_auth_url_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ def test_long_state(self):
self.client._validate_create_auth_url_inputs(USERNAME, LONG_LENGTH)
self.assertEqual(e, client.ERR_STATE)

def test_short_nonce(self):
"""
Test _validate_create_auth_url_inputs
throws a DuoException if the nonce is set and is too short
"""
with self.assertRaises(client.DuoException) as e:
AaronAtDuo marked this conversation as resolved.
Show resolved Hide resolved
self.client._validate_create_auth_url_inputs(USERNAME, STATE, nonce=SHORT_STATE)
self.assertEqual(e, client.ERR_NONCE_LEN)

def test_long_nonce(self):
"""
Test _validate_create_auth_url_inputs
throws a DuoException if the nonce is set and is too long
"""
with self.assertRaises(client.DuoException) as e:
self.client._validate_create_auth_url_inputs(USERNAME, STATE, nonce=LONG_LENGTH)
self.assertEqual(e, client.ERR_NONCE_LEN)

def test_no_username(self):
"""
Test _validate_create_auth_url_inputs
Expand All @@ -60,6 +78,13 @@ def test_success(self):
"""
self.client._validate_create_auth_url_inputs(USERNAME, STATE)

def test_success_with_nonce(self):
"""
Test _validate_create_auth_url_inputs
does not throw an error for valid inputs
"""
self.client._validate_create_auth_url_inputs(USERNAME, STATE, nonce=STATE)


if __name__ == '__main__':
unittest.main()
Loading