From 6d9156f748427d53c81b2b4b109105881379891d Mon Sep 17 00:00:00 2001 From: Scott Weber Date: Fri, 4 Sep 2026 13:57:01 -0400 Subject: [PATCH] feat: support max_age and prompt in create_auth_url Two more optional authorization request JWT claims that had no SDK support: - max_age: maximum seconds since the user's last interactive authentication; an older remembered session forces interactive reauth - prompt: set to 'login' to force interactive reauthentication despite a remembered session max_age is compared against None rather than checked for truthiness so that a max_age of zero reaches Duo, since zero always forces interactive reauthentication rather than meaning "unset". Co-Authored-By: Claude Opus 5 --- duo_universal/client.py | 13 ++++++- tests/test_create_auth.py | 73 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/duo_universal/client.py b/duo_universal/client.py index 95522cd..5c9aefd 100644 --- a/duo_universal/client.py +++ b/duo_universal/client.py @@ -241,7 +241,8 @@ def health_check(self): return res def create_auth_url(self, username, state, nonce=None, dest_app_name=None, - dest_app_id=None, display_username=None): + dest_app_id=None, display_username=None, max_age=None, + prompt=None): """Generate uri to Duo's prompt Arguments: @@ -257,6 +258,12 @@ def create_auth_url(self, username, state, nonce=None, dest_app_name=None, application; not shown to users display_username -- (Optional) Username shown in the Duo Mobile "user" field for Push. Defaults to the Duo username if not provided. + max_age -- (Optional) Maximum number of seconds since the user last + authenticated interactively. A remembered session older than + this forces interactive reauthentication. Zero always forces + it, equivalent to prompt='login'. + prompt -- (Optional) Set to 'login' to force interactive + reauthentication even when the user has a remembered session Returns: @@ -286,6 +293,10 @@ def create_auth_url(self, username, state, nonce=None, dest_app_name=None, jwt_args['dest_app_id'] = dest_app_id if display_username is not None: jwt_args['display_username'] = display_username + if max_age is not None: + jwt_args['max_age'] = max_age + if prompt is not None: + jwt_args['prompt'] = prompt request_jwt = jwt.encode(jwt_args, self._signing_key, diff --git a/tests/test_create_auth.py b/tests/test_create_auth.py index 34c1f0a..664797f 100644 --- a/tests/test_create_auth.py +++ b/tests/test_create_auth.py @@ -137,6 +137,79 @@ def test_dest_app_name_without_dest_app_id(self): self._assert_client_creates_expected_uri( duo_client, expected_jwt_args, dest_app_name=DEST_APP_NAME) + @patch('time.time', MagicMock(return_value=2)) + def test_max_age(self): + """ + Test create_auth_url includes max_age when given + """ + duo_client = client.Client(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI) + + expected_jwt_args = { + 'scope': 'openid', + 'redirect_uri': REDIRECT_URI, + 'client_id': CLIENT_ID, + 'iss': CLIENT_ID, + 'aud': client.API_HOST_URI_FORMAT.format(HOST), + 'exp': 302, + 'state': STATE, + 'response_type': 'code', + 'duo_uname': USERNAME, + 'use_duo_code_attribute': True, + 'max_age': 3600, + } + + self._assert_client_creates_expected_uri( + duo_client, expected_jwt_args, max_age=3600) + + @patch('time.time', MagicMock(return_value=2)) + def test_max_age_zero(self): + """ + Test create_auth_url includes a max_age of zero, which forces + interactive reauthentication rather than being treated as unset + """ + duo_client = client.Client(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI) + + expected_jwt_args = { + 'scope': 'openid', + 'redirect_uri': REDIRECT_URI, + 'client_id': CLIENT_ID, + 'iss': CLIENT_ID, + 'aud': client.API_HOST_URI_FORMAT.format(HOST), + 'exp': 302, + 'state': STATE, + 'response_type': 'code', + 'duo_uname': USERNAME, + 'use_duo_code_attribute': True, + 'max_age': 0, + } + + self._assert_client_creates_expected_uri( + duo_client, expected_jwt_args, max_age=0) + + @patch('time.time', MagicMock(return_value=2)) + def test_prompt(self): + """ + Test create_auth_url includes prompt when given + """ + duo_client = client.Client(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI) + + expected_jwt_args = { + 'scope': 'openid', + 'redirect_uri': REDIRECT_URI, + 'client_id': CLIENT_ID, + 'iss': CLIENT_ID, + 'aud': client.API_HOST_URI_FORMAT.format(HOST), + 'exp': 302, + 'state': STATE, + 'response_type': 'code', + 'duo_uname': USERNAME, + 'use_duo_code_attribute': True, + 'prompt': 'login', + } + + self._assert_client_creates_expected_uri( + duo_client, expected_jwt_args, prompt='login') + def _assert_client_creates_expected_uri(self, duo_client, expected_jwt_args, **kwargs): authorize_endpoint = \ client.OAUTH_V1_AUTHORIZE_ENDPOINT.format(HOST)