Skip to content

Commit

Permalink
Ensured aud claim is an array by default, but allows a single strin…
Browse files Browse the repository at this point in the history
…g value on creation for recipients that do not understand array values:

- ClaimsMutator#audience(String) now appends to the `aud` set, and may be called multiple times
- Added new ClaimsMutator#audience(Collection) method for setting/full replacement
- Added new ClaimsMutator#audienceSingle for setting/full replacement of single string value
  • Loading branch information
lhazlewood committed Sep 9, 2023
1 parent 524429e commit 59907e2
Show file tree
Hide file tree
Showing 23 changed files with 532 additions and 113 deletions.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3322,11 +3322,11 @@ String jwe = Jwts.builder().audience("Alice")
.compact();

// Alice receives and decrypts the compact JWE:
String audience = Jwts.parser()
Set<String> audience = Jwts.parser()
.decryptWith(pair.getPrivate()) // <-- Alice's RSA private key
.build().parseClaimsJwe(jwe).getPayload().getAudience();

assert "Alice".equals(audience);
assert audience.contains("Alice");
```

<a name="example-jwe-aeskw"></a>
Expand Down Expand Up @@ -3390,11 +3390,11 @@ String jwe = Jwts.builder().audience("Alice")
.compact();

// Alice receives and decrypts the compact JWE:
String audience = Jwts.parser()
Set<String> audience = Jwts.parser()
.decryptWith(pair.getPrivate()) // <-- Alice's EC private key
.build().parseClaimsJwe(jwe).getPayload().getAudience();

assert "Alice".equals(audience);
assert audience.contains("Alice");
```

<a name="example-jwe-password"></a>
Expand Down
41 changes: 29 additions & 12 deletions api/src/main/java/io/jsonwebtoken/Claims.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import java.util.Date;
import java.util.Map;
import java.util.Set;

