feat(jwt): add oauth 2.0 client assertion creation per rfc 7523#294
feat(jwt): add oauth 2.0 client assertion creation per rfc 7523#294
Conversation
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
There was a problem hiding this comment.
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
ClientAssertionRequestmodel with configurable client ID, token endpoint, private key, algorithm (defaults to RS256), and expiration (defaults to 300 seconds) - Implemented
createClientAssertion()method inJwtServiceinterface andJwtServiceImplwith 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.
| .setIssuer(request.getClientId()) | ||
| .setSubject(request.getClientId()) | ||
| .setAudience(request.getTokenEndpoint()) | ||
| .setIssuedAt(now) | ||
| .setExpiration(expiration) | ||
| .setId(UUID.randomUUID().toString()) |
There was a problem hiding this comment.
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().
| .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()) |
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| import com.descope.proxy.ApiProxy; | ||
| import com.descope.sdk.mgmt.JwtService; | ||
| import io.jsonwebtoken.Jwts; | ||
| import io.jsonwebtoken.SignatureAlgorithm; |
There was a problem hiding this comment.
The SignatureAlgorithm import is deprecated in JJWT 0.12.0+. Replace with io.jsonwebtoken.security.SecureDigestAlgorithm and use Jwts.SIG.* constants for specific algorithms.
| import io.jsonwebtoken.SignatureAlgorithm; |
| Claims claims = Jwts.parser() | ||
| .setSigningKey(rsaPublicKey) |
There was a problem hiding this comment.
The setSigningKey() method is deprecated in JJWT 0.12.0+. Use verifyWith(publicKey) instead to specify the key for signature verification.
| Claims claims = Jwts.parser() | |
| .setSigningKey(rsaPublicKey) | |
| Claims claims = Jwts.parserBuilder() | |
| .verifyWith(rsaPublicKey) |
|
|
||
| assertNotNull(claims1.getId()); | ||
| assertNotNull(claims2.getId()); | ||
| assertTrue(!claims1.getId().equals(claims2.getId())); |
There was a problem hiding this comment.
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.
| assertTrue(!claims1.getId().equals(claims2.getId())); | |
| assertNotEquals(claims1.getId(), claims2.getId()); |
| String jwt = jwtService.createClientAssertion(request); | ||
|
|
||
| assertNotNull(jwt); | ||
| assertTrue(jwt.split("\\.").length == 3); |
There was a problem hiding this comment.
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.
|
|
||
| ```java | ||
| import com.descope.model.jwt.request.ClientAssertionRequest; | ||
| import java.security.KeyStore; |
There was a problem hiding this comment.
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.
| import java.security.KeyStore; | |
| import java.security.KeyStore; | |
| import java.io.FileInputStream; | |
| import java.io.InputStream; |
| assertNotNull(thrown); | ||
| String message = thrown.getMessage(); | ||
| assertTrue(message != null && message.contains("algorithm"), | ||
| "Expected error message to contain 'algorithm', but got: " + message); |
There was a problem hiding this comment.
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.
| 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()); |
| SignatureAlgorithm algorithm = getSignatureAlgorithm(request.getAlgorithm()); | ||
| PrivateKey privateKey = request.getPrivateKey(); |
There was a problem hiding this comment.
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.
| Date expiration = new Date(nowMillis + (request.getExpirationSeconds() * 1000)); | ||
|
|
There was a problem hiding this comment.
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).
| 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); |
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:
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 signingalgorithm- Signing algorithm (defaults to RS256)expirationSeconds- JWT expiration (defaults to 300 seconds)JwtServiceImplClientAssertionTest.java - Comprehensive test suite with 11 tests:
Modified Files:
createClientAssertion()interface methodJWT Claims (RFC 7523 Compliant):
iss(issuer): client_idsub(subject): client_idaud(audience): token endpoint URLexp(expiration): configurable, defaults to 5 minutesiat(issued at): current timestampjti(JWT ID): unique UUID for replay preventionUsage Example:
Test Results:
Commit Created:
The implementation is complete and ready for the KHealth customer's Node.js to Java migration!
Created by Shuni 🐕