Skip to content

feat(auth): add VerifyOptions / aud-claim verification parity with node-sdk#315

Open
chris4490 wants to merge 8 commits intomainfrom
chris/java-sdk-verify-options-parity
Open

feat(auth): add VerifyOptions / aud-claim verification parity with node-sdk#315
chris4490 wants to merge 8 commits intomainfrom
chris/java-sdk-verify-options-parity

Conversation

@chris4490
Copy link
Copy Markdown
Member

Summary

Adds VerifyOptions to the Java SDK so that every session-validation entry point can verify the aud claim, reaching parity with node-sdk's VerifyOptions (which is accepted by validateJwt, validateSession, refreshSession, validateAndRefreshSession, and exchangeAccessKey).

Before this PR, the Java SDK had no way to assert the incoming token's audience — the most commonly-requested multi-tenant / multi-service hardening check. After this PR, any caller can supply one or more expected audiences and the SDK will reject the token if none match.

API additions

All additions are backward-compatible overloads — every existing signature is preserved and delegates to the new path with null options.

  • com.descope.model.auth.VerifyOptions (new Lombok @Data/@Builder POJO)
    • audiences: List<String>
    • VerifyOptions.withAudience(String) and .withAudiences(List<String>) static helpers
    • Builder helper .audience(String) for the common single-audience case
    • getAudiencesOrEmpty() null-safe accessor
  • JwtUtils.getToken(String, Client, VerifyOptions) — parses, validates, then verifies audience
  • JwtUtils.verifyAudience(Claims, VerifyOptions) (package-private, unit-testable)
  • SdkServicesBase.validateAndCreateToken(String, VerifyOptions)
  • AuthenticationsBase.validateJWT(String, VerifyOptions)
  • AuthenticationsBase.refreshSession(String, VerifyOptions)
  • AuthenticationsBase.getAuthenticationInfo(JWTResponse, VerifyOptions)
  • AuthenticationService:
    • validateSessionWithToken(String, VerifyOptions)
    • refreshSessionWithToken(String, VerifyOptions)
    • refreshSessionWithTokenAuthenticationInfo(String, VerifyOptions)
    • validateAndRefreshSessionWithTokens(String, String, VerifyOptions)
    • validateAndRefreshSessionWithTokensAuthenticationInfo(String, String, VerifyOptions)
    • exchangeAccessKey(String, AccessKeyLoginOptions, VerifyOptions)

Behavior

  • If verifyOptions == null or verifyOptions.getAudiencesOrEmpty().isEmpty() → no audience verification is performed (legacy behavior preserved).
  • Otherwise: the token is rejected with ClientFunctionalException.invalidToken(...) unless at least one of the expected audiences is present in the token's aud set (any-match semantics — matches node-sdk's jose.jwtVerify behavior with an audience option).
  • A missing/empty aud claim with a non-empty expected list is a rejection.

Sample usage

VerifyOptions opts = VerifyOptions.withAudience("api.example.com");
Token token = authenticationService.validateSessionWithToken(sessionJwt, opts);

// Or multiple allowed audiences:
VerifyOptions multi = VerifyOptions.withAudiences(
    List.of("api.example.com", "admin.example.com"));

Tests

Added three new unit-test classes — none require a live signing key or project:

  • VerifyOptionsTest (7 tests) — builder + helpers, null/empty handling, defaults.
  • JwtUtilsAudienceTest (9 tests) — mocks Claims.getAudience() to exercise JwtUtils.verifyAudience in isolation: null options, empty list, single match, mismatch throws, missing claim throws, null claim throws, multi-expected any-match, multi-token any-match, none-match throws.
  • AuthenticationServiceVerifyOptionsTest (5 tests) — input-validation parity: the new overloads reject blank inputs identically to the legacy ones.

Constraints respected

  • No dependency version bumps (pom.xml unchanged).
  • Fully backward compatible — every existing public method kept.
  • Follows existing code style (Lombok, Apache Commons Lang/Collections, jjwt 0.13, AssertJ + JUnit 5 + Mockito).

Gap analysis vs. node-sdk (follow-up work)

