From c986b6f4b521efc05c1c099d361402a8c1580890 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 4 Jul 2017 17:30:54 -0400 Subject: [PATCH] Add support for JWT/JWS Fixes gh-4434 --- .../jwt-jose/spring-security-jwt-jose.gradle | 8 ++ .../security/jose/jws/JwsAlgorithm.java | 91 ++++++++++++++ .../org/springframework/security/jwt/Jwt.java | 64 ++++++++++ .../security/jwt/JwtClaim.java | 43 +++++++ .../security/jwt/JwtClaimAccessor.java | 64 ++++++++++ .../security/jwt/JwtDecoder.java | 42 +++++++ .../security/jwt/JwtException.java | 33 +++++ .../nimbus/NimbusJwtDecoderJwkSupport.java | 113 ++++++++++++++++++ .../security/oauth2/core/ClaimAccessor.java | 69 +++++++++++ 9 files changed, 527 insertions(+) create mode 100644 oauth2/jwt-jose/spring-security-jwt-jose.gradle create mode 100644 oauth2/jwt-jose/src/main/java/org/springframework/security/jose/jws/JwsAlgorithm.java create mode 100644 oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/Jwt.java create mode 100644 oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/JwtClaim.java create mode 100644 oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/JwtClaimAccessor.java create mode 100644 oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/JwtDecoder.java create mode 100644 oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/JwtException.java create mode 100644 oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/nimbus/NimbusJwtDecoderJwkSupport.java create mode 100644 oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClaimAccessor.java diff --git a/oauth2/jwt-jose/spring-security-jwt-jose.gradle b/oauth2/jwt-jose/spring-security-jwt-jose.gradle new file mode 100644 index 0000000000..5194971ff7 --- /dev/null +++ b/oauth2/jwt-jose/spring-security-jwt-jose.gradle @@ -0,0 +1,8 @@ +apply plugin: 'io.spring.convention.spring-module' + +dependencies { + compile project(':spring-security-core') + compile project(':spring-security-oauth2-core') + compile springCoreDependency + compile 'com.nimbusds:nimbus-jose-jwt' +} diff --git a/oauth2/jwt-jose/src/main/java/org/springframework/security/jose/jws/JwsAlgorithm.java b/oauth2/jwt-jose/src/main/java/org/springframework/security/jose/jws/JwsAlgorithm.java new file mode 100644 index 0000000000..caf1ee0c41 --- /dev/null +++ b/oauth2/jwt-jose/src/main/java/org/springframework/security/jose/jws/JwsAlgorithm.java @@ -0,0 +1,91 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.jose.jws; + +/** + * The cryptographic algorithms defined by the JSON Web Algorithms (JWA) specification + * and used by JSON Web Signature (JWS) to digitally sign or create a MAC + * of the contents of the JWS Protected Header and JWS Payload. + * + * @author Joe Grandja + * @since 5.0 + * @see JSON Web Algorithms (JWA) + * @see JSON Web Signature (JWS) + * @see Cryptographic Algorithms for Digital Signatures and MACs + */ +public interface JwsAlgorithm { + + /** + * HMAC using SHA-256 (Required) + */ + String HS256 = "HS256"; + + /** + * HMAC using SHA-384 (Optional) + */ + String HS384 = "HS384"; + + /** + * HMAC using SHA-512 (Optional) + */ + String HS512 = "HS512"; + + /** + * RSASSA-PKCS1-v1_5 using SHA-256 (Recommended) + */ + String RS256 = "RS256"; + + /** + * RSASSA-PKCS1-v1_5 using SHA-384 (Optional) + */ + String RS384 = "RS384"; + + /** + * RSASSA-PKCS1-v1_5 using SHA-512 (Optional) + */ + String RS512 = "RS512"; + + /** + * ECDSA using P-256 and SHA-256 (Recommended+) + */ + String ES256 = "ES256"; + + /** + * ECDSA using P-384 and SHA-384 (Optional) + */ + String ES384 = "ES384"; + + /** + * ECDSA using P-521 and SHA-512 (Optional) + */ + String ES512 = "ES512"; + + /** + * RSASSA-PSS using SHA-256 and MGF1 with SHA-256 (Optional) + */ + String PS256 = "PS256"; + + /** + * RSASSA-PSS using SHA-384 and MGF1 with SHA-384 (Optional) + */ + String PS384 = "PS384"; + + /** + * RSASSA-PSS using SHA-512 and MGF1 with SHA-512 (Optional) + */ + String PS512 = "PS512"; + +} diff --git a/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/Jwt.java b/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/Jwt.java new file mode 100644 index 0000000000..b72eb9b7fe --- /dev/null +++ b/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/Jwt.java @@ -0,0 +1,64 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.jwt; + +import org.springframework.security.oauth2.core.AbstractToken; +import org.springframework.util.Assert; + +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * An implementation of an {@link AbstractToken} representing a JSON Web Token (JWT). + * + *

