diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jose/jws/EncryptionAlgorithm.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jose/jws/EncryptionAlgorithm.java new file mode 100644 index 00000000000..58056f809bf --- /dev/null +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jose/jws/EncryptionAlgorithm.java @@ -0,0 +1,51 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.jose.jws; + +public enum EncryptionAlgorithm implements JweAlgorithm { + + RSA_OAEP_256("RSA-OAEP-256"); + + private final String name; + + EncryptionAlgorithm(String name) { + this.name = name; + } + + /** + * Returns the algorithm name. + * @return the algorithm name + */ + @Override + public String getName() { + return this.name; + } + + /** + * Attempt to resolve the provided algorithm name to a {@code EncryptionAlgorithm}. + * @param name the algorithm name + * @return the resolved {@code EncryptionAlgorithm}, or {@code null} if not found + */ + public static EncryptionAlgorithm from(String name) { + for (EncryptionAlgorithm value : values()) { + if (value.getName().equals(name)) { + return value; + } + } + return null; + } +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jose/jws/EncryptionMethod.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jose/jws/EncryptionMethod.java new file mode 100644 index 00000000000..e6ac1fdfa27 --- /dev/null +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jose/jws/EncryptionMethod.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.jose.jws; + +public enum EncryptionMethod { + + A256GCM("A256GCM"); + + private final String name; + + EncryptionMethod(String name) { + this.name = name; + } + + /** + * Returns the method name. + * @return the method name + */ + public String getName() { + return this.name; + } + + /** + * Attempt to resolve the provided algorithm name to a {@code EncryptionMethod}. + * @param name the algorithm name + * @return the resolved {@code EncryptionMethod}, or {@code null} if not found + */ + public static EncryptionMethod from(String name) { + for (EncryptionMethod value : values()) { + if (value.getName().equals(name)) { + return value; + } + } + return null; + } +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jose/jws/JweAlgorithm.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jose/jws/JweAlgorithm.java new file mode 100644 index 00000000000..b4f6666b8e6 --- /dev/null +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jose/jws/JweAlgorithm.java @@ -0,0 +1,23 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.jose.jws; + +import org.springframework.security.oauth2.jose.JwaAlgorithm; + +public interface JweAlgorithm extends JwaAlgorithm { + +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JweHeaderMutator.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JweHeaderMutator.java new file mode 100644 index 00000000000..a72a9c4fb73 --- /dev/null +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JweHeaderMutator.java @@ -0,0 +1,84 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.jwt; + +import java.util.Map; +import java.util.function.Consumer; + +import org.springframework.security.oauth2.jose.jws.EncryptionMethod; +import org.springframework.security.oauth2.jose.jws.JweAlgorithm; + +public interface JweHeaderMutator> { + /** + * Set the algorithm {@code (alg)} header which identifies the algorithm + * used when encrypting the JWE + * + * @return the {@link JweHeaderMutator} for more customizations + */ + default M algorithm(JweAlgorithm jws) { + return header(JoseHeaderNames.ALG, jws); + } + + /** + * Set the encryption method {@code (enc)} header which identifies the + * method to use when encrypting the JWE + * + * @return the {@link JweHeaderMutator} for more customizations + */ + default M encryptionMethod(EncryptionMethod method) { + return header("enc", method); + } + + /** + * Set a header that is critical for decoders to understand + * + * @param name the header name + * @param value the header value + * @return the {@link JweHeaderMutator} for more customizations + */ + default M criticalHeader(String name, Object value) { + return criticalHeaders((crit) -> crit.put(name, value)); + } + + /** + * Mutate the set of critical headers + * + * @param criticalHeadersConsumer a {@link Consumer} of the critical headers {@link Map} + * @return the {@link JweHeaderMutator} for more customizations + */ + M criticalHeaders(Consumer> criticalHeadersConsumer); + + /** + * Set a header + * + * Note that key-specific headers are typically best specified by the encoder + * itself. + * + * See {@link JwtEncoderAlternative} + */ + default M header(String name, Object value) { + return headers((headers) -> headers.put(name, value)); + } + + /** + * Mutate the set of headers + * + * @param headersConsumer a {@link Consumer} of the headers {@link Map} + * @return the {@link JweHeaderMutator} for more customizations + */ + M headers(Consumer> headersConsumer); +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwsHeaderMutator.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwsHeaderMutator.java new file mode 100644 index 00000000000..19e79773263 --- /dev/null +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwsHeaderMutator.java @@ -0,0 +1,61 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.jwt; + +import java.util.Map; +import java.util.function.Consumer; + +import org.springframework.security.oauth2.jose.jws.JwsAlgorithm; + +public interface JwsHeaderMutator> { + /** + * Set the algorithm {@code (alg)} header which identifies the algorithm + * used when signing the JWS + * + * @return the {@link JwsHeaderMutator} for more customizations + */ + default M algorithm(JwsAlgorithm jws) { + return header(JoseHeaderNames.ALG, jws); + } + + /** + * Set a header that is critical for decoders to understand + * + * @param name the header name + * @param value the header value + * @return the {@link JwsHeaderMutator} for more customizations + */ + default M criticalHeader(String name, Object value) { + return criticalHeaders((crit) -> crit.put(name, value)); + } + + M criticalHeaders(Consumer> criticalHeadersConsumer); + + /** + * Set a header + * + * Note that key-specific headers are typically best specified by the encoder + * itself. + * + * See {@link JwtEncoderAlternative} + */ + default M header(String name, Object value) { + return headers((headers) -> headers.put(name, value)); + } + + M headers(Consumer> headersConsumer); +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/Jwt.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/Jwt.java index 829f7312491..30b3859d0b3 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/Jwt.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/Jwt.java @@ -17,7 +17,6 @@ package org.springframework.security.oauth2.jwt; import java.time.Instant; -import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; @@ -103,7 +102,7 @@ public static Builder withTokenValue(String tokenValue) { * @author Josh Cummings * @since 5.2 */ - public static final class Builder { + public static final class Builder implements JwtClaimMutator { private String tokenValue; @@ -116,24 +115,13 @@ private Builder(String tokenValue) { } /** - * Use this token value in the resulting {@link Jwt} - * @param tokenValue The token value to use - * @return the {@link Builder} for further configurations - */ - public Builder tokenValue(String tokenValue) { - this.tokenValue = tokenValue; - return this; - } - - /** - * Use this claim in the resulting {@link Jwt} - * @param name The claim name - * @param value The claim value + * Use this header in the resulting {@link Jwt} + * @param name The header name + * @param value The header value * @return the {@link Builder} for further configurations */ - public Builder claim(String name, Object value) { - this.claims.put(name, value); - return this; + public Builder header(String name, Object value) { + return headers((headers) -> headers.put(name, value)); } /** @@ -142,22 +130,12 @@ public Builder claim(String name, Object value) { * @param claimsConsumer the consumer * @return the {@link Builder} for further configurations */ + @Override public Builder claims(Consumer> claimsConsumer) { claimsConsumer.accept(this.claims); return this; } - /** - * Use this header in the resulting {@link Jwt} - * @param name The header name - * @param value The header value - * @return the {@link Builder} for further configurations - */ - public Builder header(String name, Object value) { - this.headers.put(name, value); - return this; - } - /** * Provides access to every {@link #header(String, Object)} declared so far with * the possibility to add, replace, or remove. @@ -169,72 +147,17 @@ public Builder headers(Consumer> headersConsumer) { return this; } - /** - * Use this audience in the resulting {@link Jwt} - * @param audience The audience(s) to use - * @return the {@link Builder} for further configurations - */ - public Builder audience(Collection audience) { - return claim(JwtClaimNames.AUD, audience); - } - - /** - * Use this expiration in the resulting {@link Jwt} - * @param expiresAt The expiration to use - * @return the {@link Builder} for further configurations - */ - public Builder expiresAt(Instant expiresAt) { - this.claim(JwtClaimNames.EXP, expiresAt); - return this; - } - - /** - * Use this identifier in the resulting {@link Jwt} - * @param jti The identifier to use - * @return the {@link Builder} for further configurations - */ public Builder jti(String jti) { - this.claim(JwtClaimNames.JTI, jti); - return this; - } - - /** - * Use this issued-at timestamp in the resulting {@link Jwt} - * @param issuedAt The issued-at timestamp to use - * @return the {@link Builder} for further configurations - */ - public Builder issuedAt(Instant issuedAt) { - this.claim(JwtClaimNames.IAT, issuedAt); - return this; + return id(jti); } /** - * Use this issuer in the resulting {@link Jwt} - * @param issuer The issuer to use - * @return the {@link Builder} for further configurations - */ - public Builder issuer(String issuer) { - this.claim(JwtClaimNames.ISS, issuer); - return this; - } - - /** - * Use this not-before timestamp in the resulting {@link Jwt} - * @param notBefore The not-before timestamp to use - * @return the {@link Builder} for further configurations - */ - public Builder notBefore(Instant notBefore) { - this.claim(JwtClaimNames.NBF, notBefore); - return this; - } - - /** - * Use this subject in the resulting {@link Jwt} - * @param subject The subject to use + * Use this token value in the resulting {@link Jwt} + * @param tokenValue The token value to use * @return the {@link Builder} for further configurations */ - public Builder subject(String subject) { - this.claim(JwtClaimNames.SUB, subject); + public Builder tokenValue(String tokenValue) { + this.tokenValue = tokenValue; return this; } diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtClaimMutator.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtClaimMutator.java new file mode 100644 index 00000000000..da3491b09ee --- /dev/null +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtClaimMutator.java @@ -0,0 +1,108 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.jwt; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * A claims mutator for the "claims" that may be contained in the JSON + * object JWT Claims Set of a JSON Web Token (JWT). + * + * @author Josh Cummings + * @since 5.5 + * @see JwtClaimNames + * @see Jwt + * @see Registered Claim Names + */ +public interface JwtClaimMutator> { + + /** + * Returns the Issuer {@code (iss)} claim which identifies the principal that issued + * the JWT. + * @return the Issuer identifier + */ + default M issuer(String uri) { + return claim(JwtClaimNames.ISS, uri); + } + + /** + * Returns the Subject {@code (sub)} claim which identifies the principal that is the + * subject of the JWT. + * @return the Subject identifier + */ + default M subject(String sub) { + return claim(JwtClaimNames.SUB, sub); + } + + /** + * Returns the Audience {@code (aud)} claim which identifies the recipient(s) that the + * JWT is intended for. + * @return the Audience(s) that this JWT intended for + */ + default M audience(List audience) { + return claim(JwtClaimNames.AUD, audience); + } + + /** + * Returns the Expiration time {@code (exp)} claim which identifies the expiration + * time on or after which the JWT MUST NOT be accepted for processing. + * @return the Expiration time on or after which the JWT MUST NOT be accepted for + * processing + */ + default M expiresAt(Instant expiresAt) { + return claim(JwtClaimNames.EXP, expiresAt); + } + + /** + * Returns the Not Before {@code (nbf)} claim which identifies the time before which + * the JWT MUST NOT be accepted for processing. + * @return the Not Before time before which the JWT MUST NOT be accepted for + * processing + */ + default M notBefore(Instant notBefore) { + return claim(JwtClaimNames.NBF, notBefore); + } + + /** + * Returns the Issued at {@code (iat)} claim which identifies the time at which the + * JWT was issued. + * @return the Issued at claim which identifies the time at which the JWT was issued + */ + default M issuedAt(Instant issuedAt) { + return claim(JwtClaimNames.IAT, issuedAt); + } + + /** + * Sets the JWT ID {@code (jti)} claim which provides a unique identifier for the + * JWT. + * @return the {@link JwtClaimMutator} for more customizations + */ + default M id(String id) { + return claim(JwtClaimNames.JTI, id); + } + + default M claim(String name, Object value) { + claims((claims) -> claims.put(name, value)); + return (M) this; + } + + M claims(Consumer> claimsConsumer); +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncoderAlternative.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncoderAlternative.java new file mode 100644 index 00000000000..95a522ae0b9 --- /dev/null +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncoderAlternative.java @@ -0,0 +1,77 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.jwt; + +import java.util.function.Consumer; + +/** + * Encodes and signs JWTs, implementations may also support encryption. + */ +public interface JwtEncoderAlternative { + + enum EncodingMode { SIGN, ENCRYPT, SIGN_THEN_ENCRYPT } + + /** + * Return a {@link JwtMutator} for specifying any claims or headers needed in the JWT + * + * @return a parameter mutator + */ + JwtMutator encoder(); + + /** + * A parameter mutator for specifying headers and claims to encode + */ + interface JwtMutator> { + /** + * Mutate the JWS headers + * + * @param headersConsumer the {@link Consumer} that mutates the JWS headers + * @return the {@link JwtMutator} for further customizations + */ + B jwsHeaders(Consumer> headersConsumer); + + /** + * Mutate the JWE headers + * + * @param headersConsumer the {@link Consumer} that mutates the JWS headers + * @return the {@link JwtMutator} for further customizations + */ + B jweHeaders(Consumer> headersConsumer); + + /** + * Mutate the JWT Claims Set + * + * @param claimsConsumer the {@link Consumer} that mutates the JWT Claims Set + * @return the {@link JwtMutator} for further customizations + */ + B claims(Consumer> claimsConsumer); + + /** + * Sign and serialize the JWT + * + * @return the signed and serialized JWT + */ + String encode(); + + /** + * Encode the JWT according to the specified {@link EncodingMode} + * + * @return the signed and serialized JWT + */ + String encode(EncodingMode mode); + } +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoder.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoder.java new file mode 100644 index 00000000000..e8959c5d1f0 --- /dev/null +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoder.java @@ -0,0 +1,382 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.jwt; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import com.nimbusds.jose.JWEHeader; +import com.nimbusds.jose.JWEObject; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.Payload; +import com.nimbusds.jose.crypto.RSAEncrypter; +import com.nimbusds.jose.crypto.factories.DefaultJWSSignerFactory; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKMatcher; +import com.nimbusds.jose.jwk.JWKSelector; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.JWKSecurityContext; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jose.produce.JWSSignerFactory; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +import org.springframework.security.oauth2.jose.jws.EncryptionAlgorithm; +import org.springframework.security.oauth2.jose.jws.EncryptionMethod; +import org.springframework.security.oauth2.jose.jws.JweAlgorithm; +import org.springframework.security.oauth2.jose.jws.JwsAlgorithm; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.util.Assert; + +/** + * Signs and serialized a JWT using the Nimbus library + */ +public class NimbusJwtEncoder implements JwtEncoderAlternative { + private static final String ENCODING_ERROR_MESSAGE_TEMPLATE = "An error occurred while attempting to encode the Jwt: %s"; + + private JwsAlgorithm defaultAlgorithm = SignatureAlgorithm.RS256; + private JweAlgorithm defaultJweAlgorithm = EncryptionAlgorithm.RSA_OAEP_256; + private EncryptionMethod defaultEncryptionMethod = EncryptionMethod.A256GCM; + + private JWKSource jwksSource; + + public void setJwkSource(JWKSource jwksSource) { + Assert.notNull(jwksSource, "jwkSelector cannot be null"); + this.jwksSource = jwksSource; + } + + @Override + public NimbusJwtMutator encoder() { + Instant now = Instant.now(); + return new NimbusJwtMutator(this.jwksSource) + .jwsHeaders((jws) -> jws + .algorithm(this.defaultAlgorithm) + .header(JoseHeaderNames.TYP, "JWT") + ) + .jweHeaders((jwe) -> jwe + .algorithm(this.defaultJweAlgorithm) + .encryptionMethod(this.defaultEncryptionMethod) + ) + .claims((claims) -> claims + .issuedAt(now) + .expiresAt(now.plusSeconds(3600)) + .notBefore(now) + ); + } + + public static final class NimbusJwtMutator implements JwtMutator { + private final JWSSignerFactory jwsSignerFactory = new DefaultJWSSignerFactory(); + private final JWKSource jwkSource; + private final NimbusJwtClaimMutator claims = new NimbusJwtClaimMutator(); + private final NimbusJwsHeaderMutator jwsHeaders = new NimbusJwsHeaderMutator(); + private final NimbusJweHeaderMutator jweHeaders = new NimbusJweHeaderMutator(); + + private JWK jwsKey; + private JWK jweKey; + private SecurityContext jwsContext; + private SecurityContext jweContext; + + private NimbusJwtMutator(JWKSource jwkSource) { + this.jwkSource = jwkSource; + } + + @Override + public NimbusJwtMutator jwsHeaders(Consumer> headersConsumer) { + headersConsumer.accept(this.jwsHeaders); + return this; + } + + @Override + public NimbusJwtMutator jweHeaders(Consumer> headersConsumer) { + headersConsumer.accept(this.jweHeaders); + return this; + } + + /** + * Use this {@link JWK} to sign the JWT + */ + public NimbusJwtMutator jwsKey(JWK jwk) { + this.jwsKey = jwk; + if (this.jwsKey.getKeyID() != null) { + this.jwsHeaders.headers.put(JoseHeaderNames.KID, this.jwsKey.getKeyID()); + } + if (this.jwsKey.getX509CertSHA256Thumbprint() != null) { + this.jwsHeaders.headers.put(JoseHeaderNames.X5T, this.jwsKey.getX509CertSHA256Thumbprint().toString()); + } + return this; + } + + /** + * Use this {@link JWK} to encrypt the JWT + */ + public NimbusJwtMutator jweKey(RSAKey jwk) { + this.jweKey = jwk; + if (this.jweKey.getKeyID() != null) { + this.jweHeaders.headers.put(JoseHeaderNames.KID, this.jweKey.getKeyID()); + } + if (this.jweKey.getX509CertSHA256Thumbprint() != null) { + this.jweHeaders.headers.put(JoseHeaderNames.X5T, this.jweKey.getX509CertSHA256Thumbprint().toString()); + } + return this; + } + + /** + * Send this {@link SecurityContext} to Nimbus's signing infrastructure + */ + public NimbusJwtMutator jwsSecurityContext(SecurityContext context) { + this.jwsContext = context; + return this; + } + + /** + * Send this {@link SecurityContext} to Nimbus's encryption infrastructure + */ + public NimbusJwtMutator jweSecurityContext(SecurityContext context) { + this.jweContext = context; + return this; + } + + @Override + public NimbusJwtMutator claims(Consumer> claimsConsumer) { + claimsConsumer.accept(this.claims); + return this; + } + + @Override + public String encode() { + return encode(EncodingMode.SIGN); + } + + @Override + public String encode(EncodingMode mode) { + switch (mode) { + case SIGN: return sign(); + case ENCRYPT: return encrypt(false); + default: return encrypt(true); + } + } + + private String sign() { + if (this.jwsKey == null) { + if (this.jwsContext instanceof JWKSecurityContext) { + this.jwsKey = ((JWKSecurityContext) this.jwsContext).getKeys().iterator().next(); + } else if (this.jwkSource != null) { + jwsKey(selectJwsJwk()); + } else { + throw new IllegalStateException("Could not derive a signing key"); + } + } + + JWSHeader jwsHeader = this.jwsHeaders.jwsHeader(); + JWTClaimsSet jwtClaimsSet = this.claims.jwtClaimsSet(); + + SignedJWT signedJwt = new SignedJWT(jwsHeader, jwtClaimsSet); + try { + JWSSigner signer = this.jwsSignerFactory.createJWSSigner(this.jwsKey); + signedJwt.sign(signer); + return signedJwt.serialize(); + } + catch (Exception ex) { + throw new JwtEncodingException( + String.format(ENCODING_ERROR_MESSAGE_TEMPLATE, "Failed to sign the JWT"), ex); + } + } + + private String encrypt(boolean sign) { + if (sign) { + this.jweHeaders.header(JoseHeaderNames.CTY, "JWT"); // required parameter + return encrypt(new Payload(sign())); + } else { + return encrypt(new Payload(this.claims.jwtClaimsSet().toJSONObject())); + } + } + + private JWK selectJwsJwk() { + List jwks; + try { + JWSHeader jwsHeader = this.jwsHeaders.jwsHeader(); + JWKSelector jwkSelector = new JWKSelector(JWKMatcher.forJWSHeader(jwsHeader)); + jwks = this.jwkSource.get(jwkSelector, this.jwsContext); + } + catch (Exception ex) { + throw new JwtEncodingException(String.format(ENCODING_ERROR_MESSAGE_TEMPLATE, + "Failed to select a JWK signing key -> " + ex.getMessage()), ex); + } + + if (jwks.isEmpty()) { + throw new JwtEncodingException( + String.format(ENCODING_ERROR_MESSAGE_TEMPLATE, "Failed to select a JWK signing key")); + } + + return jwks.get(0); + } + + private JWK selectJweJwk() { + List jwks; + try { + JWEHeader jweHeader = this.jweHeaders.jweHeader(); + JWKSelector jwkSelector = new JWKSelector(JWKMatcher.forJWEHeader(jweHeader)); + jwks = this.jwkSource.get(jwkSelector, this.jweContext); + } + catch (Exception ex) { + throw new JwtEncodingException(String.format(ENCODING_ERROR_MESSAGE_TEMPLATE, + "Failed to select a JWK encryption key -> " + ex.getMessage()), ex); + } + + if (jwks.isEmpty()) { + throw new JwtEncodingException( + String.format(ENCODING_ERROR_MESSAGE_TEMPLATE, "Failed to select a JWK encryption key")); + } + + return jwks.get(0); + } + + private String encrypt(Payload payload) { + if (this.jweKey == null) { + if (this.jweContext instanceof JWKSecurityContext) { + this.jweKey = ((JWKSecurityContext) this.jweContext).getKeys().iterator().next(); + } else if (this.jwkSource != null) { + jweKey((RSAKey) selectJweJwk()); + } else { + throw new IllegalStateException("Could not derive a encryption key"); + } + } + + JWEHeader jweHeader = this.jweHeaders.jweHeader(); + JWEObject object = new JWEObject(jweHeader, payload); + try { + object.encrypt(new RSAEncrypter((RSAKey) this.jweKey)); + return object.serialize(); + } + catch (Exception ex) { + throw new JwtEncodingException( + String.format(ENCODING_ERROR_MESSAGE_TEMPLATE, "Failed to sign the JWT"), ex); + } + } + } + + static final class NimbusJwsHeaderMutator implements JwsHeaderMutator { + private final Map headers = new LinkedHashMap<>(); + private final Map criticalHeaders = new LinkedHashMap<>(); + + @Override + public NimbusJwsHeaderMutator algorithm(JwsAlgorithm jws) { + return header(JoseHeaderNames.ALG, jws.getName()); + } + + @Override + public NimbusJwsHeaderMutator criticalHeaders(Consumer> criticalHeadersConsumer) { + criticalHeadersConsumer.accept(this.criticalHeaders); + return this; + } + + @Override + public NimbusJwsHeaderMutator headers(Consumer> headersConsumer) { + headersConsumer.accept(this.headers); + return this; + } + + JWSHeader jwsHeader() { + Map allHeaders = new LinkedHashMap<>(this.headers); + if (!this.criticalHeaders.isEmpty()) { + allHeaders.put(JoseHeaderNames.CRIT, this.criticalHeaders.keySet()); + allHeaders.putAll(this.criticalHeaders); + } + + try { + return JWSHeader.parse(allHeaders); + } catch (Exception ex) { + throw new JwtEncodingException(String.format(ENCODING_ERROR_MESSAGE_TEMPLATE, + "Failed to convert header to Nimbus JWSHeader"), ex); + } + } + } + + static final class NimbusJweHeaderMutator implements JweHeaderMutator { + private final Map headers = new LinkedHashMap<>(); + private final Map criticalHeaders = new LinkedHashMap<>(); + + @Override + public NimbusJweHeaderMutator algorithm(JweAlgorithm jwe) { + return header(JoseHeaderNames.ALG, jwe.getName()); + } + + @Override + public NimbusJweHeaderMutator encryptionMethod(EncryptionMethod method) { + return header("enc", method.getName()); + } + + @Override + public NimbusJweHeaderMutator criticalHeaders(Consumer> criticalHeadersConsumer) { + criticalHeadersConsumer.accept(this.criticalHeaders); + return this; + } + + @Override + public NimbusJweHeaderMutator headers(Consumer> headersConsumer) { + headersConsumer.accept(this.headers); + return this; + } + + JWEHeader jweHeader() { + Map allHeaders = new LinkedHashMap<>(this.headers); + if (!this.criticalHeaders.isEmpty()) { + allHeaders.put(JoseHeaderNames.CRIT, this.criticalHeaders.keySet()); + allHeaders.putAll(this.criticalHeaders); + } + + try { + return JWEHeader.parse(allHeaders); + } catch (Exception ex) { + throw new JwtEncodingException(String.format(ENCODING_ERROR_MESSAGE_TEMPLATE, + "Failed to convert header to Nimbus JWSHeader"), ex); + } + } + } + + static final class NimbusJwtClaimMutator implements JwtClaimMutator { + private final Map claims = new LinkedHashMap<>(); + + @Override + public NimbusJwtClaimMutator claim(String name, Object value) { + if (value instanceof Instant) { + return claim(name, ((Instant) value).getEpochSecond()); + } + return claims((headers) -> headers.put(name, value)); + } + + @Override + public NimbusJwtClaimMutator claims(Consumer> claimsConsumer) { + claimsConsumer.accept(this.claims); + return this; + } + + JWTClaimsSet jwtClaimsSet() { + try { + return JWTClaimsSet.parse(this.claims); + } catch (Exception ex) { + throw new JwtEncodingException(String.format(ENCODING_ERROR_MESSAGE_TEMPLATE, + "Failed to convert claims to Nimbus JWTClaimsSet"), ex); + } + } + } +} diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoderTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoderTests.java new file mode 100644 index 00000000000..c3e66404baa --- /dev/null +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoderTests.java @@ -0,0 +1,318 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.jwt; + +import java.util.Arrays; +import java.util.function.Consumer; + +import com.nimbusds.jose.EncryptionMethod; +import com.nimbusds.jose.JWEAlgorithm; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.JWEDecryptionKeySelector; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import org.junit.Before; +import org.junit.Test; + +import org.springframework.security.oauth2.jose.TestKeys; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwtEncoderAlternative.EncodingMode; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link NimbusJwsEncoder}. + * + * @author Joe Grandja + */ +public class NimbusJwtEncoderTests { + + private JWKSource jwkSelector; + + private NimbusJwtEncoder jwsEncoder; + + @Before + public void setUp() { + this.jwkSelector = mock(JWKSource.class); + this.jwsEncoder = new NimbusJwtEncoder(); + this.jwsEncoder.setJwkSource(this.jwkSelector); + } + + @Test + public void constructorWhenJwkSelectorNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.jwsEncoder.setJwkSource(null)) + .withMessage("jwkSelector cannot be null"); + } + + @Test + public void encodeWhenJwkNotSelectedThenThrowJwtEncodingException() { + assertThatExceptionOfType(JwtEncodingException.class).isThrownBy(() -> this.jwsEncoder.encoder().encode()) + .withMessageContaining("Failed to select a JWK signing key"); + } + + @Test + public void encodeWhenJwkUseEncryptionThenThrowJwtEncodingException() throws Exception { + // @formatter:off + RSAKey rsaJwk = new RSAKey.Builder(TestKeys.DEFAULT_PUBLIC_KEY) + .privateKey(TestKeys.DEFAULT_PRIVATE_KEY) + .keyID("keyId") + .keyUse(KeyUse.ENCRYPTION) + .build(); + // @formatter:on + + given(this.jwkSelector.get(any(), any())).willReturn(Arrays.asList(rsaJwk)); + + assertThatExceptionOfType(JwtEncodingException.class).isThrownBy(() -> this.jwsEncoder.encoder().encode()) + .withMessageContaining( + "Failed to sign the JWT"); + } + + @Test + public void encodeWhenSuccessThenDecodes() throws Exception { + // @formatter:off + RSAKey rsaJwk = new RSAKey.Builder(TestKeys.DEFAULT_PUBLIC_KEY) + .privateKey(TestKeys.DEFAULT_PRIVATE_KEY) + .keyID("keyId") + .build(); + // @formatter:on + + given(this.jwkSelector.get(any(), any())).willReturn(Arrays.asList(rsaJwk)); + + String token = this.jwsEncoder.encoder().claims((claims) -> claims.id("id")).encode(); + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(rsaJwk.toRSAPublicKey()).build(); + Jwt jwt = jwtDecoder.decode(token); + + // Assert headers/claims were added + assertThat(jwt.getHeaders().get(JoseHeaderNames.TYP)).isEqualTo("JWT"); + assertThat(jwt.getHeaders().get(JoseHeaderNames.KID)).isEqualTo(rsaJwk.getKeyID()); + assertThat(jwt.getId()).isNotNull(); + } + + @Test + public void encodeWhenCustomizerSetThenCalled() throws Exception { + // @formatter:off + RSAKey rsaJwk = new RSAKey.Builder(TestKeys.DEFAULT_PUBLIC_KEY) + .privateKey(TestKeys.DEFAULT_PRIVATE_KEY) + .keyID("keyId") + .build(); + // @formatter:on + + given(this.jwkSelector.get(any(), any())).willReturn(Arrays.asList(rsaJwk)); + + Consumer> jwtCustomizer = mock(Consumer.class); + JwtEncoderAlternative encoder = () -> this.jwsEncoder.encoder().claims(jwtCustomizer); + encoder.encoder().encode(); + + verify(jwtCustomizer).accept(any(JwtClaimMutator.class)); + } + + @Test + public void defaultJwkSelectorApplyWhenMultipleSelectedThenThrowJwtEncodingException() throws Exception { + // @formatter:off + RSAKey rsaJwk = new RSAKey.Builder(TestKeys.DEFAULT_PUBLIC_KEY) + .privateKey(TestKeys.DEFAULT_PRIVATE_KEY) + .keyID("keyId") + .build(); + // @formatter:on + + given(this.jwkSelector.get(any(), any())).willReturn(Arrays.asList(rsaJwk, rsaJwk)); + + assertThatExceptionOfType(JwtEncodingException.class).isThrownBy(() -> this.jwsEncoder.encoder().encode()) + .withMessageContaining("Found multiple JWK signing keys for algorithm 'RS256'"); + } + + @Test + public void encodeWhenKeysRotatedThenNewKeyUsed() throws Exception { + // @formatter:off + RSAKey first = new RSAKey.Builder(TestKeys.DEFAULT_PUBLIC_KEY) + .privateKey(TestKeys.DEFAULT_PRIVATE_KEY) + .keyID("first") + .build(); + RSAKey second = new RSAKey.Builder(TestKeys.DEFAULT_PUBLIC_KEY) + .privateKey(TestKeys.DEFAULT_PRIVATE_KEY) + .keyID("second") + .build(); + // @formatter:on + + given(this.jwkSelector.get(any(), any())).willReturn(Arrays.asList(first)); + + String firstToken = this.jwsEncoder.encoder().encode(); + + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey((first).toRSAPublicKey()).build(); + Jwt firstDecoded = jwtDecoder.decode(firstToken); + + reset(this.jwkSelector); + given(this.jwkSelector.get(any(), any())).willReturn(Arrays.asList(second)); + + String secondToken = this.jwsEncoder.encoder().encode(); + + jwtDecoder = NimbusJwtDecoder.withPublicKey((second).toRSAPublicKey()).build(); + Jwt secondDecoded = jwtDecoder.decode(secondToken); + + assertThat(firstDecoded.getHeaders().get(JoseHeaderNames.KID)).isEqualTo(first.getKeyID()); + assertThat(secondDecoded.getHeaders().get(JoseHeaderNames.KID)).isEqualTo(second.getKeyID()); + } + + @Test + public void encodeWhenClaimsThenContains() throws Exception { + // @formatter:off + RSAKey rsaJwk = new RSAKey.Builder(TestKeys.DEFAULT_PUBLIC_KEY) + .privateKey(TestKeys.DEFAULT_PRIVATE_KEY) + .keyID("keyId") + .build(); + // @formatter:on + + given(this.jwkSelector.get(any(), any())).willReturn(Arrays.asList(rsaJwk)); + + String token = this.jwsEncoder.encoder().claims((claims) -> claims.subject("subject")).encode(); + + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(rsaJwk.toRSAPublicKey()).build(); + Jwt decoded = jwtDecoder.decode(token); + + assertThat(decoded.getSubject()).isEqualTo("subject"); + } + + @Test + public void encodeWhenDefaultClaimRemovedThenRemoved() throws Exception { + // @formatter:off + RSAKey rsaJwk = new RSAKey.Builder(TestKeys.DEFAULT_PUBLIC_KEY) + .privateKey(TestKeys.DEFAULT_PRIVATE_KEY) + .keyID("keyId") + .build(); + // @formatter:on + + given(this.jwkSelector.get(any(), any())).willReturn(Arrays.asList(rsaJwk)); + + String token = this.jwsEncoder.encoder() + .claims((claims) -> claims + .subject("subject") + .claims((map) -> map.remove("exp"))) + .encode(); + + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(rsaJwk.toRSAPublicKey()).build(); + Jwt decoded = jwtDecoder.decode(token); + + assertThat(decoded.getExpiresAt()).isNull(); + } + + @Test + public void encryptWithDefaultsThenWorks() throws Exception { + RSAKey rsaJwk = new RSAKey.Builder(TestKeys.DEFAULT_PUBLIC_KEY) + .privateKey(TestKeys.DEFAULT_PRIVATE_KEY) + .keyID("keyId") + .keyUse(KeyUse.ENCRYPTION) + .build(); + + given(this.jwkSelector.get(any(), any())).willReturn(Arrays.asList(rsaJwk)); + + String token = this.jwsEncoder.encoder().encode(EncodingMode.ENCRYPT); + + DefaultJWTProcessor processor = new DefaultJWTProcessor<>(); + processor.setJWEKeySelector(new JWEDecryptionKeySelector<>(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM, new ImmutableJWKSet<>(new JWKSet(rsaJwk)))); + JWTClaimsSet claims = processor.process(token, null); + assertThat(claims.getExpirationTime()).isNotNull(); + } + + @Test + public void signThenEncryptWithDefaultsThenWorks() throws Exception { + RSAKey rsaJwk = new RSAKey.Builder(TestKeys.DEFAULT_PUBLIC_KEY) + .privateKey(TestKeys.DEFAULT_PRIVATE_KEY) + .keyID("keyId") + .build(); + + given(this.jwkSelector.get(any(), any())).willReturn(Arrays.asList(rsaJwk)); + + String token = this.jwsEncoder.encoder().encode(EncodingMode.SIGN_THEN_ENCRYPT); + + DefaultJWTProcessor processor = new DefaultJWTProcessor<>(); + processor.setJWEKeySelector(new JWEDecryptionKeySelector<>(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM, new ImmutableJWKSet<>(new JWKSet(rsaJwk)))); + processor.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, new ImmutableJWKSet<>(new JWKSet(rsaJwk)))); + JWTClaimsSet claims = processor.process(token, null); + assertThat(claims.getExpirationTime()).isNotNull(); + } + + @Test + public void signThenEncryptWithOverridingClaimsThenWorks() throws Exception { + RSAKey rsaJwk = new RSAKey.Builder(TestKeys.DEFAULT_PUBLIC_KEY) + .privateKey(TestKeys.DEFAULT_PRIVATE_KEY) + .keyID("keyId") + .build(); + + given(this.jwkSelector.get(any(), any())).willReturn(Arrays.asList(rsaJwk)); + + String token = this.jwsEncoder.encoder() + .jwsHeaders((jws) -> jws.algorithm(SignatureAlgorithm.RS512)) + .jweHeaders((jwe) -> jwe.header("zip", "DEF")) + .claims((claims) -> claims.id("id")) + .encode(EncodingMode.SIGN_THEN_ENCRYPT); + + DefaultJWTProcessor processor = new DefaultJWTProcessor<>(); + processor.setJWEKeySelector(new JWEDecryptionKeySelector<>(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM, new ImmutableJWKSet<>(new JWKSet(rsaJwk)))); + processor.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.RS512, new ImmutableJWKSet<>(new JWKSet(rsaJwk)))); + JWTClaimsSet claims = processor.process(token, null); + assertThat(claims.getJWTID()).isEqualTo("id"); + assertThat(claims.getExpirationTime()).isNotNull(); + } + + @Test + public void signWithSettingKeyThenWorks() throws Exception { + RSAKey rsaJwk = new RSAKey.Builder(TestKeys.DEFAULT_PUBLIC_KEY) + .privateKey(TestKeys.DEFAULT_PRIVATE_KEY) + .keyID("keyId") + .build(); + + given(this.jwkSelector.get(any(), any())).willReturn(Arrays.asList(rsaJwk)); + + String token = this.jwsEncoder.encoder().jwsKey(rsaJwk).encode(); + + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(rsaJwk.toRSAPublicKey()).build(); + Jwt decoded = jwtDecoder.decode(token); + assertThat(decoded.getHeaders().get(JoseHeaderNames.KID)).isEqualTo("keyId"); + assertThat(decoded.getExpiresAt()).isNotNull(); + } + + @Test + public void encryptWithSettingKeyThenWorks() throws Exception { + RSAKey rsaJwk = new RSAKey.Builder(TestKeys.DEFAULT_PUBLIC_KEY) + .privateKey(TestKeys.DEFAULT_PRIVATE_KEY) + .keyID("keyId") + .build(); + + given(this.jwkSelector.get(any(), any())).willReturn(Arrays.asList(rsaJwk)); + + String token = this.jwsEncoder.encoder().jweKey(rsaJwk).encode(EncodingMode.ENCRYPT); + + DefaultJWTProcessor processor = new DefaultJWTProcessor<>(); + processor.setJWEKeySelector(new JWEDecryptionKeySelector<>(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM, new ImmutableJWKSet<>(new JWKSet(rsaJwk)))); + JWTClaimsSet claims = processor.process(token, null); + assertThat(claims.getExpirationTime()).isNotNull(); + } +} diff --git a/samples/boot/oauth2resourceserver/README.adoc b/samples/boot/oauth2resourceserver/README.adoc index a82747004f8..ad4a8f9a21b 100644 --- a/samples/boot/oauth2resourceserver/README.adoc +++ b/samples/boot/oauth2resourceserver/README.adoc @@ -39,7 +39,7 @@ To run as a stand-alone application, do: ./gradlew bootRun ``` -Or import the project into your IDE and run `OAuth2ResourceServerApplication` from there. +Or import the project into your IDE and run `SingleKeyApplication` from there. Once it is up, you can use the following token: diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/README.md b/samples/boot/oauth2resourceserver/src/main/java/sample/README.md new file mode 100644 index 00000000000..3777fd57886 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/README.md @@ -0,0 +1,53 @@ +Here are my samples. + +Please don't worry too much about class names, etc. +I'm sure you understand that all of this is a small step above pseudocode, and simply exists to try and clarify why I like the pattern I'm advocating. + +I recommend looking at them in the following order: + +* _singlekey_ - This sample is a likely candidate for how REST APIs will think about the encoder, i.e. many will just use a single key. +Its intent is to act as a baseline for the other two samples, but it also hopefully demonstrates that NimbusJwtEncoder has an opinion about +headers and claims that cannot be overridden. +* _context_ - This sample shows the value of an encoder contract that makes it possible to expose the underlying library, which is handy in Nimbus's case because it allows callers to supply an instance of `SecurityContext`. +The proposed NimbusJWSMinter, for example, supports handing a `JWKSecurityContext` so that a key can be specified. +* _style_ - This sample shows that returning a builder-like object is flexible enough to support both approaches - either customization before or after, while the approach in the PR is only practical for setting the values before the call. +Obviously, style is a lower priority, but if one way can address both approaches, why wouldn't we pick that, all other things being equal? + +In each case, there are four classes: + +* `TokenConfigOne` - what I'd imagine a typical `JwtEncoder` configuration to be where the application wants to set some defaults +* `TokenControllerOne` - how I'd imagine a controller using `JwtEncoder` to mint a `Jwt`, removing the defaulted `crit` header at invocation time +* `TokenConfigTwo` - what I'd imagine a typical `JwtEncoderAlternative` configuration to be where the application wants to set some defaults +* `TokenControllerTwo` - how I'd imagine a controller using `JwtEncoderAlternative` to mint a `Jwt`, removing the defaulted `crit` header at invocation time + +### Usage + +And you can get a token by doing: + +```bash +http -a user:password :8080/token/one +http -a user:password :8080/token/two +``` + +You can of course run the `XXXApplicationTests` tests, too. + +### Other Notes + +The reason there are unit tests for `JwtEncoderAlternative` are not because I'm wanting to take over the PR, but only because as I was doing my research, I wanted to make it really clear to myself the ins and outs of the pattern. +You are welcome to play around with those tests as well, if you wish. + +I understand your concerns about mixing responsibilities. +I don't see it this way - that information needs to be specified either way, it's simply a matter of when. +As you can see, the implementation doesn't do any additional work - it simply changes how it accepts it from the caller. +In fact, just as a point of illustration, I added an `apply` method to show that the responsibility could also be delegated to component objects. + +By the way, thank you for the observation about prototype beans. +Honestly, that brought me much closer to "even" in my assessment of the two approaches. +For the reasons each of these samples show, I still lean towards returning a builder-like object. + +### Conclusions + +If you are still not convinced after going through these samples, it might be time to get a third point of view from Rob. +Actually, we've debated this point long enough it might be good to get his opinion regardless. +I won't be offended if I'm out-voted. + diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/context/SecurityContextApplication.java b/samples/boot/oauth2resourceserver/src/main/java/sample/context/SecurityContextApplication.java new file mode 100644 index 00000000000..d7e72658385 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/context/SecurityContextApplication.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.context; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; + +import com.nimbusds.jose.jwk.RSAKey; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; + +/** + * @author Josh Cummings + */ +@SpringBootApplication +public class SecurityContextApplication { + + @Bean + UserDetailsService users() { + return new InMemoryUserDetailsManager(User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build()); + } + + @Bean + RSAKey key() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + KeyPair keyPair = generator.generateKeyPair(); + RSAPublicKey pub = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey priv = (RSAPrivateKey) keyPair.getPrivate(); + return new RSAKey.Builder(pub).privateKey(priv).keyIDFromThumbprint().build(); + } + + public static void main(String[] args) { + SpringApplication.run(SecurityContextApplication.class, args); + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/context/TenantSecurityContext.java b/samples/boot/oauth2resourceserver/src/main/java/sample/context/TenantSecurityContext.java new file mode 100644 index 00000000000..8c0bbc85342 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/context/TenantSecurityContext.java @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.context; + +import com.nimbusds.jose.proc.SecurityContext; + +public class TenantSecurityContext implements SecurityContext { + private final String tenant; + + public TenantSecurityContext(String tenant) { + this.tenant = tenant; + } + + public String getTenant() { + return tenant; + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/context/TokenConfigOne.java b/samples/boot/oauth2resourceserver/src/main/java/sample/context/TokenConfigOne.java new file mode 100644 index 00000000000..b01b1c330c0 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/context/TokenConfigOne.java @@ -0,0 +1,94 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.context; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; + +import com.nimbusds.jose.KeySourceException; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSelector; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JoseHeader; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwsEncoder; + +@Configuration +public class TokenConfigOne { + @Bean + public Supplier defaultHeader() { + return () -> + JoseHeader.builder() + .algorithm(SignatureAlgorithm.RS256) + .type("JWT") + .critical(Collections.singleton("ext")); // application-level opinion that might need overriding + } + + @Bean + public Supplier defaultClaimsSet() { + return () -> { + Instant now = Instant.now(); + return JwtClaimsSet.builder() + .id("id") + .issuedAt(now) + .expiresAt(now.plusSeconds(3600)) + .subject(SecurityContextHolder.getContext().getAuthentication().getName()) + .issuer("http://self"); + }; + } + + @Bean + JwtEncoder jwtEncoder(RSAKey key) { + return new NimbusJwsEncoder(new TenantJwkSource(key)); + } + + // NOTE: There are certainly numerous ways to represent multi-tenancy. + // This is simply an example of something application-specific that an + // application may want to pass into their Nimbus code. Nimbus lists + // other examples in their JavaDoc. Spring Security has a custom + // Nimbus SecurityContext in the reactive code as another example. + + private static class TenantJwkSource implements JWKSource { + private final List> sources; + + public TenantJwkSource(RSAKey key) { + // imagine one source per tenant, for example + this.sources = Collections.singletonList(new ImmutableJWKSet<>(new JWKSet(key))); + } + + @Override + public List get(JWKSelector jwkSelector, SecurityContext context) throws KeySourceException { + if (context instanceof TenantSecurityContext) { + // change behavior based on who the tenant is + return this.sources.get(0).get(jwkSelector, context); + } + throw new KeySourceException("Could not derive tenant"); + } + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/context/TokenConfigTwo.java b/samples/boot/oauth2resourceserver/src/main/java/sample/context/TokenConfigTwo.java new file mode 100644 index 00000000000..45779e0fe07 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/context/TokenConfigTwo.java @@ -0,0 +1,84 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.context; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +import com.nimbusds.jose.KeySourceException; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSelector; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.JwsHeaderMutator; +import org.springframework.security.oauth2.jwt.JwtClaimMutator; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +@Configuration +public class TokenConfigTwo { + @Bean + NimbusJwtEncoder jwsEncoder(RSAKey key) { + NimbusJwtEncoder jwsEncoder = new NimbusJwtEncoder(); + jwsEncoder.setJwkSource(new TenantJwkSource(key)); + return jwsEncoder; + } + + @Bean + Consumer> jwsCustomizer() { + return (headers) -> headers.criticalHeader("exp", Instant.now()); + } + + @Bean + Consumer> claimsCustomizer() { // might want to override at invocation time + return (claims) -> claims + .issuer("http://self") + .subject(SecurityContextHolder.getContext().getAuthentication().getName()); + } + + // NOTE: There are certainly numerous ways to represent multi-tenancy. + // This is simply an example of something application-specific that an + // application may want to pass into their Nimbus code. Nimbus lists + // other examples in their JavaDoc. Spring Security has a custom + // Nimbus SecurityContext in the reactive code as another example. + + private static class TenantJwkSource implements JWKSource { + private final List> sources; + + public TenantJwkSource(RSAKey key) { + // imagine one source per tenant, for example + this.sources = Collections.singletonList(new ImmutableJWKSet<>(new JWKSet(key))); + } + + @Override + public List get(JWKSelector jwkSelector, SecurityContext context) throws KeySourceException { + if (context instanceof TenantSecurityContext) { + // change behavior based on who the tenant is + return this.sources.get(0).get(jwkSelector, (TenantSecurityContext) context); + } + throw new KeySourceException("Could not derive tenant"); + } + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/context/TokenControllerOne.java b/samples/boot/oauth2resourceserver/src/main/java/sample/context/TokenControllerOne.java new file mode 100644 index 00000000000..5b3d4b51636 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/context/TokenControllerOne.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.context; + +import java.util.function.Supplier; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.oauth2.jwt.JoseHeader; +import org.springframework.security.oauth2.jwt.JoseHeaderNames; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TokenControllerOne { + @Autowired + JwtEncoder jwtEncoder; + + @Autowired + Supplier defaultHeader; + + @Autowired + Supplier defaultClaimsSet; + + @GetMapping("/token/one") + String tokenOne() { + return this.jwtEncoder.encode( + this.defaultHeader.get().headers((header) -> header.remove(JoseHeaderNames.CRIT)).build(), + this.defaultClaimsSet.get().id("id").build()).getTokenValue(); + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/context/TokenControllerTwo.java b/samples/boot/oauth2resourceserver/src/main/java/sample/context/TokenControllerTwo.java new file mode 100644 index 00000000000..ada32dd1b89 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/context/TokenControllerTwo.java @@ -0,0 +1,61 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.context; + +import java.util.Map; +import java.util.function.Consumer; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.oauth2.jwt.JwsHeaderMutator; +import org.springframework.security.oauth2.jwt.JwtClaimMutator; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TokenControllerTwo { + @Autowired + NimbusJwtEncoder jwtEncoder; + + @Autowired + Consumer> headerMutator; + + @Autowired + Consumer> claimsMutator; + + @GetMapping("/token/two") + String tokenTwo() { + return this.jwtEncoder.encoder() + .jwsHeaders(this.headerMutator) + .jwsHeaders((headers) -> headers.criticalHeaders(Map::clear)) + .jwsSecurityContext(new TenantSecurityContext("subdomain")) + .claims(this.claimsMutator) + .claims((claims) -> claims.id("id")) + .encode(); + } + + + /** + * What's nice about this pattern is that it's minimal for the application. + * For example, note that because of the application configuration, I only + * need one Spring Security component to sign and serialize a JWT. + */ + @GetMapping("/token/two/simple") + String tokenTwoSimple() { + return this.jwtEncoder.encoder().encode(); + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/singlekey/SingleKeyApplication.java b/samples/boot/oauth2resourceserver/src/main/java/sample/singlekey/SingleKeyApplication.java new file mode 100644 index 00000000000..e2fd9234f5d --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/singlekey/SingleKeyApplication.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.singlekey; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; + +import com.nimbusds.jose.jwk.RSAKey; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; + +/** + * @author Josh Cummings + */ +@SpringBootApplication +public class SingleKeyApplication { + + @Bean + UserDetailsService users() { + return new InMemoryUserDetailsManager(User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build()); + } + + @Bean + RSAKey key() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + KeyPair keyPair = generator.generateKeyPair(); + RSAPublicKey pub = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey priv = (RSAPrivateKey) keyPair.getPrivate(); + return new RSAKey.Builder(pub).privateKey(priv).build(); + } + + public static void main(String[] args) { + SpringApplication.run(SingleKeyApplication.class, args); + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/singlekey/TokenConfigOne.java b/samples/boot/oauth2resourceserver/src/main/java/sample/singlekey/TokenConfigOne.java new file mode 100644 index 00000000000..8518613970f --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/singlekey/TokenConfigOne.java @@ -0,0 +1,66 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.singlekey; + +import java.time.Instant; +import java.util.Collections; +import java.util.function.Supplier; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JoseHeader; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwsEncoder; + +@Configuration +public class TokenConfigOne { + @Bean + public Supplier defaultHeader() { + return () -> + JoseHeader.builder() + .algorithm(SignatureAlgorithm.RS256) + .type("JWT") + .critical(Collections.singleton("exp")); // application-level opinion that might need overriding + } + + @Bean + public Supplier defaultClaimsSet() { + return () -> { + Instant now = Instant.now(); + return JwtClaimsSet.builder() + .issuedAt(now) + .expiresAt(now.plusSeconds(3600)) + .subject(SecurityContextHolder.getContext().getAuthentication().getName()) + .issuer("http://self"); + }; + } + + @Bean + JwtEncoder jwtEncoder(RSAKey key) { + JWKSource jwks = new ImmutableJWKSet<>(new JWKSet(key)); + return new NimbusJwsEncoder(jwks); + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/singlekey/TokenConfigTwo.java b/samples/boot/oauth2resourceserver/src/main/java/sample/singlekey/TokenConfigTwo.java new file mode 100644 index 00000000000..12fefd66b58 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/singlekey/TokenConfigTwo.java @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.singlekey; + +import java.time.Instant; + +import com.nimbusds.jose.jwk.RSAKey; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.JwtEncoderAlternative; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +@Configuration +public class TokenConfigTwo { + @Bean + JwtEncoderAlternative jwsEncoder(RSAKey key) { + NimbusJwtEncoder delegate = new NimbusJwtEncoder(); + return () -> delegate.encoder() + .jwsKey(key) // simple to specify the key since I can expose Nimbus-specific methods when returning a builder + .jwsHeaders((jws) -> jws.criticalHeader("exp", Instant.now())) // application-level opinion that might need overriding + .claims((claims) -> claims + .issuer("http://self") + .subject(SecurityContextHolder.getContext().getAuthentication().getName()) + ); + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/singlekey/TokenControllerOne.java b/samples/boot/oauth2resourceserver/src/main/java/sample/singlekey/TokenControllerOne.java new file mode 100644 index 00000000000..cd36cd70b25 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/singlekey/TokenControllerOne.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.singlekey; + +import java.util.function.Supplier; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.oauth2.jwt.JoseHeader; +import org.springframework.security.oauth2.jwt.JoseHeaderNames; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TokenControllerOne { + @Autowired + JwtEncoder jwtEncoder; + + @Autowired + Supplier defaultHeader; + + @Autowired + Supplier defaultClaimsSet; + + @GetMapping("/token/one") + String tokenOne() { + return this.jwtEncoder.encode( + this.defaultHeader.get().headers((header) -> header.remove(JoseHeaderNames.CRIT)).build(), + this.defaultClaimsSet.get().id("id").build()).getTokenValue(); + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/singlekey/TokenControllerTwo.java b/samples/boot/oauth2resourceserver/src/main/java/sample/singlekey/TokenControllerTwo.java new file mode 100644 index 00000000000..e9768c71dee --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/singlekey/TokenControllerTwo.java @@ -0,0 +1,48 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.singlekey; + +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.oauth2.jwt.JwtEncoderAlternative; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TokenControllerTwo { + @Autowired + JwtEncoderAlternative jwtEncoder; + + @GetMapping("/token/two") + String tokenTwo() { + return this.jwtEncoder.encoder() + .claims((claims) -> claims.id("id")) + .jwsHeaders((headers) -> headers.criticalHeaders(Map::clear)) + .encode(); + } + + /** + * What's nice about this pattern is that it's minimal for the application. + * For example, note that because of the application configuration, I only + * need one Spring Security component to sign and serialize a JWT. + */ + @GetMapping("/token/two/simple") + String tokenTwoSimple() { + return this.jwtEncoder.encoder().encode(); + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/style/README.md b/samples/boot/oauth2resourceserver/src/main/java/sample/style/README.md new file mode 100644 index 00000000000..daee87aadb3 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/style/README.md @@ -0,0 +1,14 @@ +The point of this sample is to show that returning a builder is ultimately more flexible since it allows for both styles. + +In this case, + +* `TokenConfigOne` is enforcing its opinion in a delegating `JwtEncoder` implementation, and +* `TokenConfigTwo` is enforcing its opinion using `JoseHeader.Builder` and `JwtClaimsSet.Builder` beans + +(this is the opposite arrangement from the other samples) + +When the tables are turned like this, `TokenConfigTwo` and `TokenControllerTwo` are equally complex as in the other samples. +But, `TokenConfigOne` and `TokenControllerOne` are more complex and are missing features. + +I completely respect the fact that you prefer customizing outside of the encoder, and my point is not to discount that. +It seems to me that returning a builder leaves the possibility open for either style. diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/style/StyleApplication.java b/samples/boot/oauth2resourceserver/src/main/java/sample/style/StyleApplication.java new file mode 100644 index 00000000000..282505e183a --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/style/StyleApplication.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.style; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; + +import com.nimbusds.jose.jwk.RSAKey; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; + +/** + * @author Josh Cummings + */ +@SpringBootApplication +public class StyleApplication { + + @Bean + UserDetailsService users() { + return new InMemoryUserDetailsManager(User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build()); + } + + @Bean + RSAKey key() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + KeyPair keyPair = generator.generateKeyPair(); + RSAPublicKey pub = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey priv = (RSAPrivateKey) keyPair.getPrivate(); + return new RSAKey.Builder(pub).privateKey(priv).keyIDFromThumbprint().build(); + } + + public static void main(String[] args) { + SpringApplication.run(StyleApplication.class, args); + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/style/TokenConfigOne.java b/samples/boot/oauth2resourceserver/src/main/java/sample/style/TokenConfigOne.java new file mode 100644 index 00000000000..b9693b95305 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/style/TokenConfigOne.java @@ -0,0 +1,74 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.style; + +import java.time.Instant; +import java.util.Collections; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JoseHeader; +import org.springframework.security.oauth2.jwt.JoseHeaderNames; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwsEncoder; + +@Configuration +public class TokenConfigOne { + @Bean + JwtEncoder jwtEncoder(RSAKey key) { + JWKSource jwks = new ImmutableJWKSet<>(new JWKSet(key)); + NimbusJwsEncoder delegate = new NimbusJwsEncoder(jwks); + return (headers, claims) -> { + JoseHeader.Builder defaultHeaders = JoseHeader.from(headers); + if (!headers.getHeaders().containsKey(JoseHeaderNames.CRIT)) { // can't tell if caller wants `crit` to not be in the header or wants to take the encoder's opinion + defaultHeaders.critical(Collections.singleton("exp")); + defaultHeaders.header("exp", Instant.now()); + } + if (!headers.getHeaders().containsKey(JoseHeaderNames.TYP)) { + defaultHeaders.type("JWT"); + } + if (!headers.getHeaders().containsKey(JoseHeaderNames.ALG)) { + defaultHeaders.algorithm(SignatureAlgorithm.RS256); + } + JwtClaimsSet.Builder defaultClaimsSet = JwtClaimsSet.from(claims); + Instant now = Instant.now(); + if (!claims.hasClaim(JwtClaimNames.IAT)) { + defaultClaimsSet.issuedAt(now); + } + if (!claims.hasClaim(JwtClaimNames.EXP)) { + defaultClaimsSet.expiresAt(now.plusSeconds(3600)); + } + if (!claims.hasClaim(JwtClaimNames.SUB)) { + defaultClaimsSet.subject(SecurityContextHolder.getContext().getAuthentication().getName()); + } + if (!claims.hasClaim(JwtClaimNames.ISS)) { + defaultClaimsSet.issuer("http://self"); + } + return delegate.encode(defaultHeaders.build(), defaultClaimsSet.build()); + }; + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/style/TokenConfigTwo.java b/samples/boot/oauth2resourceserver/src/main/java/sample/style/TokenConfigTwo.java new file mode 100644 index 00000000000..66163eb4a3e --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/style/TokenConfigTwo.java @@ -0,0 +1,62 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.style; + +import java.time.Instant; +import java.util.function.Consumer; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JoseHeaderNames; +import org.springframework.security.oauth2.jwt.JwsHeaderMutator; +import org.springframework.security.oauth2.jwt.JwtClaimMutator; +import org.springframework.security.oauth2.jwt.JwtEncoderAlternative; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +@Configuration +public class TokenConfigTwo { + @Bean + public Consumer> defaultHeader() { + return (jws) -> jws + .algorithm(SignatureAlgorithm.RS256) + .header(JoseHeaderNames.TYP, "JWT") + .criticalHeader("exp", Instant.now()); // application-level opinion that might need overriding + } + + @Bean + public Consumer> defaultClaimsSet() { + return (claims) -> claims + .subject(SecurityContextHolder.getContext().getAuthentication().getName()) + .issuer("http://self"); + } + + @Bean + JwtEncoderAlternative jwsEncoder(RSAKey key) { + JWKSource jwks = new ImmutableJWKSet<>(new JWKSet(key)); + NimbusJwtEncoder jwsEncoder = new NimbusJwtEncoder(); + jwsEncoder.setJwkSource(jwks); + return jwsEncoder; + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/style/TokenControllerOne.java b/samples/boot/oauth2resourceserver/src/main/java/sample/style/TokenControllerOne.java new file mode 100644 index 00000000000..a208eb22db9 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/style/TokenControllerOne.java @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.style; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JoseHeader; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TokenControllerOne { + @Autowired + JwtEncoder jwtEncoder; + + @GetMapping("/token/one") + String tokenOne() { + return this.jwtEncoder.encode( + JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).build(), + // can't prevent `crit` header from being added + JwtClaimsSet.builder().id("id").build()).getTokenValue(); + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/style/TokenControllerTwo.java b/samples/boot/oauth2resourceserver/src/main/java/sample/style/TokenControllerTwo.java new file mode 100644 index 00000000000..6e8f35b263e --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/style/TokenControllerTwo.java @@ -0,0 +1,75 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.style; + +import java.util.Map; +import java.util.function.Consumer; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.oauth2.jwt.JwsHeaderMutator; +import org.springframework.security.oauth2.jwt.JwtClaimMutator; +import org.springframework.security.oauth2.jwt.JwtEncoderAlternative; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TokenControllerTwo { + @Autowired + JwtEncoderAlternative jwtEncoder; + + @Autowired + Consumer> defaultHeader; + + @Autowired + Consumer> defaultClaimsSet; + + /** + * This method is intended to demonstrate that both coding styles are possible with + * {@link JwtEncoderAlternative}, though I'll point out that this is less + * preferred due to the number of Spring Security components involved. + * + * In Authorization Server, there is an additional proposal for a separate customizer class, + * which could mean that folks may need to have four components to encode a JWT: + * + * 1. Use the customizer to create + * 2. A header object, and + * 3. A claims object, and then + * 4. Pass those two objects into encode + * + * If you take a look at {@link sample.singlekey.TokenControllerTwo#tokenTwoSimple()}, only one component + * is required to get all the benefit, which I think is quite nice for the application developer. + */ + @GetMapping("/token/two") + String tokenTwo() { + return this.jwtEncoder.encoder() + .jwsHeaders(this.defaultHeader) + .jwsHeaders((headers) -> headers.criticalHeaders(Map::clear)) + .claims(this.defaultClaimsSet) + .claims((claims) -> claims.id("id")) + .encode(); + } + + /** + * What's nice about this pattern is that it's minimal for the application. + * For example, note that because of the application configuration, I only + * need one Spring Security component to sign and serialize a JWT. + */ + @GetMapping("/token/two/simple") + String tokenTwoSimple() { + return this.jwtEncoder.encoder().encode(); + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/resources/application.yml b/samples/boot/oauth2resourceserver/src/main/resources/application.yml index 2a6d127d3fe..d1989ae553a 100644 --- a/samples/boot/oauth2resourceserver/src/main/resources/application.yml +++ b/samples/boot/oauth2resourceserver/src/main/resources/application.yml @@ -1,6 +1,2 @@ -spring: - security: - oauth2: - resourceserver: - jwt: - jwk-set-uri: ${mockwebserver.url}/.well-known/jwks.json +signing.key.private: classpath:simple.priv +signing.key.public: classpath:simple.pub diff --git a/samples/boot/oauth2resourceserver/src/test/java/sample/context/SecurityContextApplicationTests.java b/samples/boot/oauth2resourceserver/src/test/java/sample/context/SecurityContextApplicationTests.java new file mode 100644 index 00000000000..e70f5ad47bf --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/test/java/sample/context/SecurityContextApplicationTests.java @@ -0,0 +1,102 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.context; + +import com.nimbusds.jose.jwk.RSAKey; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.oauth2.jwt.JoseHeaderNames; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +public class SecurityContextApplicationTests { + @Autowired + MockMvc mvc; + + @Autowired + RSAKey key; + + JwtDecoder jwtDecoder; + + @Before + public void setup() throws Exception { + this.jwtDecoder = NimbusJwtDecoder.withPublicKey(this.key.toRSAPublicKey()).build(); + } + + @Test + @WithMockUser + public void tokenOneWhenDefaultsThenHasNoCritHeader() throws Exception { + String token = this.mvc.perform(get("/token/one")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getHeaders().get(JoseHeaderNames.CRIT)).isNull(); + } + + @Test + @WithMockUser + public void tokenOneWhenDefaultsThenHasDefaultIssuer() throws Exception { + String token = this.mvc.perform(get("/token/one")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getIssuer().toExternalForm()).isEqualTo("http://self"); + } + + @Test + @WithMockUser + public void tokenOneWhenDefaultsThenHasCustomId() throws Exception { + String token = this.mvc.perform(get("/token/one")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getId()).isEqualTo("id"); + } + + @Test + @WithMockUser + public void tokenTwoWhenDefaultsThenHasNoCritHeader() throws Exception { + String token = this.mvc.perform(get("/token/two")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getHeaders().get(JoseHeaderNames.CRIT)).isNull(); + } + + @Test + @WithMockUser + public void tokenTwoWhenDefaultsThenHasDefaultIssuer() throws Exception { + String token = this.mvc.perform(get("/token/two")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getIssuer().toExternalForm()).isEqualTo("http://self"); + } + + @Test + @WithMockUser + public void tokenTwoWhenDefaultsThenHasCustomId() throws Exception { + String token = this.mvc.perform(get("/token/two")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getId()).isEqualTo("id"); + } +} diff --git a/samples/boot/oauth2resourceserver/src/test/java/sample/singlekey/SingleKeyApplicationTests.java b/samples/boot/oauth2resourceserver/src/test/java/sample/singlekey/SingleKeyApplicationTests.java new file mode 100644 index 00000000000..5f36d3e2139 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/test/java/sample/singlekey/SingleKeyApplicationTests.java @@ -0,0 +1,118 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.singlekey; + +import com.nimbusds.jose.jwk.RSAKey; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.oauth2.jwt.JoseHeaderNames; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +public class SingleKeyApplicationTests { + @Autowired + MockMvc mvc; + + @Autowired + RSAKey key; + + JwtDecoder jwtDecoder; + + @Before + public void setup() throws Exception { + this.jwtDecoder = NimbusJwtDecoder.withPublicKey(this.key.toRSAPublicKey()).build(); + } + + @Test + @WithMockUser + public void tokenOneWhenDefaultsThenHasNoCritHeader() throws Exception { + String token = this.mvc.perform(get("/token/one")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getHeaders().get(JoseHeaderNames.CRIT)).isNull(); + } + + @Test + @WithMockUser + public void tokenOneWhenDefaultsThenHasDefaultIssuer() throws Exception { + String token = this.mvc.perform(get("/token/one")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getIssuer().toExternalForm()).isEqualTo("http://self"); + } + + @Test + @WithMockUser + public void tokenOneWhenDefaultsThenHasCustomId() throws Exception { + String token = this.mvc.perform(get("/token/one")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getId()).isEqualTo("id"); + } + + @Test + @WithMockUser + public void tokenOneWhenDefaultsThenHasNoKid() throws Exception { + String token = this.mvc.perform(get("/token/one")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getHeaders().get(JoseHeaderNames.KID)).isNull(); + } + + @Test + @WithMockUser + public void tokenTwoWhenDefaultsThenHasNoCritHeader() throws Exception { + String token = this.mvc.perform(get("/token/two")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getHeaders().get(JoseHeaderNames.CRIT)).isNull(); + } + + @Test + @WithMockUser + public void tokenTwoWhenDefaultsThenHasDefaultIssuer() throws Exception { + String token = this.mvc.perform(get("/token/two")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getIssuer().toExternalForm()).isEqualTo("http://self"); + } + + @Test + @WithMockUser + public void tokenTwoWhenDefaultsThenHasCustomId() throws Exception { + String token = this.mvc.perform(get("/token/two")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getId()).isEqualTo("id"); + } + + @Test + @WithMockUser + public void tokenTwoWhenDefaultsThenHasNoKid() throws Exception { + String token = this.mvc.perform(get("/token/two")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getHeaders().get(JoseHeaderNames.KID)).isNull(); + } +} diff --git a/samples/boot/oauth2resourceserver/src/test/java/sample/style/StyleApplicationTests.java b/samples/boot/oauth2resourceserver/src/test/java/sample/style/StyleApplicationTests.java new file mode 100644 index 00000000000..1c1840bd6f7 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/test/java/sample/style/StyleApplicationTests.java @@ -0,0 +1,121 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.style; + +import java.security.Key; +import java.security.interfaces.RSAPublicKey; +import java.util.Collections; + +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jose.crypto.factories.DefaultJWSVerifierFactory; +import com.nimbusds.jose.jwk.RSAKey; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.oauth2.jwt.JoseHeaderNames; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +public class StyleApplicationTests { + @Autowired + MockMvc mvc; + + @Autowired + RSAKey key; + + JwtDecoder jwtDecoder; + + @Before + public void setup() throws Exception { + this.jwtDecoder = NimbusJwtDecoder.withPublicKey(this.key.toRSAPublicKey()) + .jwtProcessorCustomizer((processor) -> processor + .setJWSVerifierFactory( + new DefaultJWSVerifierFactory() { + @Override + public JWSVerifier createJWSVerifier(JWSHeader header, Key key) { + return new RSASSAVerifier((RSAPublicKey) key, Collections.singleton("exp")); + } + } + ) + ) + .build(); + } + + @Test + @WithMockUser + public void tokenOneWhenDefaultsThenHasNoCritHeader() throws Exception { + String token = this.mvc.perform(get("/token/one")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getHeaders().get(JoseHeaderNames.CRIT)).isNull(); + } + + @Test + @WithMockUser + public void tokenOneWhenDefaultsThenHasDefaultIssuer() throws Exception { + String token = this.mvc.perform(get("/token/one")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getIssuer().toExternalForm()).isEqualTo("http://self"); + } + + @Test + @WithMockUser + public void tokenOneWhenDefaultsThenHasCustomId() throws Exception { + String token = this.mvc.perform(get("/token/one")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getId()).isEqualTo("id"); + } + + @Test + @WithMockUser + public void tokenTwoWhenDefaultsThenHasNoCritHeader() throws Exception { + String token = this.mvc.perform(get("/token/two")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getHeaders().get(JoseHeaderNames.CRIT)).isNull(); + } + + @Test + @WithMockUser + public void tokenTwoWhenDefaultsThenHasDefaultIssuer() throws Exception { + String token = this.mvc.perform(get("/token/two")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getIssuer().toExternalForm()).isEqualTo("http://self"); + } + + @Test + @WithMockUser + public void tokenTwoWhenDefaultsThenHasCustomId() throws Exception { + String token = this.mvc.perform(get("/token/two")).andReturn().getResponse().getContentAsString(); + Jwt jwt = this.jwtDecoder.decode(token); + assertThat(jwt.getId()).isEqualTo("id"); + } +}