VerifyOptions / aud was the gap called out by the reporter, and this PR addresses it fully. A broader parity pass against node-sdk is recommended as follow-up — a detailed list of remaining differences (OAuth flows, WebAuthn options, some management APIs, cookie-helper utilities, etc.) is included in the PR changelog markdown delivered alongside this PR. Those are intentionally out of scope here so this change stays reviewable.

Draft

Opened as a draft per the reporter's request. Ready for early review on the API surface; I'll flip to ready-for-review once CI is green and any naming / shape feedback is addressed.


🤖 Generated with Claude Code

…session validation paths

Mirrors the node-sdk's VerifyOptions type (audience). Adds backward-compatible overloads on AuthenticationService for validateSessionWithToken, refreshSessionWithToken(+AuthenticationInfo), validateAndRefreshSessionWithTokens(+AuthenticationInfo), and exchangeAccessKey. JwtUtils.getToken now accepts VerifyOptions and enforces 'aud' when provided. Includes unit tests for VerifyOptions and JwtUtils.verifyAudience.
…nService, and AuthenticationServiceImpl

Adds new overloads to the public AuthenticationService interface and its impl that accept a VerifyOptions parameter. Existing call sites are preserved (all legacy methods now delegate to the new overloads with null options).
Extends the public AuthenticationService interface with VerifyOptions
overloads for validateSessionWithToken, refreshSessionWithToken,
refreshSessionWithTokenAuthenticationInfo,
validateAndRefreshSessionWithTokens,
validateAndRefreshSessionWithTokensAuthenticationInfo, and
exchangeAccessKey. Existing signatures delegate to the new paths with
null options, preserving full backward compatibility.

Mirrors node-sdk's VerifyOptions parameter and unlocks aud-claim
verification on every session-validation entry point.
- VerifyOptionsTest: builder + convenience helpers cover null/empty
  inputs, the single-audience shortcut, multiple audiences, and the
  getAudiencesOrEmpty() null-safe read.
- JwtUtilsAudienceTest: exercises JwtUtils.verifyAudience in full
  isolation by mocking Claims.getAudience(), covering null options,
  empty expected list, single and multiple expected audiences,
  multiple token audiences, missing/null aud claims, and mismatch.
- AuthenticationServiceVerifyOptionsTest: verifies that the new
  VerifyOptions overloads on AuthenticationService still enforce
  input validation (blank inputs rejected) identically to the legacy
  overloads. End-to-end JWT behavior is covered by
  JwtUtilsAudienceTest.
