Add dest_app_name, dest_app_id, display_username, max_age and prompt - #68
Add dest_app_name, dest_app_id, display_username, max_age and prompt#68scweber-cisco wants to merge 4 commits into
Conversation
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>
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>
| .build()); | ||
|
|
||
| /* Example of setting the optional destination application, display and freshness fields | ||
| String authUrl = duoClient.createAuthUrl( |
There was a problem hiding this comment.
Since this is meant to be an example app, let's move the new options out of a comment and actually exercise them.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
Should the param type be an enum, if we will only ever accept a specific list of values?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
I see no value in these tests
There was a problem hiding this comment.
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>
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_ageandpromptare documented by Duo but aren't in the Python SDK yet.All five are claims in the signed request JWT:
dest_app_namedest_app_iddisplay_usernamemax_agepromptloginforces interactive reauthentication even when a remembered session exists, equivalent to amax_ageof0.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_ageis a boxedIntegerrather than anintso that unset stays distinguishable from0, which forces interactive reauthentication rather than meaning "no limit".Response field
Duo echoes
dest_app_nameback in the ID token asauth_context.application.destination_name, butApplicationonly hadkeyandname, soUtils.getApplicationwas 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
createAuthUrlto eight positional arguments — wheredestAppName,destAppIdanddisplayUsernameare easy to transpose with no compile error — this addsAuthUrlOptionswith a builder: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 existingcreateAuthUrl(username, state, null)calls ambiguous and stopped them compiling. As written, every existing call shape is untouched:Both existing overloads now delegate to the options path, so the pre-existing nonce and validation tests cover that delegation. The two-argument
Applicationconstructor is likewise left alone, matching howTokenhandlesamrandnonce.promptis anAuthUrlOptions.Promptenum, so an unsupported value is a compile error rather than something Duo rejects at runtime.Utils.createJwtForAuthUrltakes the options object for the same reason — it was already at seven positional parameters. It's package private, so only its two callers inUtilsTestchanged.Tests
Thirteen new tests, each watched fail before the implementation existed:
display_usernametest also assertsduo_unameis unaffected, since only the displayed name should changemax_ageof0is sent rather than swallowed — the case anif (maxAge > 0)style check would silently breakcreateAuthUrl(null)throwsDuoException("Missing options")rather than an NPEdestination_namemaps out ofauth_context.application, and is null when Duo omits itApplicationTestcoveringequalsandhashCode; the RED here was two applications differing only indestination_namecomparing equalThe 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_ageandpromptwere added to it: emitting the claims unconditionally producedexpected: <true> but was: <false>.80 tests pass, checkstyle is clean on both modules, and
mvn installsucceeds.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-examplenow 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