+ * JWTs represent a set of "Claims" as a JSON object that is encoded in a + * JSON Web Signature (JWS) and/or JSON Web Encryption (JWE) structure. + * The JSON object, also known as the JWT Claims Set, consists of one or more Claim Name/Claim Value pairs. + * The Claim Name is a String and the Claim Value is an arbitrary JSON object. + * + * @author Joe Grandja + * @since 5.0 + * @see AbstractToken + * @see JwtClaimAccessor + * @see JSON Web Token (JWT) + * @see JSON Web Signature (JWS) + * @see JSON Web Encryption (JWE) + */ +public class Jwt extends AbstractToken implements JwtClaimAccessor { + private final Map headers; + private final Map claims; + + public Jwt(String tokenValue, Instant issuedAt, Instant expiresAt, + Map headers, Map claims) { + super(tokenValue, issuedAt, expiresAt); + Assert.notEmpty(headers, "headers cannot be empty"); + Assert.notEmpty(claims, "claims cannot be empty"); + this.headers = Collections.unmodifiableMap(new LinkedHashMap<>(headers)); + this.claims = Collections.unmodifiableMap(new LinkedHashMap<>(claims)); + } + + public Map getHeaders() { + return this.headers; + } + + @Override + public Map getClaims() { + return this.claims; + } +} diff --git a/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/JwtClaim.java b/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/JwtClaim.java new file mode 100644 index 0000000000..edf82bb8f1 --- /dev/null +++ b/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/JwtClaim.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.jwt; + +/** + * The "Registered Claim Names" defined by the JSON Web Token (JWT) specification + * that may be contained in the JSON object JWT Claims Set. + * + * @author Joe Grandja + * @since 5.0 + * @see JwtClaimAccessor + * @see JWT Claims + */ +public interface JwtClaim { + + String ISS = "iss"; + + String SUB = "sub"; + + String AUD = "aud"; + + String EXP = "exp"; + + String NBF = "nbf"; + + String IAT = "iat"; + + String JTI = "jti"; + +} diff --git a/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/JwtClaimAccessor.java b/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/JwtClaimAccessor.java new file mode 100644 index 0000000000..03f81d59cb --- /dev/null +++ b/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/JwtClaimAccessor.java @@ -0,0 +1,64 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.jwt; + +import org.springframework.security.oauth2.core.ClaimAccessor; + +import java.net.URI; +import java.time.Instant; + +/** + * A {@link ClaimAccessor} for the "Registered Claim Names" + * that may be contained in the JSON object JWT Claims Set of a JSON Web Token (JWT). + * + * @author Joe Grandja + * @since 5.0 + * @see ClaimAccessor + * @see JwtClaim + * @see Jwt + * @see Registered Claim Names + */ +public interface JwtClaimAccessor extends ClaimAccessor { + + default URI getIssuer() { + return this.getClaimAsURI(JwtClaim.ISS); + } + + default String getSubject() { + return this.getClaimAsString(JwtClaim.SUB); + } + + default String getAudience() { + // FIXME Should return String[] + return this.getClaimAsString(JwtClaim.AUD); + } + + default Instant getExpiresAt() { + return this.getClaimAsInstant(JwtClaim.EXP); + } + + default Instant getNotBefore() { + return this.getClaimAsInstant(JwtClaim.NBF); + } + + default Instant getIssuedAt() { + return this.getClaimAsInstant(JwtClaim.IAT); + } + + default String getId() { + return this.getClaimAsString(JwtClaim.JTI); + } +} diff --git a/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/JwtDecoder.java b/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/JwtDecoder.java new file mode 100644 index 0000000000..b61976e7f3 --- /dev/null +++ b/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/JwtDecoder.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.jwt; + +/** + * Implementations of this interface are responsible for "decoding" + * a JSON Web Token (JWT) from it's compact claims representation format to a {@link Jwt}. + * + *

+ * JWTs may be represented using the JWS Compact Serialization format for a + * JSON Web Signature (JWS) structure or JWE Compact Serialization format for a + * JSON Web Encryption (JWE) structure. Therefore, implementors are responsible + * for verifying a JWS and/or decrypting a JWE. + * + * @author Joe Grandja + * @since 5.0 + * @see Jwt + * @see JSON Web Token (JWT) + * @see JSON Web Signature (JWS) + * @see JSON Web Encryption (JWE) + * @see JWS Compact Serialization + * @see JWE Compact Serialization + */ +@FunctionalInterface +public interface JwtDecoder { + + Jwt decode(String token) throws JwtException; + +} diff --git a/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/JwtException.java b/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/JwtException.java new file mode 100644 index 0000000000..8a69decb7e --- /dev/null +++ b/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/JwtException.java @@ -0,0 +1,33 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.jwt; + +/** + * Base exception for all JSON Web Token (JWT) related errors. + * + * @author Joe Grandja + * @since 5.0 + */ +public class JwtException extends RuntimeException { + + public JwtException(String message) { + super(message); + } + + public JwtException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/nimbus/NimbusJwtDecoderJwkSupport.java b/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/nimbus/NimbusJwtDecoderJwkSupport.java new file mode 100644 index 0000000000..11265bc78d --- /dev/null +++ b/oauth2/jwt-jose/src/main/java/org/springframework/security/jwt/nimbus/NimbusJwtDecoderJwkSupport.java @@ -0,0 +1,113 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.jwt.nimbus; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.RemoteJWKSet; +import com.nimbusds.jose.proc.JWSKeySelector; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.JWTParser; +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import org.springframework.security.jose.jws.JwsAlgorithm; +import org.springframework.security.jwt.Jwt; +import org.springframework.security.jwt.JwtDecoder; +import org.springframework.security.jwt.JwtException; +import org.springframework.util.Assert; + +import java.net.MalformedURLException; +import java.net.URL; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * An implementation of a {@link JwtDecoder} that "decodes" a + * JSON Web Token (JWT) and additionally verifies it's digital signature if the JWT is a + * JSON Web Signature (JWS). The public key used for verification is obtained from the + * JSON Web Key (JWK) Set URL which is supplied via the constructor. + * + *

+ * NOTE: This implementation uses the Nimbus JOSE + JWT SDK internally. + * + * @author Joe Grandja + * @since 5.0 + * @see JwtDecoder + * @see JSON Web Token (JWT) + * @see JSON Web Signature (JWS) + * @see JSON Web Key (JWK) + * @see Nimbus JOSE + JWT SDK + */ +public class NimbusJwtDecoderJwkSupport implements JwtDecoder { + private final URL jwkSetUrl; + private final JWSAlgorithm jwsAlgorithm; + private final ConfigurableJWTProcessor jwtProcessor; + + public NimbusJwtDecoderJwkSupport(String jwkSetUrl) { + this(jwkSetUrl, JwsAlgorithm.RS256); + } + + public NimbusJwtDecoderJwkSupport(String jwkSetUrl, String jwsAlgorithm) { + Assert.hasText(jwkSetUrl, "jwkSetUrl cannot be empty"); + Assert.hasText(jwsAlgorithm, "jwsAlgorithm cannot be empty"); + try { + this.jwkSetUrl = new URL(jwkSetUrl); + } catch (MalformedURLException ex) { + throw new IllegalArgumentException("Invalid JWK Set URL: " + ex.getMessage(), ex); + } + this.jwsAlgorithm = JWSAlgorithm.parse(jwsAlgorithm); + + this.jwtProcessor = new DefaultJWTProcessor<>(); + JWKSource jwkSource = new RemoteJWKSet(this.jwkSetUrl); + JWSKeySelector jwsKeySelector = + new JWSVerificationKeySelector(this.jwsAlgorithm, jwkSource); + this.jwtProcessor.setJWSKeySelector(jwsKeySelector); + } + + @Override + public Jwt decode(String token) throws JwtException { + Jwt jwt; + + try { + JWT parsedJwt = JWTParser.parse(token); + + // Verify the signature + JWTClaimsSet jwtClaimsSet = this.jwtProcessor.process(parsedJwt, null); + + Instant expiresAt = jwtClaimsSet.getExpirationTime().toInstant(); + Instant issuedAt; + if (jwtClaimsSet.getIssueTime() != null) { + issuedAt = jwtClaimsSet.getIssueTime().toInstant(); + } else { + // issuedAt is required in AbstractToken so let's default to expiresAt - 1 second + issuedAt = Instant.from(expiresAt).minusSeconds(1); + } + + Map headers = new LinkedHashMap<>(parsedJwt.getHeader().toJSONObject()); + + jwt = new Jwt(token, issuedAt, expiresAt, headers, jwtClaimsSet.getClaims()); + + } catch (Exception ex) { + throw new JwtException("An error occurred while attempting to decode the Jwt: " + ex.getMessage(), ex); + } + + return jwt; + } +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClaimAccessor.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClaimAccessor.java new file mode 100644 index 0000000000..0795c3ec44 --- /dev/null +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClaimAccessor.java @@ -0,0 +1,69 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.core; + +import org.springframework.util.Assert; + +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Instant; +import java.util.Map; + +/** + * An "accessor" for a set of claims that may be used for assertions. + * + * @author Joe Grandja + * @since 5.0 + */ +public interface ClaimAccessor { + + Map getClaims(); + + default Boolean containsClaim(String claim) { + Assert.notNull(claim, "claim cannot be null"); + return this.getClaims().containsKey(claim); + } + + default String getClaimAsString(String claim) { + return (this.containsClaim(claim) ? this.getClaims().get(claim).toString() : null); + } + + default Boolean getClaimAsBoolean(String claim) { + return (this.containsClaim(claim) ? Boolean.valueOf(this.getClaimAsString(claim)) : null); + } + + default Instant getClaimAsInstant(String claim) { + if (!this.containsClaim(claim)) { + return null; + } + try { + return Instant.ofEpochSecond(Long.valueOf(this.getClaimAsString(claim))); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException("Unable to convert claim '" + claim + "' to Instant: " + ex.getMessage(), ex); + } + } + + default URI getClaimAsURI(String claim) { + if (!this.containsClaim(claim)) { + return null; + } + try { + return new URI(this.getClaimAsString(claim)); + } catch (URISyntaxException ex) { + throw new IllegalArgumentException("Unable to convert claim '" + claim + "' to URI: " + ex.getMessage(), ex); + } + } +}