Skip to content

feat(jwt): add oauth 2.0 client assertion creation per rfc 7523#294

Closed
aviadl wants to merge 1 commit intomainfrom
shuni/task-20260215132753
Closed

feat(jwt): add oauth 2.0 client assertion creation per rfc 7523#294
aviadl wants to merge 1 commit intomainfrom
shuni/task-20260215132753

Conversation

@aviadl
Copy link
Member

@aviadl aviadl commented Feb 15, 2026

Fixes descope/etc#13822

View workflow run

LSP is not available, but we already ran Maven compilation and tests successfully. Let me create a summary of what was implemented:

Implementation Complete ✅

I've successfully implemented OAuth 2.0 client assertion JWT creation for the descope-java SDK. Here's what was delivered:

New Files Created:

  1. ClientAssertionRequest.java - Request model with:

    • clientId - The OAuth client ID (issuer and subject)
    • tokenEndpoint - The OAuth token endpoint URL (audience)
    • privateKey - RSA/EC private key for signing
    • algorithm - Signing algorithm (defaults to RS256)
    • expirationSeconds - JWT expiration (defaults to 300 seconds)
  2. JwtServiceImplClientAssertionTest.java - Comprehensive test suite with 11 tests:

    • RS256 and ES256 algorithm support
    • Default algorithm and custom expiration
    • JWT structure validation
    • Unique JTI generation
    • Input validation and error handling

Modified Files:

  1. JwtService.java - Added createClientAssertion() interface method
  2. JwtServiceImpl.java - Implemented the method using jjwt library (already in dependencies)
  3. README.md - Added usage example with complete documentation

JWT Claims (RFC 7523 Compliant):

  • iss (issuer): client_id
  • sub (subject): client_id
  • aud (audience): token endpoint URL
  • exp (expiration): configurable, defaults to 5 minutes
  • iat (issued at): current timestamp
  • jti (JWT ID): unique UUID for replay prevention

Usage Example:

ClientAssertionRequest request = ClientAssertionRequest.builder()
    .clientId("your-client-id")
    .tokenEndpoint("https://auth.example.com/oauth/token")
    .privateKey(privateKey)
    .algorithm("RS256")
    .build();

String clientAssertion = jwtService.createClientAssertion(request);

// Use in OAuth token request with:
// client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
// client_assertion=<clientAssertion>

Test Results:

  • ✅ All 11 unit tests pass
  • ✅ Checkstyle validation passes
  • ✅ Maven compilation succeeds
  • ✅ No new dependencies added (uses existing jjwt library)

Commit Created:

feat: add OAuth 2.0 client assertion JWT creation

The implementation is complete and ready for the KHealth customer's Node.js to Java migration!


Created by Shuni 🐕

Implements RFC 7523 client assertion JWT creation for OAuth client authentication.
This allows customers to use JWT bearer tokens for client authentication when
migrating from Node.js to Java services.

Changes:
- Add ClientAssertionRequest model with clientId, tokenEndpoint, privateKey, algorithm, and expirationSeconds
- Add createClientAssertion method to JwtService interface
- Implement client assertion JWT creation using jjwt library with proper claims (iss, sub, aud, exp, iat, jti)
- Add comprehensive unit tests covering RS256, ES256, validation, and error cases
- Update README with usage examples and documentation

The generated JWT follows RFC 7523 specifications:
- iss and sub set to client_id
- aud set to token endpoint URL
- exp defaults to 5 minutes (customizable)
- jti includes unique UUID to prevent replay attacks
- Supports RS256, ES256, and other signing algorithms

Resolves: KHealth customer request for Java SDK parity with Node.js
Copilot AI review requested due to automatic review settings February 15, 2026 13:36
Copy link

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

This PR implements OAuth 2.0 client assertion JWT creation functionality according to RFC 7523, enabling the descope-java SDK to authenticate with OAuth token endpoints using JWT bearer client assertions. This supports a KHealth customer's migration from Node.js to Java.

Changes:

  • Added ClientAssertionRequest model with configurable client ID, token endpoint, private key, algorithm (defaults to RS256), and expiration (defaults to 300 seconds)
  • Implemented createClientAssertion() method in JwtService interface and JwtServiceImpl with RFC 7523 compliant JWT claims (iss, sub, aud, exp, iat, jti)
  • Added comprehensive test suite with 11 tests covering multiple algorithms, validation, and edge cases

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
ClientAssertionRequest.java New request model with client assertion parameters and sensible defaults
JwtService.java Added interface method with Javadoc for creating client assertions
JwtServiceImpl.java Implemented JWT creation with input validation and algorithm selection
JwtServiceImplClientAssertionTest.java Comprehensive test suite covering RS256/ES256, defaults, validation, and error cases
README.md Added usage example with keystore loading and OAuth token request guidance

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

Comment on lines +170 to +175
.setIssuer(request.getClientId())
.setSubject(request.getClientId())
.setAudience(request.getTokenEndpoint())
.setIssuedAt(now)
.setExpiration(expiration)
.setId(UUID.randomUUID().toString())
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The setter methods like setIssuer(), setSubject(), setAudience(), setIssuedAt(), setExpiration(), and setId() are deprecated in JJWT 0.12.0+. Use the non-setter methods instead: issuer(), subject(), audience(), issuedAt(), expiration(), and id().

