Skip to content

Add dest_app_name, dest_app_id, display_username, max_age and prompt - #68

Open
scweber-cisco wants to merge 4 commits into
duosecurity:mainfrom
scweber-cisco:dest-app-and-display-username
Open

Add dest_app_name, dest_app_id, display_username, max_age and prompt#68
scweber-cisco wants to merge 4 commits into
duosecurity:mainfrom
scweber-cisco:dest-app-and-display-username

Conversation

@scweber-cisco

@scweber-cisco scweber-cisco commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Adds five optional parameters Duo's authorize endpoint accepts that this SDK could not send, plus the one field Duo returns in response to them. The first three are the Java SDK equivalent of duosecurity/duo_universal_python#42; max_age and prompt are documented by Duo but aren't in the Python SDK yet.

All five are claims in the signed request JWT:

Claim Meaning
dest_app_name User-facing name of the application the user is authenticating to. Duo shows it in Duo Mobile and records it in the authentication log.
dest_app_id Long-lived unique identifier for that application. Not shown to users.
display_username Username shown in Duo Mobile's "user" field for Push, in place of the Duo username. Does not change which user Duo authenticates.
max_age How many seconds may have passed since the user last authenticated interactively. A remembered session older than this forces interactive reauthentication.
prompt login forces interactive reauthentication even when a remembered session exists, equivalent to a max_age of 0.

Duo distinguishes an absent claim from one present with an empty value, so each is omitted from the JWT unless the caller supplies a value. None of the five is validated locally, matching the Python SDK, which validates only username, state and nonce — Duo is the authority on what it accepts.

max_age is a boxed Integer rather than an int so that unset stays distinguishable from 0, which forces interactive reauthentication rather than meaning "no limit".

Response field

Duo echoes dest_app_name back in the ID token as auth_context.application.destination_name, but Application only had key and name, so Utils.getApplication was silently dropping it. The other four aren't documented as being returned anywhere in the token, so they need no response-side counterpart.

API surface

Rather than grow createAuthUrl to eight positional arguments — where destAppName, destAppId and displayUsername are easy to transpose with no compile error — this adds AuthUrlOptions with a builder:

String authUrl = duoClient.createAuthUrl(
        new AuthUrlOptions.Builder(username, state)
                .setNonce(nonce)
                .setDestAppName("Acme VPN")
                .setDestAppId("vpn-prod-1")
                .setDisplayUsername("a.smith@acme.com")
                .setMaxAge(3600)
                .setPrompt(AuthUrlOptions.Prompt.LOGIN)
                .build());

Username and state live inside the options object rather than staying positional. That's what keeps the change source compatible: a three-argument createAuthUrl(username, state, AuthUrlOptions) would have made existing createAuthUrl(username, state, null) calls ambiguous and stopped them compiling. As written, every existing call shape is untouched:

client.createAuthUrl(username, state);         // unchanged
client.createAuthUrl(username, state, nonce);  // unchanged
client.createAuthUrl(username, state, null);   // still compiles

Both existing overloads now delegate to the options path, so the pre-existing nonce and validation tests cover that delegation. The two-argument Application constructor is likewise left alone, matching how Token handles amr and nonce.

prompt is an AuthUrlOptions.Prompt enum, so an unsupported value is a compile error rather than something Duo rejects at runtime.

Utils.createJwtForAuthUrl takes the options object for the same reason — it was already at seven positional parameters. It's package private, so only its two callers in UtilsTest changed.

Tests

Thirteen new tests, each watched fail before the implementation existed:

  • one per claim reaching the request JWT; the display_username test also asserts duo_uname is unaffected, since only the displayed name should change
  • a max_age of 0 is sent rather than swallowed — the case an if (maxAge > 0) style check would silently break
  • unset options leave the claims out of the JWT entirely
  • createAuthUrl(null) throws DuoException("Missing options") rather than an NPE
  • destination_name maps out of auth_context.application, and is null when Duo omits it
  • a new ApplicationTest covering equals and hashCode; the RED here was two applications differing only in destination_name comparing equal

The omission test would have passed on arrival, since the null check was written alongside the first claim. It was mutation-verified instead, and again when max_age and prompt were added to it: emitting the claims unconditionally produced expected: <true> but was: <false>.

80 tests pass, checkstyle is clean on both modules, and mvn install succeeds.

Also tested manually, modifying the demo app to pass the destination application and display parameters and verifying they flow through the auth as expected.

Example app

duo-example now uses the options form, with a commented block showing the five new setters. Its behavior is unchanged — it sets none of the new values.

🤖 Generated with Claude Code

scweber-cisco and others added 2 commits September 2, 2026 16:39
These are the last three optional parameters Duo's authorize endpoint
accepts that this SDK could not send. All three are claims in the signed
request JWT:

  dest_app_name    user-facing name of the application being authenticated
                   to, shown in Duo Mobile and recorded in the auth log
  dest_app_id      long-lived identifier for that application, not shown
                   to users
  display_username username shown in Duo Mobile's "user" field for Push,
                   in place of the Duo username

