Automatic compact `cty` header (#795)

Ensured header 'cty' raw value reflects a compact form but getContentType() return value reflects a normalized value per per https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.10
This commit is contained in:
lhazlewood 2023-08-09 12:20:10 -07:00 committed by GitHub
parent 7ed0b772ae
commit c142fb5c7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 102 additions and 60 deletions

View File

@ -62,28 +62,34 @@ public interface HeaderMutator<T extends HeaderMutator<T>> extends MapMutator<St
T type(String typ); T type(String typ);
/** /**
* Sets the JWT <a href="https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.10"> * Sets the compact <a href="https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.10">
* <code>cty</code> (Content Type)</a> header parameter value. A {@code null} value will remove the property from * <code>cty</code> (Content Type)</a> header parameter value, used by applications to declare the
* the JSON map. * <a href="https://www.iana.org/assignments/media-types/media-types.xhtml">IANA MediaType</a> of the JWT
* payload. A {@code null} value will remove the property from the JSON map.
* *
* <p>The <code>cty</code> (Content Type) Header Parameter is used by applications to declare the * <p><b>Compact Media Type Identifier</b></p>
* <a href="https://www.iana.org/assignments/media-types/media-types.xhtml">IANA MediaType</a> of the content
* (the payload). This is intended for use by the application when more than
* one kind of object could be present in the Payload; the application can use this value to disambiguate among
* the different kinds of objects that might be present. It will typically not be used by applications when
* the kind of object is already known. This parameter is ignored by JWT implementations (like JJWT); any
* processing of this parameter is performed by the JWS application. Use of this Header Parameter is OPTIONAL.</p>
* *
* <p>To keep messages compact in common situations, it is RECOMMENDED that producers omit an * <p>This method will automatically remove any <code><b>application/</b></code> prefix from the
* <b><code>application/</code></b> prefix of a media type value in a {@code cty} Header Parameter when * {@code cty} string if possible according to the rules defined in the last paragraph of
* no other '<b>/</b>' appears in the media type value. A recipient using the media type value <em>MUST</em> * <a href="https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.10">RFC 7517, Section 4.1.10</a>:</p>
* treat it as if <b><code>application/</code></b> were prepended to any {@code cty} value not containing a * <blockquote><pre>
* '<b>/</b>'. For instance, a {@code cty} value of <b><code>example</code></b> <em>SHOULD</em> be used to * To keep messages compact in common situations, it is RECOMMENDED that
* represent the <b><code>application/example</code></b> media type, whereas the media type * producers omit an "application/" prefix of a media type value in a
* <b><code>application/example;part=&quot;1/2&quot;</code></b> cannot be shortened to * "cty" Header Parameter when no other '/' appears in the media type
* <b><code>example;part=&quot;1/2&quot;</code></b>.</p> * value. A recipient using the media type value MUST treat it as if
* "application/" were prepended to any "cty" value not containing a
* '/'. For instance, a "cty" value of "example" SHOULD be used to
* represent the "application/example" media type, whereas the media
* type "application/example;part="1/2"" cannot be shortened to
* "example;part="1/2"".</pre></blockquote>
* *
* @param cty the JWT JOSE {@code cty} header value or {@code null} to remove the property from the JSON map. * <p>JJWT performs the reverse during JWT parsing: {@link Header#getContentType()} will automatically prepend the
* {@code application/} prefix if the parsed {@code cty} value does not contain a '<code>/</code>' character (as
* mandated by the RFC language above). This ensures application developers can use and read standard IANA Media
* Type identifiers without needing JWT-specific prefix conditional logic in application code.
* </p>
*
* @param cty the JWT {@code cty} header value or {@code null} to remove the property from the JSON map.
* @return the instance for method chaining. * @return the instance for method chaining.
*/ */
T contentType(String cty); T contentType(String cty);

View File

@ -155,7 +155,7 @@ public interface JwtBuilder extends ClaimsMutator<JwtBuilder> {
* {@link #content(byte[], String)} instead. * {@link #content(byte[], String)} instead.
* *
* <p>This is a wrapper method for:</p> * <p>This is a wrapper method for:</p>
* <blockquote><pre> * <blockquote><pre>s
* {@link #content(byte[]) setPayload}(payload.getBytes(StandardCharsets.UTF_8));</pre></blockquote> * {@link #content(byte[]) setPayload}(payload.getBytes(StandardCharsets.UTF_8));</pre></blockquote>
* *
* <p>If you want the JWT payload to be JSON, use the {@link #claims()} method instead.</p> * <p>If you want the JWT payload to be JSON, use the {@link #claims()} method instead.</p>
@ -178,9 +178,6 @@ public interface JwtBuilder extends ClaimsMutator<JwtBuilder> {
/** /**
* Sets the JWT payload to be the specified content byte array. * Sets the JWT payload to be the specified content byte array.
* *
* <p>This method is mutually exclusive of the {@link #claims()} and {@link #claim(String, Object)}
* methods. Either {@code claims} or {@code content} method variants may be used, but not both.</p>
*
* <p><b>Content Type Recommendation</b></p> * <p><b>Content Type Recommendation</b></p>
* *
* <p>Unless you are confident that the JWT recipient will <em>always</em> know how to use * <p>Unless you are confident that the JWT recipient will <em>always</em> know how to use
@ -188,44 +185,57 @@ public interface JwtBuilder extends ClaimsMutator<JwtBuilder> {
* {@link #content(byte[], String)} method instead of this one. That method ensures that a JWT recipient * {@link #content(byte[], String)} method instead of this one. That method ensures that a JWT recipient
* can inspect the {@code cty} header to know how to handle the byte array without ambiguity.</p> * can inspect the {@code cty} header to know how to handle the byte array without ambiguity.</p>
* *
* <p>Note that the content and claims properties are mutually exclusive - only one of the two may be used.</p> * <p><b>Mutually Exclusive Claims and Content</b></p>
*
* <p>This method is mutually exclusive of the {@link #claim(String, Object)} and {@link #claims()}
* methods. Either {@code claims} or {@code content} method variants may be used, but not both. If you want the
* JWT payload to be JSON claims, use the {@link #claim(String, Object)} or {@link #claims()} methods instead.</p>
* *
* @param content the content byte array to use as the JWT payload * @param content the content byte array to use as the JWT payload
* @return the builder for method chaining. * @return the builder for method chaining.
* @see #content(byte[], String)
* @since JJWT_RELEASE_VERSION * @since JJWT_RELEASE_VERSION
*/ */
JwtBuilder content(byte[] content); JwtBuilder content(byte[] content);
/** /**
* Convenience method that sets the JWT payload to be the specified content byte array and also sets the * Sets the JWT payload to be the specified content byte array and also sets the
* {@link BuilderHeader#contentType(String) contentType} header value to a compact {@code cty} media type * {@link BuilderHeader#contentType(String) contentType} header value to a compact {@code cty} IANA Media Type
* identifier to indicate the data format of the byte array. The JWT recipient can inspect the * identifier to indicate the data format of the byte array. The JWT recipient can inspect the
* {@code cty} value to determine how to convert the byte array to the final content type as desired. * {@code cty} value to determine how to convert the byte array to the final content type as desired.
* *
* <p>This method is mutually exclusive of the {@link #claim(String, Object)} and {@link #claims()} * <p>This is a convenience method semantically equivalent to:</p>
* methods. Either {@code claims} or {@code content} method variants may be used, but not both.</p> * <blockquote><pre>
* {@link #header()}.{@link HeaderMutator#contentType(String) contentType(cty)}.{@link BuilderHeader#and() and()}
* {@link #content(byte[]) content(content)}</pre></blockquote>
* *
* <p><b>Compact Media Type Identifier</b></p> * <p><b>Compact Media Type Identifier</b></p>
* *
* <p>As a convenience, this method will automatically trim any <code><b>application/</b></code> prefix from the * <p>This method will automatically remove any <code><b>application/</b></code> prefix from the
* {@code cty} string if possible according to the * {@code cty} string if possible according to the rules defined in the last paragraph of
* <a href="https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.10">JWT specification recommendations</a>.</p> * <a href="https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.10">RFC 7517, Section 4.1.10</a>:</p>
*
* <p>If for some reason you do not wish to adhere to the JWT specification recommendation, do not call this
* method - instead call {@link #content(byte[])} and set the header's
* {@link BuilderHeader#contentType(String) contentType} independently. For example:</p>
*
* <blockquote><pre> * <blockquote><pre>
* Jwts.builder() * To keep messages compact in common situations, it is RECOMMENDED that
* .header().contentType("application/whatever").and() * producers omit an "application/" prefix of a media type value in a
* .content(byteArray) * "cty" Header Parameter when no other '/' appears in the media type
* ... * value. A recipient using the media type value MUST treat it as if
* .build();</pre></blockquote> * "application/" were prepended to any "cty" value not containing a
* '/'. For instance, a "cty" value of "example" SHOULD be used to
* represent the "application/example" media type, whereas the media
* type "application/example;part="1/2"" cannot be shortened to
* "example;part="1/2"".</pre></blockquote>
* *
* <p>If you want the JWT payload to be JSON claims, use the {@link #claim(String, Object)} or * <p>JJWT performs the reverse during JWT parsing: {@link Header#getContentType()} will automatically prepend the
* {@link #claims()} methods instead.</p> * {@code application/} prefix if the parsed {@code cty} value does not contain a '<code>/</code>' character (as
* mandated by the RFC language above). This ensures application developers can use and read standard IANA Media
* Type identifiers without needing JWT-specific prefix conditional logic in application code.
* </p>
* *
* <p>Note that the content and claims properties are mutually exclusive - only one of the two may be used.</p> * <p><b>Mutually Exclusive Claims and Content</b></p>
*
* <p>This method is mutually exclusive of the {@link #claim(String, Object)} and {@link #claims()}
* methods. Either {@code claims} or {@code content} method variants may be used, but not both. If you want the
* JWT payload to be JSON claims, use the {@link #claim(String, Object)} or {@link #claims()} methods instead.</p>
* *
* @param content the content byte array that will be the JWT payload. Cannot be null or empty. * @param content the content byte array that will be the JWT payload. Cannot be null or empty.
* @param cty the content type (media type) identifier attributed to the byte array. Cannot be null or empty. * @param cty the content type (media type) identifier attributed to the byte array. Cannot be null or empty.

View File

@ -16,6 +16,7 @@
package io.jsonwebtoken.impl; package io.jsonwebtoken.impl;
import io.jsonwebtoken.Header; import io.jsonwebtoken.Header;
import io.jsonwebtoken.impl.lang.CompactMediaTypeIdConverter;
import io.jsonwebtoken.impl.lang.Field; import io.jsonwebtoken.impl.lang.Field;
import io.jsonwebtoken.impl.lang.Fields; import io.jsonwebtoken.impl.lang.Fields;
import io.jsonwebtoken.lang.Registry; import io.jsonwebtoken.lang.Registry;
@ -26,7 +27,9 @@ import java.util.Map;
public class DefaultHeader extends FieldMap implements Header { public class DefaultHeader extends FieldMap implements Header {
static final Field<String> TYPE = Fields.string(Header.TYPE, "Type"); static final Field<String> TYPE = Fields.string(Header.TYPE, "Type");
static final Field<String> CONTENT_TYPE = Fields.string(Header.CONTENT_TYPE, "Content Type"); static final Field<String> CONTENT_TYPE = Fields.builder(String.class)
.setId(Header.CONTENT_TYPE).setName("Content Type")
.setConverter(CompactMediaTypeIdConverter.INSTANCE).build();
static final Field<String> ALGORITHM = Fields.string(Header.ALGORITHM, "Algorithm"); static final Field<String> ALGORITHM = Fields.string(Header.ALGORITHM, "Algorithm");
static final Field<String> COMPRESSION_ALGORITHM = Fields.string(Header.COMPRESSION_ALGORITHM, "Compression Algorithm"); static final Field<String> COMPRESSION_ALGORITHM = Fields.string(Header.COMPRESSION_ALGORITHM, "Compression Algorithm");
@SuppressWarnings("DeprecatedIsStillUsed") @SuppressWarnings("DeprecatedIsStillUsed")

View File

@ -20,7 +20,6 @@ import io.jsonwebtoken.JweHeader;
import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.impl.lang.Bytes; import io.jsonwebtoken.impl.lang.Bytes;
import io.jsonwebtoken.impl.lang.CompactMediaTypeIdConverter;
import io.jsonwebtoken.impl.lang.Function; import io.jsonwebtoken.impl.lang.Function;
import io.jsonwebtoken.impl.lang.Functions; import io.jsonwebtoken.impl.lang.Functions;
import io.jsonwebtoken.impl.lang.Services; import io.jsonwebtoken.impl.lang.Services;
@ -325,9 +324,7 @@ public class DefaultJwtBuilder implements JwtBuilder {
public JwtBuilder content(byte[] content, String cty) { public JwtBuilder content(byte[] content, String cty) {
Assert.notEmpty(content, "content byte array cannot be null or empty."); Assert.notEmpty(content, "content byte array cannot be null or empty.");
Assert.hasText(cty, "Content Type String cannot be null or empty."); Assert.hasText(cty, "Content Type String cannot be null or empty.");
cty = CompactMediaTypeIdConverter.INSTANCE.applyFrom(cty); return header().contentType(cty).and().content(content);
this.headerBuilder.contentType(cty);
return content(content);
} }
@Override @Override

View File

@ -22,7 +22,9 @@ public final class CompactMediaTypeIdConverter implements Converter<String, Obje
public static final Converter<String, Object> INSTANCE = new CompactMediaTypeIdConverter(); public static final Converter<String, Object> INSTANCE = new CompactMediaTypeIdConverter();
private static final String APP_MEDIA_TYPE_PREFIX = "application/"; private static final char FORWARD_SLASH = '/';
private static final String APP_MEDIA_TYPE_PREFIX = "application" + FORWARD_SLASH;
static String compactIfPossible(String cty) { static String compactIfPossible(String cty) {
Assert.hasText(cty, "Value cannot be null or empty."); Assert.hasText(cty, "Value cannot be null or empty.");
@ -31,7 +33,7 @@ public final class CompactMediaTypeIdConverter implements Converter<String, Obje
// we can only use the compact form if no other '/' exists in the string // we can only use the compact form if no other '/' exists in the string
for (int i = cty.length() - 1; i >= APP_MEDIA_TYPE_PREFIX.length(); i--) { for (int i = cty.length() - 1; i >= APP_MEDIA_TYPE_PREFIX.length(); i--) {
char c = cty.charAt(i); char c = cty.charAt(i);
if (c == '/') { if (c == FORWARD_SLASH) {
return cty; // found another '/', can't compact, so just return unmodified return cty; // found another '/', can't compact, so just return unmodified
} }
} }
@ -49,8 +51,18 @@ public final class CompactMediaTypeIdConverter implements Converter<String, Obje
@Override @Override
public String applyFrom(Object o) { public String applyFrom(Object o) {
Assert.notNull(o, "Value cannot be null."); Assert.notNull(o, "Value cannot be null.");
Assert.isInstanceOf(String.class, o, "Value must be a string."); String s = Assert.isInstanceOf(String.class, o, "Value must be a string.");
String s = (String) o;
return compactIfPossible(s); // https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.10:
//
// A recipient using the media type value MUST treat it as if
// "application/" were prepended to any "cty" value not containing a
// '/'.
//
if (s.indexOf(FORWARD_SLASH) < 0) {
s = APP_MEDIA_TYPE_PREFIX + s;
}
return s;
} }
} }

View File

@ -178,7 +178,10 @@ class JwtsTest {
String cty = "application/$subtype" String cty = "application/$subtype"
String compact = Jwts.builder().content(s.getBytes(StandardCharsets.UTF_8), cty).compact() String compact = Jwts.builder().content(s.getBytes(StandardCharsets.UTF_8), cty).compact()
def jwt = Jwts.parser().enableUnsecured().build().parseContentJwt(compact) def jwt = Jwts.parser().enableUnsecured().build().parseContentJwt(compact)
assertEquals subtype, jwt.header.getContentType() // assert that the compact form was used // assert raw value is compact form:
assertEquals subtype, jwt.header.get('cty')
// assert getter reflects normalized form per https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.10:
assertEquals cty, jwt.header.getContentType()
assertEquals s, new String(jwt.payload, StandardCharsets.UTF_8) assertEquals s, new String(jwt.payload, StandardCharsets.UTF_8)
} }

View File

@ -39,8 +39,11 @@ class DefaultHeaderTest {
@Test @Test
void testContentType() { void testContentType() {
header = h([cty: 'bar']) header = h([cty: 'bar'])
assertEquals 'bar', header.getContentType() // Per per https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.10, the raw header should have a
assertEquals 'bar', header.get('cty') // compact form, but application developers shouldn't have to check for that all the time, so our getter has
// the normalized form:
assertEquals 'bar', header.get('cty') // raw compact form
assertEquals 'application/bar', header.getContentType() // getter normalized form
} }
@Test @Test

View File

@ -266,7 +266,10 @@ class DefaultJwtHeaderBuilderTest {
@Test @Test
void testDeprecatedSetters() { // TODO: remove before 1.0 void testDeprecatedSetters() { // TODO: remove before 1.0
assertEquals 'foo', builder.setType('foo').build().getType() assertEquals 'foo', builder.setType('foo').build().getType()
assertEquals 'foo', builder.setContentType('foo').build().getContentType()
assertEquals 'foo', builder.setContentType('foo').build().get('cty') // compact form
assertEquals 'application/foo', builder.build().getContentType() // normalized form
assertEquals 'foo', builder.setCompressionAlgorithm('foo').build().getCompressionAlgorithm() assertEquals 'foo', builder.setCompressionAlgorithm('foo').build().getCompressionAlgorithm()
assertEquals 'foo', builder.setKeyId('foo').build().getKeyId() assertEquals 'foo', builder.setKeyId('foo').build().getKeyId()
assertEquals 'foo', builder.setAlgorithm('foo').build().getAlgorithm() assertEquals 'foo', builder.setAlgorithm('foo').build().getAlgorithm()

View File

@ -52,7 +52,9 @@ class CompactMediaTypeIdConverterTest {
void testNonApplicationMediaType() { void testNonApplicationMediaType() {
String cty = 'foo' String cty = 'foo'
assertEquals cty, converter.applyTo(cty) assertEquals cty, converter.applyTo(cty)
assertEquals cty, converter.applyFrom(cty) // must auto-prepend 'application/' if no slash in cty value
// per https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.10:
assertEquals "application/$cty" as String, converter.applyFrom(cty)
} }
@Test @Test

View File

@ -610,7 +610,10 @@ class RFC7520Section5Test {
assertEquals alg.getId(), parsed.header.getAlgorithm() assertEquals alg.getId(), parsed.header.getAlgorithm()
assertEquals FIGURE_99, b64Url(parsed.header.getPbes2Salt()) assertEquals FIGURE_99, b64Url(parsed.header.getPbes2Salt())
assertEquals p2c, parsed.header.getPbes2Count() assertEquals p2c, parsed.header.getPbes2Count()
assertEquals cty, parsed.header.getContentType()
assertEquals cty, parsed.header.get('cty') // compact form
assertEquals "application/$cty" as String, parsed.header.getContentType() // normalized form
assertEquals enc.getId(), parsed.header.getEncryptionAlgorithm() assertEquals enc.getId(), parsed.header.getEncryptionAlgorithm()
assertEquals FIGURE_95, utf8(parsed.payload) assertEquals FIGURE_95, utf8(parsed.payload)
} }