The maven-javadoc-plugin processes raw source (pre-delombok), so the
{@link VerifyOptions#getAudiences()} reference in JwtUtils' Javadoc
could not be resolved and failed the `attach-javadocs` goal with
"reference not found". Switch to a prose reference to VerifyOptions
so the link resolves without depending on a Lombok-synthesized method.
…eep intercepting

Thirteen existing service tests (Magic Link, OTP, OAuth, SAML, TOTP,
Password, WebAuthn, Enchanted Link, Jwt) stub JwtUtils.getToken via
MockedStatic on the 2-arg overload:
    mockedJwtUtils.when(() -> JwtUtils.getToken(anyString(), any()))
        .thenReturn(MOCK_TOKEN);

After the VerifyOptions refactor, the legacy public methods delegated
into the new 3-arg overload with `null` options, so the 2-arg stub
was bypassed, `JwtUtils.getToken(jwt, client, null)` was left
unstubbed, Mockito returned the default (null), and every
authentication-info-shaped test started failing with "Expecting
actual not to be null".

This commit restructures the delegation so:

- JwtUtils.getToken(String, Client) is the original method — builds
  a Token directly, unchanged. This is what existing stubs target.
- JwtUtils.getToken(String, Client, VerifyOptions) short-circuits to
  the 2-arg overload when options are null or carry no audiences;
  the new audience-verifying path only runs when the caller actually
  passes expected audiences.
- SdkServicesBase.validateAndCreateToken(String) stays at its
  original body and is what the refactored internals call whenever
  no VerifyOptions are involved. The new 2-arg overload delegates
  to it for the null/empty case.

Result: all existing tests (420) should pass again, new tests
(VerifyOptionsTest, JwtUtilsAudienceTest,
AuthenticationServiceVerifyOptionsTest) continue to pass, and the
new audience-verification path is only exercised when a caller
supplies VerifyOptions.
@chris4490 chris4490 marked this pull request as ready for review April 17, 2026 15:01
Copilot AI review requested due to automatic review settings April 17, 2026 15:02
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a VerifyOptions API to the Java SDK to optionally verify JWT aud claim across session/JWT validation entry points, aligning behavior with the Node SDK’s VerifyOptions semantics.

Changes:

  • Introduces VerifyOptions model with helpers for configuring expected audiences.
  • Adds new overloads across AuthenticationService and internal auth flows to pass VerifyOptions through JWT parsing/validation.
  • Implements and unit-tests audience verification logic in JwtUtils.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/main/java/com/descope/model/auth/VerifyOptions.java New options POJO (builder/helpers) for configuring expected JWT audiences.
src/main/java/com/descope/utils/JwtUtils.java Adds getToken(..., VerifyOptions) overload and aud verification helper.
src/main/java/com/descope/sdk/SdkServicesBase.java Adds validateAndCreateToken(..., VerifyOptions) overload to preserve legacy mocking path.
src/main/java/com/descope/sdk/auth/AuthenticationService.java Adds public overloads accepting VerifyOptions for session/JWT flows and access-key exchange.
src/main/java/com/descope/sdk/auth/impl/AuthenticationsBase.java Threads VerifyOptions through validation and refresh/auth-info construction.
src/main/java/com/descope/sdk/auth/impl/AuthenticationServiceImpl.java Implements new overloads and forwards to VerifyOptions-aware paths.
src/test/java/com/descope/model/auth/VerifyOptionsTest.java Unit tests for VerifyOptions builder/helpers and null/empty handling.
src/test/java/com/descope/utils/JwtUtilsAudienceTest.java Isolated unit tests for JwtUtils.verifyAudience behavior.
src/test/java/com/descope/sdk/auth/impl/AuthenticationServiceVerifyOptionsTest.java Tests input validation parity for new overloads.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +71 to +88
public static Token getToken(String jwt, Client client, VerifyOptions options) {
if (options == null || options.getAudiencesOrEmpty().isEmpty()) {
return getToken(jwt, client);
}

Jws<Claims> claimsJws = getClaimsJws(jwt, client);
Claims claims = claimsJws.getPayload();

verifyAudience(claims, options);

return Token.builder()
.jwt(jwt)
.projectId(client.getProjectId())
.id(claims.getSubject())
.expiration(claims.getExpiration().getTime())
.refreshExpiration(claims.get("rexp", Date.class))
.claims(claims)
.build();
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

JwtUtils.getToken(String, Client) and the new getToken(String, Client, VerifyOptions) duplicate the Token.builder() assembly logic. This increases the risk of the two overloads drifting (e.g., if new fields are added to Token later). Consider extracting the common Token construction into a private helper that both overloads call after obtaining Claims (while still keeping the 2-arg overload’s behavior intact for existing static mocks).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@copilot apply changes based on this feedback

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.

Done in 69c1a76. Extracted a private buildToken(String jwt, String projectId, Claims claims) helper — both getToken overloads now call it, so the Token.builder() assembly logic lives in exactly one place.

Comment thread src/main/java/com/descope/sdk/auth/impl/AuthenticationServiceImpl.java Outdated
…mpl.java

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…per in JwtUtils

Agent-Logs-Url: https://github.com/descope/descope-java/sessions/b5a56654-76da-48a1-9dcc-0a31de599d6e

Co-authored-by: chris4490 <42780533+chris4490@users.noreply.github.com>
Copy link
Copy Markdown
Member

@slavikm slavikm left a comment

Choose a reason for hiding this comment

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

Looks good. One minor typo?

}
ExchangeTokenRequest request = new ExchangeTokenRequest(code);
ApiProxy apiProxy = getApiProxy();
ApiProxy apiProxy = getApiProxy();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Typo?

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.

4 participants