From 4e7c9bee460dc161b0b1f944c3895c102158d03a Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Tue, 21 Sep 2021 16:56:14 -0600 Subject: [PATCH] Add Supplier JwtDecoders Closes gh-9991 --- .../JwtDecoderInitializationException.java | 32 +++++++ .../oauth2/jwt/SupplierJwtDecoder.java | 61 ++++++++++++++ .../jwt/SupplierReactiveJwtDecoder.java | 59 +++++++++++++ .../oauth2/jwt/SupplierJwtDecoderTests.java | 80 ++++++++++++++++++ .../jwt/SupplierReactiveJwtDecoderTests.java | 83 +++++++++++++++++++ 5 files changed, 315 insertions(+) create mode 100644 oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoderInitializationException.java create mode 100644 oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/SupplierJwtDecoder.java create mode 100644 oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/SupplierReactiveJwtDecoder.java create mode 100644 oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/SupplierJwtDecoderTests.java create mode 100644 oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/SupplierReactiveJwtDecoderTests.java diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoderInitializationException.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoderInitializationException.java new file mode 100644 index 0000000000..775da4c9a9 --- /dev/null +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoderInitializationException.java @@ -0,0 +1,32 @@ +/* + * 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; + +/** + * An exception thrown when a {@link JwtDecoder} or {@link ReactiveJwtDecoder}'s lazy + * initialization fails. + * + * @author Josh Cummings + * @since 5.6 + */ +public class JwtDecoderInitializationException extends RuntimeException { + + public JwtDecoderInitializationException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/SupplierJwtDecoder.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/SupplierJwtDecoder.java new file mode 100644 index 0000000000..bbcbdab35e --- /dev/null +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/SupplierJwtDecoder.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.function.Supplier; + +/** + * A {@link JwtDecoder} that lazily initializes another {@link JwtDecoder} + * + * @author Josh Cummings + * @since 5.6 + */ +public final class SupplierJwtDecoder implements JwtDecoder { + + private final Supplier jwtDecoderSupplier; + + private volatile JwtDecoder delegate; + + public SupplierJwtDecoder(Supplier jwtDecoderSupplier) { + this.jwtDecoderSupplier = jwtDecoderSupplier; + } + + /** + * {@inheritDoc} + */ + @Override + public Jwt decode(String token) throws JwtException { + if (this.delegate == null) { + synchronized (this.jwtDecoderSupplier) { + if (this.delegate == null) { + try { + this.delegate = this.jwtDecoderSupplier.get(); + } + catch (Exception ex) { + throw wrapException(ex); + } + } + } + } + return this.delegate.decode(token); + } + + private JwtDecoderInitializationException wrapException(Exception ex) { + return new JwtDecoderInitializationException("Failed to lazily resolve the supplied JwtDecoder instance", ex); + } + +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/SupplierReactiveJwtDecoder.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/SupplierReactiveJwtDecoder.java new file mode 100644 index 0000000000..bdffc21c98 --- /dev/null +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/SupplierReactiveJwtDecoder.java @@ -0,0 +1,59 @@ +/* + * 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.Duration; +import java.util.function.Supplier; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * A {@link ReactiveJwtDecoder} that lazily initializes another {@link ReactiveJwtDecoder} + * + * @author Josh Cummings + * @since 5.6 + */ +public final class SupplierReactiveJwtDecoder implements ReactiveJwtDecoder { + + private static final Duration FOREVER = Duration.ofMillis(Long.MAX_VALUE); + + private Mono jwtDecoderMono; + + public SupplierReactiveJwtDecoder(Supplier supplier) { + // @formatter:off + this.jwtDecoderMono = Mono.fromSupplier(supplier) + .subscribeOn(Schedulers.boundedElastic()) + .publishOn(Schedulers.parallel()) + .onErrorMap(this::wrapException) + .cache((delegate) -> FOREVER, (ex) -> Duration.ZERO, () -> Duration.ZERO); + // @formatter:on + } + + private JwtDecoderInitializationException wrapException(Throwable t) { + return new JwtDecoderInitializationException("Failed to lazily resolve the supplied JwtDecoder instance", t); + } + + /** + * {@inheritDoc} + */ + @Override + public Mono decode(String token) throws JwtException { + return this.jwtDecoderMono.flatMap((decoder) -> decoder.decode(token)); + } + +} diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/SupplierJwtDecoderTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/SupplierJwtDecoderTests.java new file mode 100644 index 0000000000..645fe35e27 --- /dev/null +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/SupplierJwtDecoderTests.java @@ -0,0 +1,80 @@ +/* + * 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.Supplier; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link SupplierJwtDecoder} + * + * @author Josh Cummings + */ +public class SupplierJwtDecoderTests { + + @Test + public void decodeWhenUninitializedThenSupplierInitializes() { + JwtDecoder jwtDecoder = mock(JwtDecoder.class); + SupplierJwtDecoder supplierJwtDecoder = new SupplierJwtDecoder(() -> jwtDecoder); + supplierJwtDecoder.decode("token"); + verify(jwtDecoder).decode("token"); + } + + @Test + public void decodeWhenInitializationFailsThenInitializationException() { + Supplier broken = mock(Supplier.class); + given(broken.get()).willThrow(RuntimeException.class); + JwtDecoder jwtDecoder = new SupplierJwtDecoder(broken); + assertThatExceptionOfType(JwtDecoderInitializationException.class).isThrownBy(() -> jwtDecoder.decode("token")); + verify(broken).get(); + } + + @Test + public void decodeWhenInitializedThenCaches() { + JwtDecoder jwtDecoder = mock(JwtDecoder.class); + Supplier supplier = mock(Supplier.class); + given(supplier.get()).willReturn(jwtDecoder); + JwtDecoder supplierJwtDecoder = new SupplierJwtDecoder(supplier); + supplierJwtDecoder.decode("token"); + supplierJwtDecoder.decode("token"); + verify(supplier, times(1)).get(); + verify(jwtDecoder, times(2)).decode("token"); + } + + @Test + public void decodeWhenInitializationInitiallyFailsThenRecoverable() { + JwtDecoder jwtDecoder = mock(JwtDecoder.class); + Supplier broken = mock(Supplier.class); + given(broken.get()).willThrow(RuntimeException.class); + JwtDecoder supplierJwtDecoder = new SupplierJwtDecoder(broken); + assertThatExceptionOfType(JwtDecoderInitializationException.class) + .isThrownBy(() -> supplierJwtDecoder.decode("token")); + reset(broken); + given(broken.get()).willReturn(jwtDecoder); + supplierJwtDecoder.decode("token"); + verify(jwtDecoder).decode("token"); + } + +} diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/SupplierReactiveJwtDecoderTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/SupplierReactiveJwtDecoderTests.java new file mode 100644 index 0000000000..febe62d607 --- /dev/null +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/SupplierReactiveJwtDecoderTests.java @@ -0,0 +1,83 @@ +/* + * 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.Supplier; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link SupplierReactiveJwtDecoder} + */ +public class SupplierReactiveJwtDecoderTests { + + @Test + public void decodeWhenUninitializedThenSupplierInitializes() { + ReactiveJwtDecoder jwtDecoder = mock(ReactiveJwtDecoder.class); + given(jwtDecoder.decode("token")).willReturn(Mono.empty()); + SupplierReactiveJwtDecoder supplierReactiveJwtDecoder = new SupplierReactiveJwtDecoder(() -> jwtDecoder); + supplierReactiveJwtDecoder.decode("token").block(); + verify(jwtDecoder).decode("token"); + } + + @Test + public void decodeWhenInitializationFailsThenInitializationException() { + Supplier broken = mock(Supplier.class); + given(broken.get()).willThrow(RuntimeException.class); + ReactiveJwtDecoder jwtDecoder = new SupplierReactiveJwtDecoder(broken); + assertThatExceptionOfType(JwtDecoderInitializationException.class) + .isThrownBy(() -> jwtDecoder.decode("token").block()); + verify(broken).get(); + } + + @Test + public void decodeWhenInitializedThenCaches() { + ReactiveJwtDecoder jwtDecoder = mock(ReactiveJwtDecoder.class); + Supplier supplier = mock(Supplier.class); + given(supplier.get()).willReturn(jwtDecoder); + given(jwtDecoder.decode("token")).willReturn(Mono.empty()); + ReactiveJwtDecoder supplierReactiveJwtDecoder = new SupplierReactiveJwtDecoder(supplier); + supplierReactiveJwtDecoder.decode("token").block(); + supplierReactiveJwtDecoder.decode("token").block(); + verify(supplier, times(1)).get(); + verify(jwtDecoder, times(2)).decode("token"); + } + + @Test + public void decodeWhenInitializationInitiallyFailsThenRecoverable() { + ReactiveJwtDecoder jwtDecoder = mock(ReactiveJwtDecoder.class); + Supplier broken = mock(Supplier.class); + given(broken.get()).willThrow(RuntimeException.class); + given(jwtDecoder.decode("token")).willReturn(Mono.empty()); + ReactiveJwtDecoder supplierReactiveJwtDecoder = new SupplierReactiveJwtDecoder(broken); + assertThatExceptionOfType(JwtDecoderInitializationException.class) + .isThrownBy(() -> supplierReactiveJwtDecoder.decode("token").block()); + reset(broken); + given(broken.get()).willReturn(jwtDecoder); + supplierReactiveJwtDecoder.decode("token").block(); + verify(jwtDecoder).decode("token"); + } + +}