Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatic compact cty header #795

Merged
merged 6 commits into from
Aug 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 25 additions & 19 deletions api/src/main/java/io/jsonwebtoken/HeaderMutator.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,28 +62,34 @@ public interface HeaderMutator<T extends HeaderMutator<T>> extends MapMutator<St
T type(String typ);

/**
* Sets the JWT <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
* the JSON map.
* 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, used by applications to declare the
* <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
* <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><b>Compact Media Type Identifier</b></p>
*
* <p>To keep messages compact in common situations, it is RECOMMENDED that producers omit an
* <b><code>application/</code></b> prefix of a media type value in a {@code cty} Header Parameter when
* no other '<b>/</b>' appears in the media type value. A recipient using the media type value <em>MUST</em>
* treat it as if <b><code>application/</code></b> were prepended to any {@code cty} value not containing a
* '<b>/</b>'. For instance, a {@code cty} value of <b><code>example</code></b> <em>SHOULD</em> be used to
* represent the <b><code>application/example</code></b> media type, whereas the media type
* <b><code>application/example;part=&quot;1/2&quot;</code></b> cannot be shortened to
* <b><code>example;part=&quot;1/2&quot;</code></b>.</p>
* <p>This method will automatically remove any <code><b>application/</b></code> prefix from 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">RFC 7517, Section 4.1.10</a>:</p>
* <blockquote><pre>
* To keep messages compact in common situations, it is RECOMMENDED that
* producers omit an "application/" prefix of a media type value in a
* "cty" Header Parameter when no other '/' appears in the media type
* 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.
*/
T contentType(String cty);
Expand Down
62 changes: 36 additions & 26 deletions api/src/main/java/io/jsonwebtoken/JwtBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ public interface JwtBuilder extends ClaimsMutator<JwtBuilder> {
* {@link #content(byte[], String)} instead.
*
* <p>This is a wrapper method for:</p>
* <blockquote><pre>
* <blockquote><pre>s
* {@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>
Expand All @@ -178,54 +178,64 @@ public interface JwtBuilder extends ClaimsMutator<JwtBuilder> {
/**
* 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>Unless you are confident that the JWT recipient will <em>always</em> know how to use
* the given byte array without additional metadata, it is strongly recommended to use the
* {@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>
*
* <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
* @return the builder for method chaining.
* @see #content(byte[], String)
* @since JJWT_RELEASE_VERSION
*/
JwtBuilder content(byte[] content);

/**
* Convenience method that 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
* 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} IANA Media Type
* 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.
*
* <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.</p>
* <p>This is a convenience method semantically equivalent to:</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>As a convenience, this method will automatically trim any <code><b>application/</b></code> prefix from the
* {@code cty} string if possible according to the
* <a href="https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.10">JWT specification recommendations</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>
*
* <p>This method will automatically remove any <code><b>application/</b></code> prefix from 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">RFC 7517, Section 4.1.10</a>:</p>
* <blockquote><pre>
* Jwts.builder()
* .header().contentType("application/whatever").and()
* .content(byteArray)
* ...
* .build();</pre></blockquote>
*
* <p>If you want the JWT payload to be JSON claims, use the {@link #claim(String, Object)} or
* {@link #claims()} methods instead.</p>
* To keep messages compact in common situations, it is RECOMMENDED that
* producers omit an "application/" prefix of a media type value in a
* "cty" Header Parameter when no other '/' appears in the media type
* 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>
*
* <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>
*
* <p><b>Mutually Exclusive Claims and Content</b></p>
*
* <p>Note that the content and claims properties are mutually exclusive - only one of the two may be used.</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 cty the content type (media type) identifier attributed to the byte array. Cannot be null or empty.
Expand Down
5 changes: 4 additions & 1 deletion impl/src/main/java/io/jsonwebtoken/impl/DefaultHeader.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package io.jsonwebtoken.impl;

import io.jsonwebtoken.Header;
import io.jsonwebtoken.impl.lang.CompactMediaTypeIdConverter;
import io.jsonwebtoken.impl.lang.Field;
import io.jsonwebtoken.impl.lang.Fields;
import io.jsonwebtoken.lang.Registry;
Expand All @@ -26,7 +27,9 @@
public class DefaultHeader extends FieldMap implements Header {

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> COMPRESSION_ALGORITHM = Fields.string(Header.COMPRESSION_ALGORITHM, "Compression Algorithm");
@SuppressWarnings("DeprecatedIsStillUsed")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.impl.lang.Bytes;
import io.jsonwebtoken.impl.lang.CompactMediaTypeIdConverter;
import io.jsonwebtoken.impl.lang.Function;
import io.jsonwebtoken.impl.lang.Functions;
import io.jsonwebtoken.impl.lang.Services;
Expand Down Expand Up @@ -325,9 +324,7 @@ public JwtBuilder content(byte[] content) {
public JwtBuilder content(byte[] content, String cty) {
Assert.notEmpty(content, "content byte array cannot be null or empty.");
Assert.hasText(cty, "Content Type String cannot be null or empty.");
cty = CompactMediaTypeIdConverter.INSTANCE.applyFrom(cty);
this.headerBuilder.contentType(cty);
return content(content);
return header().contentType(cty).and().content(content);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ public final class CompactMediaTypeIdConverter implements Converter<String, Obje

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) {
Assert.hasText(cty, "Value cannot be null or empty.");
Expand All @@ -31,7 +33,7 @@ static String compactIfPossible(String cty) {
// 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--) {
char c = cty.charAt(i);
if (c == '/') {
if (c == FORWARD_SLASH) {
return cty; // found another '/', can't compact, so just return unmodified
}
}
Expand All @@ -49,8 +51,18 @@ public Object applyTo(String s) {
@Override
public String applyFrom(Object o) {
Assert.notNull(o, "Value cannot be null.");
Assert.isInstanceOf(String.class, o, "Value must be a string.");
String s = (String) o;
return compactIfPossible(s);
String s = Assert.isInstanceOf(String.class, o, "Value must be a string.");

// 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;
}
}
5 changes: 4 additions & 1 deletion impl/src/test/groovy/io/jsonwebtoken/JwtsTest.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,10 @@ class JwtsTest {
String cty = "application/$subtype"
String compact = Jwts.builder().content(s.getBytes(StandardCharsets.UTF_8), cty).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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ class DefaultHeaderTest {
@Test
void testContentType() {
header = h([cty: 'bar'])
assertEquals 'bar', header.getContentType()
assertEquals 'bar', header.get('cty')
// Per per https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.10, the raw header should have a
// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,10 @@ class DefaultJwtHeaderBuilderTest {
@Test
void testDeprecatedSetters() { // TODO: remove before 1.0
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.setKeyId('foo').build().getKeyId()
assertEquals 'foo', builder.setAlgorithm('foo').build().getAlgorithm()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ class CompactMediaTypeIdConverterTest {
void testNonApplicationMediaType() {
String cty = 'foo'
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,10 @@ class RFC7520Section5Test {
assertEquals alg.getId(), parsed.header.getAlgorithm()
assertEquals FIGURE_99, b64Url(parsed.header.getPbes2Salt())
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 FIGURE_95, utf8(parsed.payload)
}
Expand Down