From 48187cae5c4b06ad8ff2862bd3cb79d46fdaabe2 Mon Sep 17 00:00:00 2001 From: Scott Weber Date: Wed, 2 Sep 2026 15:14:47 -0400 Subject: [PATCH] feat: support dest_app_name, dest_app_id, and display_username The OIDC Auth API accepts three optional claims in the authorization request JWT that create_auth_url had no way to send: - dest_app_name: user-facing application name, shown in Duo Mobile and the authentication log - dest_app_id: long-lived unique application identifier, not shown to users - display_username: username shown in the Duo Mobile "user" field for Push, defaulting to the Duo username when omitted All three are optional keyword arguments and are only added to the JWT when provided, so existing callers are unaffected. Co-Authored-By: Claude Opus 5 --- duo_universal/client.py | 26 ++++++++++--- tests/test_create_auth.py | 81 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/duo_universal/client.py b/duo_universal/client.py index a44ef33..95522cd 100644 --- a/duo_universal/client.py +++ b/duo_universal/client.py @@ -240,16 +240,23 @@ def health_check(self): return res - def create_auth_url(self, username, state, nonce=None): + def create_auth_url(self, username, state, nonce=None, dest_app_name=None, + dest_app_id=None, display_username=None): """Generate uri to Duo's prompt Arguments: - username -- username trying to authenticate with Duo - 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 - and at most 1024 characters used as the nonce for the underlying OIDC flow + username -- username trying to authenticate with Duo + 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 + and at most 1024 characters used as the nonce for the underlying OIDC flow + dest_app_name -- (Optional) User-facing name of the application the user is + authenticating to, shown in Duo Mobile and the auth log + dest_app_id -- (Optional) Long-lived unique identifier for the destination + 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. Returns: @@ -273,6 +280,13 @@ def create_auth_url(self, username, state, nonce=None): 'use_duo_code_attribute': self._use_duo_code_attribute, } + if dest_app_name is not None: + jwt_args['dest_app_name'] = dest_app_name + if dest_app_id is not None: + jwt_args['dest_app_id'] = dest_app_id + if display_username is not None: + jwt_args['display_username'] = display_username + request_jwt = jwt.encode(jwt_args, self._signing_key, algorithm='HS512') diff --git a/tests/test_create_auth.py b/tests/test_create_auth.py index 1887620..34c1f0a 100644 --- a/tests/test_create_auth.py +++ b/tests/test_create_auth.py @@ -10,6 +10,9 @@ REDIRECT_URI = "https://www.example.com" USERNAME = "user1" STATE = "deadbeefdeadbeefdeadbeefdeadbeefdead" +DEST_APP_NAME = "Example App" +DEST_APP_ID = "example-app-1234" +DISPLAY_USERNAME = "User One" NONE = None @@ -60,7 +63,81 @@ def test_use_duo_code_false(self): self._assert_client_creates_expected_uri(duo_client, expected_jwt_args) - def _assert_client_creates_expected_uri(self, duo_client, expected_jwt_args): + @patch('time.time', MagicMock(return_value=2)) + def test_dest_app_name_and_dest_app_id(self): + """ + Test create_auth_url includes dest_app_name and dest_app_id 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, + 'dest_app_name': DEST_APP_NAME, + 'dest_app_id': DEST_APP_ID, + } + + self._assert_client_creates_expected_uri( + duo_client, expected_jwt_args, + dest_app_name=DEST_APP_NAME, dest_app_id=DEST_APP_ID) + + @patch('time.time', MagicMock(return_value=2)) + def test_display_username(self): + """ + Test create_auth_url includes display_username 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, + 'display_username': DISPLAY_USERNAME, + } + + self._assert_client_creates_expected_uri( + duo_client, expected_jwt_args, display_username=DISPLAY_USERNAME) + + @patch('time.time', MagicMock(return_value=2)) + def test_dest_app_name_without_dest_app_id(self): + """ + Test create_auth_url includes dest_app_name on its own + """ + 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, + 'dest_app_name': DEST_APP_NAME, + } + + self._assert_client_creates_expected_uri( + duo_client, expected_jwt_args, dest_app_name=DEST_APP_NAME) + + def _assert_client_creates_expected_uri(self, duo_client, expected_jwt_args, **kwargs): authorize_endpoint = \ client.OAUTH_V1_AUTHORIZE_ENDPOINT.format(HOST) @@ -74,7 +151,7 @@ def _assert_client_creates_expected_uri(self, duo_client, expected_jwt_args): expected_authorization_uri = "{}?{}".format(authorize_endpoint, encoded_all_args) - actual_authorization_uri = duo_client.create_auth_url(USERNAME, STATE) + actual_authorization_uri = duo_client.create_auth_url(USERNAME, STATE, **kwargs) self.assertEqual(expected_authorization_uri, actual_authorization_uri)