/**
* A JWT <a href="https://www.rfc-editor.org/rfc/rfc7519.html#section-4">Claims set</a>.
Expand All @@ -38,25 +39,39 @@
*/
public interface Claims extends Map<String, Object>, Identifiable {

/** JWT {@code Issuer} claims parameter name: <code>"iss"</code> */
/**
* JWT {@code Issuer} claims parameter name: <code>"iss"</code>
*/
String ISSUER = "iss";

/** JWT {@code Subject} claims parameter name: <code>"sub"</code> */
/**
* JWT {@code Subject} claims parameter name: <code>"sub"</code>
*/
String SUBJECT = "sub";

/** JWT {@code Audience} claims parameter name: <code>"aud"</code> */
/**
* JWT {@code Audience} claims parameter name: <code>"aud"</code>
*/
String AUDIENCE = "aud";

/** JWT {@code Expiration} claims parameter name: <code>"exp"</code> */
/**
* JWT {@code Expiration} claims parameter name: <code>"exp"</code>
*/
String EXPIRATION = "exp";

/** JWT {@code Not Before} claims parameter name: <code>"nbf"</code> */
/**
* JWT {@code Not Before} claims parameter name: <code>"nbf"</code>
*/
String NOT_BEFORE = "nbf";

/** JWT {@code Issued At} claims parameter name: <code>"iat"</code> */
/**
* JWT {@code Issued At} claims parameter name: <code>"iat"</code>
*/
String ISSUED_AT = "iat";

/** JWT {@code JWT ID} claims parameter name: <code>"jti"</code> */
/**
* JWT {@code JWT ID} claims parameter name: <code>"jti"</code>
*/
String ID = "jti";

/**
Expand All @@ -81,7 +96,7 @@ public interface Claims extends Map<String, Object>, Identifiable {
*
* @return the JWT {@code aud} value or {@code null} if not present.
*/
String getAudience();
Set<String> getAudience();

/**
* Returns the JWT <a href="https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.4">
Expand Down Expand Up @@ -124,7 +139,8 @@ public interface Claims extends Map<String, Object>, Identifiable {
*
* @return the JWT {@code jti} value or {@code null} if not present.
*/
@Override // just for JavaDoc specific to the JWT spec
@Override
// just for JavaDoc specific to the JWT spec
String getId();

/**
Expand All @@ -133,14 +149,15 @@ public interface Claims extends Map<String, Object>, Identifiable {
* <p>JJWT only converts simple String, Date, Long, Integer, Short and Byte types automatically. Anything more
* complex is expected to be already converted to your desired type by the JSON
* {@link io.jsonwebtoken.io.Deserializer Deserializer} implementation. You may specify a custom Deserializer for a
* JwtParser with the desired conversion configuration via the {@link JwtParserBuilder#deserializeJsonWith} method.
* JwtParser with the desired conversion configuration via the
* {@link JwtParserBuilder#deserializer deserializer} method.
* See <a href="https://github.com/jwtk/jjwt#custom-json-processor">custom JSON processor</a> for more
* information. If using Jackson, you can specify custom claim POJO types as described in
* <a href="https://github.com/jwtk/jjwt#json-jackson-custom-types">custom claim types</a>.
*
* @param claimName name of claim
* @param claimName name of claim
* @param requiredType the type of the value expected to be returned
* @param <T> the type of the value expected to be returned
* @param <T> the type of the value expected to be returned
* @return the JWT {@code claimName} value or {@code null} if not present.
* @throws RequiredTypeException throw if the claim value is not null and not of type {@code requiredType}
*/
Expand Down
44 changes: 38 additions & 6 deletions api/src/main/java/io/jsonwebtoken/ClaimsMutator.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package io.jsonwebtoken;

import java.util.Collection;
import java.util.Date;

/**
Expand Down Expand Up @@ -72,8 +73,12 @@ public interface ClaimsMutator<T extends ClaimsMutator<T>> {
T subject(String sub);

/**
* Sets the JWT <a href="https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.3">
* <code>aud</code></a> (audience) value. A {@code null} value will remove the property from the JSON map.
* Sets the JWT <a href="https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.3"><code>aud</code></a> (audience)
* Claim as <em>a single String, <b>NOT</b></em> a String array</em>. This method exists only for producing
* JWTs sent to legacy recipients that are unable to interpret the {@code aud} value as a JSON String Array; it is
* strongly recommended to avoid calling this method whenever possible and favor the
* {@link #audience(String)} or {@link #audience(Collection)} methods instead, as they ensure a single deterministic
* data type for recipients.
*
* @param aud the JWT {@code aud} value or {@code null} to remove the property from the JSON map.
* @return the {@code Claims} instance for method chaining.
Expand All @@ -84,14 +89,41 @@ public interface ClaimsMutator<T extends ClaimsMutator<T>> {
T setAudience(String aud);

/**
* Sets the JWT <a href="https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.3">
* <code>aud</code></a> (audience) value. A {@code null} value will remove the property from the JSON map.
* Sets the JWT <a href="https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.3"><code>aud</code></a> (audience)
* Claim as <em>a single String, <b>NOT</b></em> a String array</em>. This method exists only for producing
* JWTs sent to legacy recipients that are unable to interpret the {@code aud} value as a JSON String Array; it is
* strongly recommended to avoid calling this method whenever possible and favor the
* {@link #audience(String)} or {@link #audience(Collection)} methods instead, as they ensure a single deterministic
* data type for recipients.
*
* @param aud the value to use as the {@code aud} Claim single-String value (and not an array of Strings), or
* {@code null} to remove the property from the JSON map.
* @return the instance for method chaining
* @since JJWT_RELEASE_VERSION
*/
T audienceSingle(String aud);

/**
* Adds the specified {@code aud} value to the {@link #audience(Collection) audience} Claim set (JSON Array). This
* method may be called multiple times.
*
* @param aud the JWT {@code aud} value or {@code null} to remove the property from the JSON map.
* @param aud a JWT {@code aud} value to add to the {@link #audience(Collection) audience} Claim set.
* @return the {@code Claims} instance for method chaining.
* @throws IllegalArgumentException if the {@code aud} argument is null or empty.
* @since JJWT_RELEASE_VERSION
*/
T audience(String aud) throws IllegalArgumentException;

/**
* Sets the JWT <a href="https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.3"><code>aud</code></a> (audience)
* Claim set, replacing any previous value(s).
*
* @param aud the values to set as the {@code aud} Claim set (JSON Array), or {@code null}/empty to remove the
* {@code aud} claim from the JSON map entirely.
* @return the instance for method chaining
* @since JJWT_RELEASE_VERSION
*/
T audience(String aud);
T audience(Collection<String> aud);

/**
* Sets the JWT <a href="https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.4">
Expand Down
2 changes: 1 addition & 1 deletion api/src/main/java/io/jsonwebtoken/JwtParserBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ public interface JwtParserBuilder extends Builder<JwtParser> {

/**
* Ensures that the specified {@code aud} exists in the parsed JWT. If missing or if the parsed
* value does not equal the specified value, an exception will be thrown indicating that the
* value does not contain the specified value, an exception will be thrown indicating that the
* JWT is invalid and may not be used.
*
* @param audience the required value of the {@code aud} header parameter.
Expand Down
18 changes: 18 additions & 0 deletions api/src/main/java/io/jsonwebtoken/lang/Collections.java
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,24 @@ public static <T> List<T> of(T... elements) {
return java.util.Collections.unmodifiableList(Arrays.asList(elements));
}

/**
* Returns the specified collection as a {@link Set} instance.
*
* @param c the collection to represent as a set
* @param <T> collection element type
* @return a type-safe immutable {@code Set} containing the specified collection elements.
* @since JJWT_RELEASE_VERSION
*/
public static <T> Set<T> asSet(Collection<T> c) {
if (c instanceof Set) {
return (Set<T>) c;
}
if (isEmpty(c)) {
return java.util.Collections.emptySet();
}
return java.util.Collections.unmodifiableSet(new LinkedHashSet<>(c));
}

/**
* Returns a type-safe immutable {@code Set} containing the specified array elements.
*
Expand Down
37 changes: 37 additions & 0 deletions api/src/test/groovy/io/jsonwebtoken/lang/CollectionsTest.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package io.jsonwebtoken.lang

import org.junit.Test

import static org.junit.Assert.*

class CollectionsTest {

@Test
void testAsSetFromNull() {
assertSame java.util.Collections.emptySet(), Collections.asSet(null)
}

@Test
void testAsSetFromEmpty() {
def list = []
assertSame java.util.Collections.emptySet(), Collections.asSet(list)
}

@Test
void testAsSetFromSet() {
def set = Collections.setOf('foo')
assertSame set, Collections.asSet(set)
}

@Test
void testAsSetFromList() {
def list = Collections.of('one', 'two')
def set = Collections.asSet(list)
assertTrue set.containsAll(list)
try {
set.add('another')
fail()
} catch (UnsupportedOperationException ignored) { // expected, asSet returns immutable instances
}
}
}
9 changes: 7 additions & 2 deletions impl/src/main/java/io/jsonwebtoken/impl/DefaultClaims.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import java.util.Date;
import java.util.Map;
import java.util.Set;

public class DefaultClaims extends FieldMap implements Claims {

Expand All @@ -38,7 +39,7 @@ public class DefaultClaims extends FieldMap implements Claims {

static final Field<String> ISSUER = Fields.string(Claims.ISSUER, "Issuer");
static final Field<String> SUBJECT = Fields.string(Claims.SUBJECT, "Subject");
static final Field<String> AUDIENCE = Fields.string(Claims.AUDIENCE, "Audience");
static final Field<Set<String>> AUDIENCE = Fields.stringSet(Claims.AUDIENCE, "Audience");
static final Field<Date> EXPIRATION = Fields.rfcDate(Claims.EXPIRATION, "Expiration Time");
static final Field<Date> NOT_BEFORE = Fields.rfcDate(Claims.NOT_BEFORE, "Not Before");
static final Field<Date> ISSUED_AT = Fields.rfcDate(Claims.ISSUED_AT, "Issued At");
Expand All @@ -51,6 +52,10 @@ protected DefaultClaims() { // visibility for testing
super(FIELDS);
}

public DefaultClaims(FieldMap m) {
super(m.FIELDS, m);
}

public DefaultClaims(Map<String, ?> map) {
super(FIELDS, map);
}
Expand All @@ -71,7 +76,7 @@ public String getSubject() {
}

@Override
public String getAudience() {
public Set<String> getAudience() {
return get(AUDIENCE);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public DefaultJweHeaderMutator(DefaultJweHeaderMutator<?> src) {
// MapMutator methods
// =============================================================

private T put(Field<?> field, Object value) {
private <F> T put(Field<F> field, F value) {
this.DELEGATE.put(field, value);
return self();
}
Expand Down
16 changes: 15 additions & 1 deletion impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
import java.security.Provider;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.util.Collection;
import java.util.Date;
import java.util.Map;

Expand Down Expand Up @@ -393,7 +394,14 @@ public JwtBuilder subject(String sub) {

@Override
public JwtBuilder setAudience(String aud) {
return audience(aud);
this.claimsBuilder.setAudience(aud);
return this;
}

@Override
public JwtBuilder audienceSingle(String aud) {
this.claimsBuilder.audienceSingle(aud);
return this;
}

@Override
Expand All @@ -402,6 +410,12 @@ public JwtBuilder audience(String aud) {
return this;
}

@Override
public JwtBuilder audience(Collection<String> aud) {
this.claimsBuilder.audience(aud);
return this;
}

@Override
public JwtBuilder setExpiration(Date exp) {
return expiration(exp);
Expand Down
24 changes: 22 additions & 2 deletions impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import io.jsonwebtoken.io.Deserializer;
import io.jsonwebtoken.lang.Arrays;
import io.jsonwebtoken.lang.Assert;
import io.jsonwebtoken.lang.Collections;
import io.jsonwebtoken.lang.DateFormats;
import io.jsonwebtoken.lang.Strings;
import io.jsonwebtoken.security.AeadAlgorithm;
Expand Down Expand Up @@ -89,6 +90,9 @@ public class DefaultJwtParser implements JwtParser {
"used to encrypt, and PrivateKeys are used to decrypt.";

public static final String INCORRECT_EXPECTED_CLAIM_MESSAGE_TEMPLATE = "Expected %s claim to be: %s, but was: %s.";

public static final String MISSING_EXPECTED_CLAIM_VALUE_MESSAGE_TEMPLATE =
"Missing expected '%s' value in '%s' claim %s.";
public static final String MISSING_EXPECTED_CLAIM_MESSAGE_TEMPLATE = "Expected %s claim to be: %s, but was not " +
"present in the JWT claims.";

Expand Down Expand Up @@ -627,9 +631,25 @@ private void validateExpectedClaims(Header header, Claims claims) {
}

if (actualClaimValue == null) {
String msg = String.format(MISSING_EXPECTED_CLAIM_MESSAGE_TEMPLATE,
expectedClaimName, expectedClaimValue);
boolean collection = expectedClaimValue instanceof Collection;
String msg = "Missing '" + expectedClaimName + "' claim. Expected value";
if (collection) {
msg += "s: " + expectedClaimValue;
} else {
msg += ": " + expectedClaimValue;
}
throw new MissingClaimException(header, claims, expectedClaimName, expectedClaimValue, msg);
} else if (expectedClaimValue instanceof Collection) {
Collection<?> expectedValues = (Collection<?>) expectedClaimValue;
Collection<?> actualValues = actualClaimValue instanceof Collection ? (Collection<?>) actualClaimValue :
Collections.setOf(actualClaimValue);
for (Object expectedValue : expectedValues) {
if (!Collections.contains(actualValues.iterator(), expectedValue)) {
String msg = String.format(MISSING_EXPECTED_CLAIM_VALUE_MESSAGE_TEMPLATE,
expectedValue, expectedClaimName, actualValues);
throw new IncorrectClaimException(header, claims, expectedClaimName, expectedClaimValue, msg);
}
}
} else if (!expectedClaimValue.equals(actualClaimValue)) {
String msg = String.format(INCORRECT_EXPECTED_CLAIM_MESSAGE_TEMPLATE,
expectedClaimName, expectedClaimValue, actualClaimValue);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ public JwtParserBuilder requireIssuer(String issuer) {

@Override
public JwtParserBuilder requireAudience(String audience) {
expectedClaims.setAudience(audience);
expectedClaims.audience(audience);
return this;
}

Expand Down
Loading

0 comments on commit 59907e2

Please sign in to comment.