Suggested change
.setIssuer(request.getClientId())
.setSubject(request.getClientId())
.setAudience(request.getTokenEndpoint())
.setIssuedAt(now)
.setExpiration(expiration)
.setId(UUID.randomUUID().toString())
.issuer(request.getClientId())
.subject(request.getClientId())
.audience(request.getTokenEndpoint())
.issuedAt(now)
.expiration(expiration)
.id(UUID.randomUUID().toString())

Copilot uses AI. Check for mistakes.
Comment on lines +184 to +194
private SignatureAlgorithm getSignatureAlgorithm(String algorithm) {
if (StringUtils.isBlank(algorithm)) {
return SignatureAlgorithm.RS256;
}

try {
return SignatureAlgorithm.forName(algorithm);
} catch (IllegalArgumentException e) {
throw ServerCommonException.invalidArgument("algorithm - unsupported: " + algorithm);
}
}
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The SignatureAlgorithm class is deprecated in JJWT 0.12.0+. Use the SecureDigestAlgorithm interface and Jwts.SIG.* constants instead (e.g., Jwts.SIG.RS256, Jwts.SIG.ES256). This also means the getSignatureAlgorithm() helper method should return a SecureDigestAlgorithm<PrivateKey, PublicKey> type instead.

Copilot uses AI. Check for mistakes.
import com.descope.proxy.ApiProxy;
import com.descope.sdk.mgmt.JwtService;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The SignatureAlgorithm import is deprecated in JJWT 0.12.0+. Replace with io.jsonwebtoken.security.SecureDigestAlgorithm and use Jwts.SIG.* constants for specific algorithms.

Suggested change
import io.jsonwebtoken.SignatureAlgorithm;

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +70
Claims claims = Jwts.parser()
.setSigningKey(rsaPublicKey)
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The setSigningKey() method is deprecated in JJWT 0.12.0+. Use verifyWith(publicKey) instead to specify the key for signature verification.

Suggested change
Claims claims = Jwts.parser()
.setSigningKey(rsaPublicKey)
Claims claims = Jwts.parserBuilder()
.verifyWith(rsaPublicKey)

Copilot uses AI. Check for mistakes.

assertNotNull(claims1.getId());
assertNotNull(claims2.getId());
assertTrue(!claims1.getId().equals(claims2.getId()));
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The assertion assertTrue(!claims1.getId().equals(claims2.getId())) is unnecessarily complex and less readable. Use assertNotEquals(claims1.getId(), claims2.getId()) instead for better clarity and more meaningful failure messages.

Suggested change
assertTrue(!claims1.getId().equals(claims2.getId()));
assertNotEquals(claims1.getId(), claims2.getId());

Copilot uses AI. Check for mistakes.
String jwt = jwtService.createClientAssertion(request);

assertNotNull(jwt);
assertTrue(jwt.split("\\.").length == 3);
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The assertion assertTrue(jwt.split("\\.").length == 3) is less idiomatic. Use assertEquals(3, jwt.split("\\.").length) instead for clearer test intent and better failure messages.

Copilot uses AI. Check for mistakes.

```java
import com.descope.model.jwt.request.ClientAssertionRequest;
import java.security.KeyStore;
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The import statement is missing java.io.FileInputStream and java.io.InputStream which are used in the example code. These should be added to the import section.

Suggested change
import java.security.KeyStore;
import java.security.KeyStore;
import java.io.FileInputStream;
import java.io.InputStream;

Copilot uses AI. Check for mistakes.
Comment on lines +262 to +265
assertNotNull(thrown);
String message = thrown.getMessage();
assertTrue(message != null && message.contains("algorithm"),
"Expected error message to contain 'algorithm', but got: " + message);
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The test verifies that an error message contains 'algorithm', but this is fragile. If the error message format changes, the test will fail. Consider either checking the exact expected error message or verifying the exception type more specifically.

Suggested change
assertNotNull(thrown);
String message = thrown.getMessage();
assertTrue(message != null && message.contains("algorithm"),
"Expected error message to contain 'algorithm', but got: " + message);
assertEquals("The algorithm argument is invalid", thrown.getMessage());

Copilot uses AI. Check for mistakes.
Comment on lines +166 to +167
SignatureAlgorithm algorithm = getSignatureAlgorithm(request.getAlgorithm());
PrivateKey privateKey = request.getPrivateKey();
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The local variable algorithm at line 166 and privateKey at line 167 are unnecessary intermediary assignments that don't improve readability. The values can be used directly in the JWT builder for cleaner code.

Copilot uses AI. Check for mistakes.
Comment on lines +164 to +165
Date expiration = new Date(nowMillis + (request.getExpirationSeconds() * 1000));

Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

There's no validation for expirationSeconds to ensure it's positive and within reasonable bounds. A negative value would create a JWT that's already expired, and an extremely large value could cause integer overflow when multiplied by 1000 (line 164). Consider adding validation to ensure expirationSeconds is positive and within a reasonable range (e.g., 1-3600 seconds).

Suggested change
Date expiration = new Date(nowMillis + (request.getExpirationSeconds() * 1000));
long expirationSeconds = request.getExpirationSeconds();
// Ensure expiration is positive and within a reasonable bound to avoid overflow and invalid JWTs
if (expirationSeconds <= 0 || expirationSeconds > 3600L) {
throw ServerCommonException.invalidArgument("expirationSeconds");
}
long expirationMillis = nowMillis + (expirationSeconds * 1000L);
Date expiration = new Date(expirationMillis);

Copilot uses AI. Check for mistakes.
@aviadl aviadl closed this Feb 15, 2026
@aviadl aviadl deleted the shuni/task-20260215132753 branch February 15, 2026 14:02
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.

1 participant

Comments