Duo distinguishes an absent claim from one present with an empty value,
so each is omitted from the JWT unless the caller supplies it.

Rather than grow createAuthUrl to six positional Strings, this adds
AuthUrlOptions with a builder and a createAuthUrl(AuthUrlOptions)
overload. Username and state live in the options object rather than
staying positional: a three-argument createAuthUrl(username, state,
AuthUrlOptions) would have made the existing createAuthUrl(username,
state, null) calls ambiguous and stopped them compiling. As written, all
existing call shapes are untouched and now delegate to the options path,
so the existing tests cover that delegation.

Utils.createJwtForAuthUrl takes the options object for the same reason;
it is package private, so only its two callers in UtilsTest changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Duo echoes the dest_app_name sent on the authorize request back in the ID
token as auth_context.application.destination_name, but the Application
model only had key and name, so Utils.getApplication silently dropped it.
Callers had no way to read back the destination application name Duo
recorded for the auth.

Adds the field, includes it in equals, hashCode and toString, and maps it
alongside the existing two. The two-argument Application constructor is
left as it was, matching how Token handles amr and nonce.

The getter is getDestination_name rather than getDestinationName to match
every other multi-word field in this package (getPreferred_username,
getAuth_result, getId_token and ten others). These classes are
serialization targets, so the getter name determines the JSON property
name; camelCase here would emit destinationName and break the snake_case
convention the rest of the token follows.

dest_app_id and display_username are not documented as being returned in
the token, so they need no response-side counterpart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@scweber-cisco
scweber-cisco marked this pull request as ready for review September 3, 2026 15:07
Duo's authorize endpoint documents two further optional claims for the
signed request JWT, neither of which the SDK could send:

  max_age  How many seconds may have passed since the user last
           authenticated interactively. A remembered session older than
           this forces interactive reauthentication.
  prompt   "login" forces interactive reauthentication even when a
           remembered session exists, equivalent to max_age of 0.

maxAge is a boxed Integer rather than an int so that unset stays
distinguishable from 0, which is a value Duo acts on rather than a
default; addClaimIfPresent gains an Integer overload that keys off null
alone for the same reason. Both claims are omitted from the JWT entirely
when unset, matching the treatment of the other optional claims.

prompt is a String with an AuthUrlOptions.PROMPT_LOGIN constant instead
of an enum, so that a value Duo starts accepting later works without an
SDK release. Neither value is validated locally, consistent with the
other optional claims -- Duo is the authority on what it accepts.

Three new tests: the claims reach the JWT, a max_age of 0 is sent rather
than swallowed, and the omission test now covers both. That last pair of
assertions passed on arrival, so they were mutation verified by
defaulting the claims to 0 and "login" when unset.

Neither parameter appears in the Python SDK yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@scweber-cisco scweber-cisco changed the title Add the dest_app_name, dest_app_id and display_username request parameters Add dest_app_name, dest_app_id, display_username, max_age and prompt Sep 4, 2026
.build());

/* Example of setting the optional destination application, display and freshness fields
String authUrl = duoClient.createAuthUrl(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is meant to be an example app, let's move the new options out of a comment and actually exercise them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might lean toward leaving this as a commented example, rather than default behavior. All of these fields have specific use cases that are not the most common path.

  • Destination app information is only relevant for SSO-like applications that are sending the user to potentially multiple destinations through a common authentication experience
  • Display Username is only needed if, for whatever reason, the username in Duo is different from the username that the user expects to see
  • Max Age and Prompt are overrides for policy that is defined on the Duo side and may lead to confusion if enabled by default

If you feel strongly about defaulting any of these, happy to adjust.

*
* @return the Builder
*/
public Builder setPrompt(String prompt) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the param type be an enum, if we will only ever accept a specific list of values?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was leaning toward using the more general String so that if/when Duo support for additional values is added, the SDK would not have to be updated. However, it is likely that an SDK update would be needed anyway to address other behavior changes that come along with different prompt values.

The other consideration is that OIDC defines prompt as a space-delimited list of values, but we can cross that bridge if/when it comes.

import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

class ApplicationTest {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see no value in these tests

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a real risk when equals/hashCode are manually coded that new parameters are not added to these calculations, but the number of tests here is probably overboard. How about we trim down rather than eliminate entirely?

Review feedback from AaronAtDuo on duosecurity#68.

prompt becomes AuthUrlOptions.Prompt rather than a String plus a
PROMPT_LOGIN constant. The forward compatibility argument for a String
does not hold up: the realistic next value is prompt=none, and
supporting that needs the SDK to handle login_required error redirects
as well, so a caller could not reach it through a string escape hatch
without an SDK change regardless. The enum's constant name and its wire
value differ in case, so the existing test asserting the claim is "login"
was mutation verified against value.name().

ApplicationTest drops the assertNotEquals on hashCode, which asserted
something Object's contract does not guarantee -- unequal objects are
free to share a hash code -- and drops the toString test, which was
brittle and guarded nothing. The two equals tests remain: adding a field
to a hand-written equals is where the field gets forgotten, which is the
failure the destination_name work actually hit. Mutation verified by
removing destination_name from Application.equals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants