Ensured various builder collection argument methods were semantically the same (append operations, not one append, one replace) (#830)

This commit is contained in:
lhazlewood 2023-09-15 09:42:44 -07:00 committed by GitHub
parent 8cb59d760b
commit bf5d81cbb5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 235 additions and 59 deletions

View File

@ -157,7 +157,7 @@ enforcement.
* Convenient and readable [fluent](http://en.wikipedia.org/wiki/Fluent_interface) interfaces, great for IDE
auto-completion to write code quickly
* Fully RFC specification compliant on all implemented functionality, tested against RFC-specified test vectors
* Stable implementation with over 1,300+ tests and enforced 100% test code coverage. Every single method, statement
* Stable implementation with over 1,400+ tests and enforced 100% test code coverage. Every single method, statement
and conditional branch variant in the entire codebase is tested and required to pass on every build.
* Creating, parsing and verifying digitally signed compact JWTs (aka JWSs) with all standard JWS algorithms:

View File

@ -97,7 +97,7 @@ public interface ClaimsMutator<T extends ClaimsMutator<T>> {
* 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.
* {@code null}, empty or whitespace to remove the property from the JSON map.
* @return the instance for method chaining
* @since JJWT_RELEASE_VERSION
* @deprecated This is technically not deprecated because the JWT RFC mandates support for single string values,
@ -108,22 +108,26 @@ public interface ClaimsMutator<T extends ClaimsMutator<T>> {
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.
* Adds (appends) the specified {@code aud} value to the {@link #audience(Collection) audience} Claim set
* (JSON Array) unless it is {@code null}, empty, whitespace-only or already exists in the set.
*
* <p>This method may be called multiple times.</p>
*
* @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;
T audience(String aud);
/**
* 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).
* Adds (appends) the specified values to the JWT
* <a href="https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.3"><code>aud</code></a> (audience) Claim
* set, quietly ignoring any null, empty, whitespace-only, or existing value already in the set.
*
* @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.
* <p>This method may be called multiple times.</p>
*
* @param aud the values to add to the {@code aud} Claim set (JSON Array)
* @return the instance for method chaining
* @since JJWT_RELEASE_VERSION
*/

View File

@ -102,8 +102,8 @@ public interface JwtParserBuilder extends Builder<JwtParser> {
*
* <p><b>Extension Behavior</b></p>
*
* <p>The {@code crit} set only identifies header parameter names that are used in extensions supported by the
* application. <b>Application developers, <em>not JJWT</em>, MUST perform the associated extension behavior
* <p>The {@code crit} set argument only identifies header parameter names that are used in extensions supported
* by the application. <b>Application developers, <em>not JJWT</em>, MUST perform the associated extension behavior
* using the parsed JWT</b>.</p>
*
* @param crit the header parameter names used in JWT extensions supported by the application.

View File

@ -19,7 +19,7 @@ import io.jsonwebtoken.security.PublicJwk;
import io.jsonwebtoken.security.X509Mutator;
import java.net.URI;
import java.util.Set;
import java.util.Collection;
/**
* Mutation (modifications) to a {@link ProtectedHeader Header} instance.
@ -30,17 +30,29 @@ import java.util.Set;
public interface ProtectedHeaderMutator<T extends ProtectedHeaderMutator<T>> extends HeaderMutator<T>, X509Mutator<T> {
/**
* Sets the header parameter names that use extensions to the JWT or JWA specification that <em>MUST</em>
* be understood and supported by the JWT recipient. A {@code null} value will remove the
* property from the JSON map.
* Adds the name of a header parameter used by a JWT or JWA specification extension that <em>MUST</em> be understood
* and supported by the JWT recipient. A {@code null}, empty, whitespace-only or already existing value is ignored.
*
* @param crit the header parameter names that use extensions to the JWT or JWA specification that <em>MUST</em>
* be understood and supported by the JWT recipient.
* @param crit the name of a header parameter used by a JWT or JWA specification extension that <em>MUST</em> be
* understood and supported by the JWT recipient.
* @return the header for method chaining.
* @see <a href="https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.11">JWS <code>crit</code> (Critical) Header Parameter</a>
* @see <a href="https://www.rfc-editor.org/rfc/rfc7516.html#section-4.1.13">JWS <code>crit</code> (Critical) Header Parameter</a>
*/
T critical(Set<String> crit);
T critical(String crit);
/**
* Adds names of header parameters used by JWT or JWA specification extensions that <em>MUST</em> be
* understood and supported by the JWT recipient. {@code null}, empty, whitespace-only or already existing
* values are ignored.
*
* @param crit names of header parameters used by JWT or JWA specification extensions that <em>MUST</em> be
* understood and supported by the JWT recipient
* @return the header for method chaining.
* @see <a href="https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.11">JWS <code>crit</code> (Critical) Header Parameter</a>
* @see <a href="https://www.rfc-editor.org/rfc/rfc7516.html#section-4.1.13">JWS <code>crit</code> (Critical) Header Parameter</a>
*/
T critical(Collection<String> crit);
/**
* Sets the {@code jwk} (JSON Web Key) associated with the JWT. When set for a {@link JwsHeader}, the

View File

@ -180,7 +180,6 @@ public final class Collections {
/**
* Returns a non-null set, either {@code s} if it is not null, or {@link #emptySet()} otherwise.
* Returns the specified set if not null or {@link #emptySet()} otherwise.
*
* @param s the set to check for null
* @param <T> type of elements in the set
@ -191,6 +190,18 @@ public final class Collections {
return s == null ? Collections.<T>emptySet() : s;
}
/**
* Returns a non-null collection, either {@code c} if it is not null, or {@link #emptyList()} otherwise.
*
* @param c the collection to check for null
* @param <T> type of elements in the collection
* @return a non-null collection, either {@code c} if it is not null, or {@link #emptyList()} otherwise.
* @since JJWT_RELEASE_VERSION
*/
public static <T> Collection<T> nullSafe(Collection<T> c) {
return c == null ? Collections.<T>emptyList() : c;
}
/**
* Return <code>true</code> if the supplied Collection is <code>null</code>
* or empty. Otherwise, return <code>false</code>.

View File

@ -138,14 +138,14 @@ public interface JwkBuilder<K extends Key, J extends Jwk<K>, T extends JwkBuilde
*
* @param operation the value to add to the JWK {@code key_ops} value set
* @return the builder for method chaining.
* @throws IllegalArgumentException if {@code op} is {@code null} or if the operation is not permitted
* by the operations {@link #operationPolicy(KeyOperationPolicy) policy}.
* @throws IllegalArgumentException if the {@code op} is not permitted by the operations
* {@link #operationPolicy(KeyOperationPolicy) policy}.
* @see Jwks.OP
*/
T operation(KeyOperation operation) throws IllegalArgumentException;
/**
* Sets the JWK <a href="https://www.rfc-editor.org/rfc/rfc7517.html#section-4.3">{@code key_ops}
* Adds {@code ops} to the JWK <a href="https://www.rfc-editor.org/rfc/rfc7517.html#section-4.3">{@code key_ops}
* (Key Operations) Parameter</a> values.
*
* <p>The {@code key_ops} (key operations) parameter identifies the operation(s) for which the key is
@ -176,10 +176,10 @@ public interface JwkBuilder<K extends Key, J extends Jwk<K>, T extends JwkBuilde
* <p>For best interoperability with other applications however, it is recommended to use only the {@link Jwks.OP}
* constants.</p>
*
* @param ops the JWK {@code key_ops} value set, or {@code null} if not present.
* @param ops the {@code KeyOperation} values to add to the JWK {@code key_ops} value set
* @return the builder for method chaining.
* @throws IllegalArgumentException {@code ops} is {@code null} or empty, or if any of the operations are not
* permitted by the operations {@link #operationPolicy(KeyOperationPolicy) policy}.
* @throws IllegalArgumentException if any of the operations are not permitted by the operations
* {@link #operationPolicy(KeyOperationPolicy) policy}.
* @see Jwks.OP
*/
T operations(Collection<KeyOperation> ops) throws IllegalArgumentException;

View File

@ -52,7 +52,7 @@ class CollectionsTest {
@Test
void testNullSafeSetWithNullArgument() {
def set = Collections.nullSafe(null)
def set = Collections.nullSafe((Set)null)
assertNotNull set
assertTrue set.isEmpty()
}
@ -70,4 +70,25 @@ class CollectionsTest {
def b = Collections.nullSafe(a)
assertSame a, b
}
@Test
void testNullSafeCollectionWithNullArgument() {
Collection c = Collections.nullSafe((Collection)null)
assertNotNull c
assertTrue c.isEmpty()
}
@Test
void testNullSafeCollectionWithEmptyArgument() {
Collection a = new LinkedHashSet() as Collection
def b = Collections.nullSafe(a)
assertSame a, b
}
@Test
void testNullSafeCollectionWithNonEmptyArgument() {
Collection a = ["hello"] as Collection<String>
def b = Collections.nullSafe(a)
assertSame a, b
}
}

View File

@ -19,11 +19,14 @@ import io.jsonwebtoken.JweHeaderMutator;
import io.jsonwebtoken.impl.lang.DelegatingMapMutator;
import io.jsonwebtoken.impl.lang.Parameter;
import io.jsonwebtoken.impl.security.X509BuilderSupport;
import io.jsonwebtoken.lang.Collections;
import io.jsonwebtoken.lang.Strings;
import io.jsonwebtoken.security.PublicJwk;
import java.net.URI;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@ -102,8 +105,29 @@ public class DefaultJweHeaderMutator<T extends JweHeaderMutator<T>>
// =============================================================
@Override
public T critical(Set<String> crit) {
return put(DefaultProtectedHeader.CRIT, crit);
public T critical(String crit) {
crit = Strings.clean(crit);
if (Strings.hasText(crit)) {
critical(Collections.setOf(crit));
}
return self();
}
@Override
public T critical(Collection<String> crit) {
if (!Collections.isEmpty(crit)) {
Set<String> existing = Collections.nullSafe(this.DELEGATE.get(DefaultProtectedHeader.CRIT));
Set<String> set = new LinkedHashSet<>(existing.size() + crit.size());
set.addAll(existing);
for (String s : crit) {
s = Strings.clean(s);
if (s != null) {
set.add(s);
}
}
put(DefaultProtectedHeader.CRIT, set);
}
return self();
}
@Override

View File

@ -19,7 +19,6 @@ import io.jsonwebtoken.ClaimsMutator;
import io.jsonwebtoken.impl.lang.DelegatingMapMutator;
import io.jsonwebtoken.impl.lang.Parameter;
import io.jsonwebtoken.impl.lang.Parameters;
import io.jsonwebtoken.lang.Assert;
import io.jsonwebtoken.lang.Collections;
import io.jsonwebtoken.lang.MapMutator;
import io.jsonwebtoken.lang.Strings;
@ -88,8 +87,7 @@ public class DelegatingClaimsMutator<T extends MapMutator<String, Object, T> & C
put(DefaultClaims.AUDIENCE, Collections.setOf(existing)); // replace as Set
}
}
Set<String> aud = get(DefaultClaims.AUDIENCE);
return aud != null ? aud : Collections.<String>emptySet();
return get(DefaultClaims.AUDIENCE);
}
@Override
@ -108,16 +106,28 @@ public class DelegatingClaimsMutator<T extends MapMutator<String, Object, T> & C
@Override
public T audience(String aud) {
aud = Assert.hasText(Strings.clean(aud), "Audience string value cannot be null or empty.");
Set<String> set = new LinkedHashSet<>(getAudience());
set.add(aud);
return audience(set);
aud = Strings.clean(aud);
if (Strings.hasText(aud)) {
audience(java.util.Collections.singleton(aud));
}
return self();
}
@Override
public T audience(Collection<String> aud) {
getAudience(); //coerce to Set<String> if necessary
return put(DefaultClaims.AUDIENCE, Collections.asSet(aud));
if (!Collections.isEmpty(aud)) {
Set<String> existing = Collections.nullSafe(getAudience());
Set<String> set = new LinkedHashSet<>(existing.size() + aud.size());
set.addAll(existing);
for (String s : aud) {
s = Strings.clean(s);
if (s != null) {
set.add(s);
}
}
put(DefaultClaims.AUDIENCE, set);
}
return self();
}
@Override

View File

@ -109,18 +109,14 @@ abstract class AbstractJwkBuilder<K extends Key, J extends Jwk<K>, T extends Jwk
@Override
public T operation(KeyOperation operation) throws IllegalArgumentException {
Assert.notNull(operation, "KeyOperation cannot be null.");
return operations(Collections.setOf(operation));
return operation != null ? operations(Collections.setOf(operation)) : self();
}
@Override
public T operations(Collection<KeyOperation> ops) {
Assert.notEmpty(ops, "KeyOperations collection argument cannot be null or empty.");
Set<KeyOperation> set = new LinkedHashSet<>(ops); // new ones override existing ones
Set<KeyOperation> existing = this.DELEGATE.getOperations();
if (!Collections.isEmpty(existing)) {
Set<KeyOperation> set = new LinkedHashSet<>(Collections.nullSafe(ops)); // new ones override existing ones
Set<KeyOperation> existing = Collections.nullSafe(this.DELEGATE.getOperations());
set.addAll(existing);
}
this.opsPolicy.validate(set);
this.DELEGATE.setOperations(set);
return self();

View File

@ -647,6 +647,18 @@ class DefaultJwtBuilderTest {
assertEquals aud, Jwts.parser().enableUnsecured().build().parseClaimsJwt(jwt).payload.getAudience().iterator().next()
}
@Test
void testAudienceNullString() {
def jwt = Jwts.builder().subject('me').audience(null).compact()
assertNull Jwts.parser().enableUnsecured().build().parseClaimsJwt(jwt).payload.getAudience()
}
@Test
void testAudienceEmptyString() {
def jwt = Jwts.builder().subject('me').audience(' ').compact()
assertNull Jwts.parser().enableUnsecured().build().parseClaimsJwt(jwt).payload.getAudience()
}
@Test
void testAudienceMultipleTimes() {
def one = 'one'
@ -657,6 +669,28 @@ class DefaultJwtBuilderTest {
assertTrue aud.contains(two)
}
@Test
void testAudienceNullCollection() {
Collection c = null
def jwt = Jwts.builder().subject('me').audience(c).compact()
assertNull Jwts.parser().enableUnsecured().build().parseClaimsJwt(jwt).payload.getAudience()
}
@Test
void testAudienceEmptyCollection() {
Collection c = new ArrayList()
def jwt = Jwts.builder().subject('me').audience(c).compact()
assertNull Jwts.parser().enableUnsecured().build().parseClaimsJwt(jwt).payload.getAudience()
}
@Test
void testAudienceCollectionWithNullElement() {
Collection c = new ArrayList()
c.add(null)
def jwt = Jwts.builder().subject('me').audience(c).compact()
assertNull Jwts.parser().enableUnsecured().build().parseClaimsJwt(jwt).payload.getAudience()
}
/**
* Asserts that if someone calls builder.audienceSingle and then audience(String), that the audience value
* will automatically be coerced from a String to a Set<String> and contain both elements.
@ -693,17 +727,17 @@ class DefaultJwtBuilderTest {
/**
* Asserts that if someone calls builder.audienceSingle and then audience(Collection), the builder coerces the
* aud to a Set<String> and only the elements in the Collection will be applied since audience(Collection) is a
* full-replacement operation.
* aud to a Set<String> and all elements will be applied since audience(Collection).
*/
@Test
void testAudienceSingleThenAudienceCollection() {
def single = 'one'
def collection = ['two', 'three'] as Set<String>
def expected = ['one', 'two', 'three'] as Set<String>
def jwt = Jwts.builder().audienceSingle(single).audience(collection).compact()
def aud = Jwts.parser().enableUnsecured().build().parseClaimsJwt(jwt).payload.getAudience()
assertEquals collection.size(), aud.size()
assertTrue aud.containsAll(collection)
assertEquals expected.size(), aud.size()
assertTrue aud.contains(single) && aud.containsAll(collection)
}
/**

View File

@ -496,21 +496,46 @@ class DefaultJwtHeaderBuilderTest {
}
@Test
void testCritNullRemovesValues() {
def crit = ['test'] as Set<String>
def header = jws().add('test', 'foo').critical(crit).build() as ProtectedHeader
assertEquals crit, header.getCritical()
header = builder.critical(null).build()
assertFalse header.containsKey('crit') // removed
void testCritSingle() {
def crit = 'test'
def header = jws().add(crit, 'foo').critical(crit).build() as ProtectedHeader
def expected = [crit] as Set<String>
assertEquals expected, header.getCritical()
}
@Test
void testCritEmptyRemovesValues() {
void testCritSingleNullIgnored() {
def crit = 'test'
def expected = [crit] as Set<String>
def header = jws().add(crit, 'foo').critical(crit).build() as ProtectedHeader
assertEquals expected, header.getCritical()
header = builder.critical((String) null).build() as ProtectedHeader // ignored
assertEquals expected, header.getCritical() // nothing changed
}
@Test
void testCritNullCollectionIgnored() {
def crit = ['test'] as Set<String>
def header = jws().add('test', 'foo').critical(crit).build() as ProtectedHeader
assertEquals crit, header.getCritical()
header = builder.critical((Collection) null).build() as ProtectedHeader
assertEquals crit, header.getCritical() // nothing changed
}
@Test
void testCritCollectionWithNullElement() {
def crit = [null] as Set<String>
def header = jws().add('test', 'foo').critical(crit).build() as ProtectedHeader
assertNull header.getCritical()
}
@Test
void testCritEmptyIgnored() {
def crit = ['test'] as Set<String>
def header = jws().add('test', 'foo').critical(crit).build() as ProtectedHeader
assertEquals crit, header.getCritical()
header = builder.critical([] as Set<String>).build()
assertFalse header.containsKey('crit') // removed
assertEquals crit, header.getCritical() // ignored
}
/**

View File

@ -142,11 +142,50 @@ class JwksTest {
testProperty('id', 'kid', srandom())
}
@Test
void testSingleOperation() {
def op = Jwks.OP.ENCRYPT
def expected = [op] as Set<KeyOperation>
def canonical = [op.getId()] as Set<String>
def jwk = Jwks.builder().key(TestKeys.A128GCM).operation(op).build()
assertEquals expected, jwk.getOperations()
assertEquals canonical, jwk.get(AbstractJwk.KEY_OPS.id)
}
@Test
void testSingleOperationNull() {
def jwk = Jwks.builder().key(TestKeys.A128GCM).operation(null).build() //ignored null
assertNull jwk.getOperations() //nothing added
assertFalse jwk.containsKey(AbstractJwk.KEY_OPS.id)
}
@Test
void testSingleOperationAppends() {
def expected = [Jwks.OP.ENCRYPT, Jwks.OP.DECRYPT] as Set<KeyOperation>
def jwk = Jwks.builder().key(TestKeys.A128GCM)
.operation(Jwks.OP.ENCRYPT).operation(Jwks.OP.DECRYPT)
.build()
assertEquals expected, jwk.getOperations()
}
@Test
void testOperations() {
def val = [Jwks.OP.SIGN, Jwks.OP.VERIFY] as Set<KeyOperation>
def canonical = Collections.setOf('sign', 'verify')
testProperty('operations', 'key_ops', val, canonical)
def jwk = Jwks.builder().key(TestKeys.A128GCM).operations(val).build()
assertEquals val, jwk.getOperations()
}
@Test
void testOperationsNull() {
def jwk = Jwks.builder().key(TestKeys.A128GCM).operations(null).build()
assertNull jwk.getOperations()
assertFalse jwk.containsKey(AbstractJwk.KEY_OPS.id)
}
@Test
void testOperationsEmpty() {
def jwk = Jwks.builder().key(TestKeys.A128GCM).operations(Collections.emptyList()).build()
assertNull jwk.getOperations()
}
@Test