Skip to content

Commit

Permalink
Add Support For Passing Nonce and Configurable JWT Expiry
Browse files Browse the repository at this point in the history
* Added support to the Python client to allow the nonce to be set in
  the auth URL
* Added a client parameter to allow JWT expiry to be configured.
* Updated some tests to check nonce validation.
  • Loading branch information
rpcope1 committed Jan 31, 2024
1 parent 1ee3e5b commit ee00240
Show file tree
Hide file tree
Showing 2 changed files with 42 additions and 6 deletions.
25 changes: 19 additions & 6 deletions duo_universal/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
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
)

API_HOST_URI_FORMAT = "https://{}"
OAUTH_V1_HEALTH_CHECK_ENDPOINT = "https://{}/oauth/v1/health_check"
Expand Down Expand Up @@ -95,7 +99,9 @@ def _validate_init_config(self, client_id, client_secret,
if not redirect_uri:
raise DuoException(ERR_REDIRECT_URI)

def _validate_create_auth_url_inputs(self, username, state):
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 +112,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._exp_seconds,
'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,6 +133,7 @@ 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
"""

self._validate_init_config(client_id,
Expand Down Expand Up @@ -153,6 +161,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 +207,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
nonce -- Randomly generated character string of at least 16
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 +233,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._exp_seconds,
'state': state,
'response_type': 'code',
'duo_uname': username,
Expand All @@ -237,6 +248,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
23 changes: 23 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,22 @@ 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:
self.client._validate_create_auth_url_inputs(USERNAME, STATE, nonce=SHORT_STATE)

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)

def test_no_username(self):
"""
Test _validate_create_auth_url_inputs
Expand All @@ -60,6 +76,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()

0 comments on commit ee00240

Please sign in to comment.