diff --git a/core/src/main/java/feign/BaseBuilder.java b/core/src/main/java/feign/BaseBuilder.java index d2549e0a7b..132b4c22ea 100644 --- a/core/src/main/java/feign/BaseBuilder.java +++ b/core/src/main/java/feign/BaseBuilder.java @@ -262,7 +262,13 @@ B enrich() { } }); - return clone; + B enrichedBuilder = clone; + + for (final Capability capability : capabilities) { + enrichedBuilder = capability.beforeBuild(enrichedBuilder); + } + + return enrichedBuilder; } catch (CloneNotSupportedException e) { throw new AssertionError(e); } diff --git a/core/src/main/java/feign/Capability.java b/core/src/main/java/feign/Capability.java index 7fb7e5d131..d4c950f155 100644 --- a/core/src/main/java/feign/Capability.java +++ b/core/src/main/java/feign/Capability.java @@ -144,4 +144,18 @@ default AsyncContextSupplier enrich(AsyncContextSupplier asyncContextS default MethodInfoResolver enrich(MethodInfoResolver methodInfoResolver) { return methodInfoResolver; } + + /** + * Hook executed before the build of Feign client is done. Any interceptors or retryers added by + * this method are not enriched by this or any other Capability. + * + * @param baseBuilder feign client builder + * @return enriched builder + * @param builder class + * @param target class + * @see OAuth2Authentication + */ + default , T> B beforeBuild(B baseBuilder) { + return baseBuilder; + } } diff --git a/core/src/test/java/feign/BaseBuilderTest.java b/core/src/test/java/feign/BaseBuilderTest.java index 297f0e3bd3..dc441dbcbc 100644 --- a/core/src/test/java/feign/BaseBuilderTest.java +++ b/core/src/test/java/feign/BaseBuilderTest.java @@ -17,7 +17,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.RETURNS_MOCKS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import java.lang.reflect.Field; import java.util.List; @@ -29,16 +33,38 @@ class BaseBuilderTest { @Test void checkEnrichTouchesAllAsyncBuilderFields() throws IllegalArgumentException, IllegalAccessException { - test( - AsyncFeign.builder() - .requestInterceptor(template -> {}) - .responseInterceptor((ic, c) -> c.next(ic)), - 14); + Capability mockingCapability = + test( + AsyncFeign.builder() + .requestInterceptor(template -> {}) + .responseInterceptor((ic, c) -> c.next(ic)), + 14); + + // make sure capability was invoked only once + verify(mockingCapability).enrich(any(AsyncClient.class)); + } + + @Test + void checkEnrichTouchesAllBuilderFields() + throws IllegalArgumentException, IllegalAccessException { + Capability mockingCapability = + test( + Feign.builder() + .requestInterceptor(template -> {}) + .responseInterceptor((ic, c) -> c.next(ic)), + 12); + + // make sure capability was invoked only once + verify(mockingCapability).enrich(any(Client.class)); } - private void test(BaseBuilder builder, int expectedFieldsCount) + private Capability test(BaseBuilder builder, int expectedFieldsCount) throws IllegalArgumentException, IllegalAccessException { - Capability mockingCapability = Mockito.mock(Capability.class, RETURNS_MOCKS); + Capability mockingCapability = mock(Capability.class, RETURNS_MOCKS); + doAnswer(inv -> inv.getArgument(0, BaseBuilder.class)) + .when(mockingCapability) + .beforeBuild(any(BaseBuilder.class)); + BaseBuilder enriched = builder.addCapability(mockingCapability).enrich(); List fields = enriched.getFieldsToEnrich(); @@ -56,15 +82,10 @@ private void test(BaseBuilder builder, int expectedFieldsCount) .isTrue(); assertNotSame(builder, enriched); } - } - @Test - void checkEnrichTouchesAllBuilderFields() - throws IllegalArgumentException, IllegalAccessException { - test( - Feign.builder() - .requestInterceptor(template -> {}) - .responseInterceptor((ic, c) -> c.next(ic)), - 12); + // make sure capability was invoked only once + verify(mockingCapability).beforeBuild(any(BaseBuilder.class)); + + return mockingCapability; } } diff --git a/oauth2/README.md b/oauth2/README.md new file mode 100644 index 0000000000..0531f0ca75 --- /dev/null +++ b/oauth2/README.md @@ -0,0 +1,88 @@ +# Feign OAuth2 + +This module extends **Feign** to enable client authentication using +[OAuth2](https://datatracker.ietf.org/doc/html/rfc6749) +and +[OIDC](https://openid.net/specs/openid-connect-core-1_0.html) +frameworks. + +It automatically authenticates the client against an **OAuth2/OpenID Connect (OIDC) Authorization Server** using +the `client_credentials` grant type. +Additionally, it manages **access token renewal** seamlessly. + +### Supported Authentication Methods + +- ✅ `client_secret_basic` (OAuth2) +- ✅ `client_secret_post` (OAuth2) +- ✅ `client_secret_jwt` (OIDC) +- ✅ `private_key_jwt` (OIDC) + +### 🛠️ Upcoming Features (Planned Support) + +- 🚀 `tls_client_auth` (RFC 8705) +- 🚀 `self_signed_tls_client_auth` (RFC 8705) + +### Compatibility + +Designed to work with most **OAuth2/OpenID Connect** providers. +Out-of-the-box support for: +- **AWS Cognito** +- **Okta Auth0** +- **Keycloak** + +## Installation + +### With Maven + +```xml + + ... + + io.github.openfeign + feign-oauth2 + + ... + +``` + +### With Gradle + +```groovy +compile group: 'io.github.openfeign', name: 'feign-oauth2' +``` + +## Usage + +Module provides `OAuth2Authentication` and `OpenIdAuthentication` generic capabilities, but also more specialized factory +classes: `AWSCognitoAuthentication`, `Auth0Authentication` and `KeycloakAuthentication`. + +Here an example how to create an authenticated REST client by using **OIDC Discovery** of **Keycloak**: + +```java +String issuer = String.format("http://keycloak:8080/realms/%s", ""); + +// Create an authentication +OpenIdAuthentication openIdAuthentication = OpenIdAuthentication.discover(ClientRegistration + .builder() + .credentials(Credentials + .builder() + .clientId("") + .clientSecret("") + .build()) + .providerDetails(ProviderDetails + .builder() + .issuerUri(issuer) + .build()) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .build()); + +IcecreamClient client = Feign + .builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .addCapability(openIdAuthentication) // <-- add authentication to the Feign client + .target(IcecreamClient.class, "http://localhost:5555"); + +// This call to the service will be authenticated +Collection mixins = client.getAvailableMixins(); +``` \ No newline at end of file diff --git a/oauth2/pom.xml b/oauth2/pom.xml new file mode 100644 index 0000000000..6f3ffadc00 --- /dev/null +++ b/oauth2/pom.xml @@ -0,0 +1,148 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 13.6-SNAPSHOT + + + feign-oauth2 + + Feign OAuth2 + Provides support for the Client role as defined in the OAuth 2.0 Authorization Framework. + + + 4.5.0 + + 3.4.0 + 0.3.5 + 1.20.4 + 3.6.0 + 3.1.0 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + + + + + + + + + io.github.openfeign + feign-core + + + + + com.auth0 + java-jwt + ${java-jwt.version} + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + io.github.openfeign + feign-jackson + test + + + + io.github.openfeign + feign-hc5 + test + + + + io.hosuaby + inject-resources-junit-jupiter + ${inject-resources.version} + test + + + + org.assertj + assertj-core + test + + + + org.testcontainers + junit-jupiter + test + + + + com.github.dasniko + testcontainers-keycloak + ${testcontainers-keycloak.version} + test + + + + io.github.cdimascio + dotenv-java + ${dotenv-java.version} + test + + + + + org.springframework.boot + spring-boot-starter-web + test + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + test + + + diff --git a/oauth2/src/main/java/feign/ConfidentialLogger.java b/oauth2/src/main/java/feign/ConfidentialLogger.java new file mode 100644 index 0000000000..0ca8726558 --- /dev/null +++ b/oauth2/src/main/java/feign/ConfidentialLogger.java @@ -0,0 +1,71 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public final class ConfidentialLogger extends Logger { + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final Pattern ACCESS_TOKEN_PATTERN = + Pattern.compile("^Bearer [A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+$"); + + private final Logger delegate; + + public ConfidentialLogger(final Logger delegate) { + this.delegate = delegate; + } + + @Override + protected void logRequest(final String configKey, final Level logLevel, final Request request) { + final Map> filteredHeaders = filterHeaders(request.headers()); + + final Request copyRequest = + Request.create( + request.httpMethod(), + request.url(), + filteredHeaders, + request.body(), + request.charset(), + request.requestTemplate()); + + super.logRequest(configKey, logLevel, copyRequest); + } + + @Override + protected void log(final String configKey, final String format, final Object... args) { + delegate.log(configKey, format, args); + } + + static Map> filterHeaders( + final Map> headers) { + final Map> filteredHeaders = new HashMap<>(headers); + filteredHeaders.computeIfPresent( + AUTHORIZATION_HEADER, + (ignored, values) -> + values.stream() + .map( + value -> + ACCESS_TOKEN_PATTERN.matcher(value).matches() + ? "Bearer " + : value) + .collect(Collectors.toList())); + return filteredHeaders; + } +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/AWSCognitoAuthentication.java b/oauth2/src/main/java/feign/auth/oauth2/AWSCognitoAuthentication.java new file mode 100644 index 0000000000..6f908c07bc --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/AWSCognitoAuthentication.java @@ -0,0 +1,68 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2; + +import feign.auth.oauth2.core.AuthorizationGrantType; +import feign.auth.oauth2.core.ClientAuthenticationMethod; +import feign.auth.oauth2.core.registration.ClientRegistration; +import feign.auth.oauth2.core.registration.Credentials; +import feign.auth.oauth2.core.registration.ProviderDetails; + +public final class AWSCognitoAuthentication extends OpenIdAuthentication { + private static final String COGNITO_HOST_TEMPLATE = "https://%s.auth.%s.amazoncognito.com"; + private static final String COGNITO_ISSUER_TEMPLATE = "https://cognito-idp.%s.amazonaws.com/%s"; + + public AWSCognitoAuthentication( + final String domain, final String region, final String clientId, final String clientSecret) { + super(clientRegistration(domain, region, clientId, clientSecret, null)); + } + + public AWSCognitoAuthentication( + final String domain, + final String region, + final String clientId, + final String clientSecret, + final String tenant) { + super(clientRegistration(domain, region, clientId, clientSecret, tenant)); + } + + private static ClientRegistration clientRegistration( + final String domain, + final String region, + final String clientId, + final String clientSecret, + final String tenant) { + final String cognitoHost = String.format(COGNITO_HOST_TEMPLATE, domain, region); + ProviderDetails.Builder detailsBuilder = + ProviderDetails.builder() + .authorizationUri(cognitoHost + "/oauth2/authorize") + .tokenUri(cognitoHost + "/oauth2/token") + .userInfoUri(cognitoHost + "/oauth2/userInfo"); + + if (tenant != null) { + final String issuer = String.format(COGNITO_ISSUER_TEMPLATE, region, tenant); + detailsBuilder = + detailsBuilder.issuerUri(issuer).jwkSetUri(issuer + "/.well-known/jwks.json"); + } + + return ClientRegistration.builder() + .credentials(Credentials.builder().clientId(clientId).clientSecret(clientSecret).build()) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .providerDetails(detailsBuilder.build()) + .build(); + } +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/Auth0Authentication.java b/oauth2/src/main/java/feign/auth/oauth2/Auth0Authentication.java new file mode 100644 index 0000000000..f999b55dc1 --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/Auth0Authentication.java @@ -0,0 +1,56 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2; + +import feign.auth.oauth2.core.AuthorizationGrantType; +import feign.auth.oauth2.core.ClientAuthenticationMethod; +import feign.auth.oauth2.core.registration.ClientRegistration; +import feign.auth.oauth2.core.registration.Credentials; +import feign.auth.oauth2.core.registration.ProviderDetails; + +public final class Auth0Authentication extends OpenIdAuthentication { + private static final String AUTH0_HOST_TEMPLATE = "https://%s.auth0.com"; + + public Auth0Authentication( + final String clientId, + final String clientSecret, + final String tenant, + final String audience) { + super(clientRegistration(clientId, clientSecret, tenant, audience)); + } + + private static ClientRegistration clientRegistration( + final String clientId, + final String clientSecret, + final String tenant, + final String audience) { + final String auth0Host = String.format(AUTH0_HOST_TEMPLATE, tenant); + + return ClientRegistration.builder() + .credentials(Credentials.builder().clientId(clientId).clientSecret(clientSecret).build()) + .audience(audience) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .providerDetails( + ProviderDetails.builder() + .authorizationUri(auth0Host + "/authorize") + .tokenUri(auth0Host + "/oauth/token") + .userInfoUri(auth0Host + "/userInfo") + .jwkSetUri(auth0Host + "/.well-known/jwks.json") + .build()) + .build(); + } +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/AuthenticationInterceptor.java b/oauth2/src/main/java/feign/auth/oauth2/AuthenticationInterceptor.java new file mode 100644 index 0000000000..d0402e1568 --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/AuthenticationInterceptor.java @@ -0,0 +1,33 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2; + +import feign.RequestInterceptor; +import feign.RequestTemplate; + +final class AuthenticationInterceptor implements RequestInterceptor { + private final OAuth2Authentication authentication; + + AuthenticationInterceptor(OAuth2Authentication authentication) { + this.authentication = authentication; + } + + @Override + public void apply(final RequestTemplate requestTemplate) { + final String accessToken = authentication.getAccessToken(); + requestTemplate.header("Authorization", "Bearer " + accessToken); + } +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/KeycloakAuthentication.java b/oauth2/src/main/java/feign/auth/oauth2/KeycloakAuthentication.java new file mode 100644 index 0000000000..f677f5c5ac --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/KeycloakAuthentication.java @@ -0,0 +1,122 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2; + +import com.auth0.jwt.algorithms.Algorithm; +import feign.auth.oauth2.core.AuthorizationGrantType; +import feign.auth.oauth2.core.ClientAuthenticationMethod; +import feign.auth.oauth2.core.registration.ClientRegistration; +import feign.auth.oauth2.core.registration.Credentials; +import feign.auth.oauth2.core.registration.ProviderDetails; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.interfaces.RSAPrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; + +public final class KeycloakAuthentication extends OpenIdAuthentication { + private KeycloakAuthentication(final ClientRegistration clientRegistration) { + super(clientRegistration); + } + + public static KeycloakAuthentication withClientSecretBasic( + final String host, final String realm, final String clientId, final String clientSecret) { + final ClientRegistration clientRegistration = + ClientRegistration.builder() + .credentials( + Credentials.builder().clientId(clientId).clientSecret(clientSecret).build()) + .providerDetails(providerDetails(host, realm)) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .build(); + return new KeycloakAuthentication(clientRegistration); + } + + public static KeycloakAuthentication withClientSecretPost( + final String host, final String realm, final String clientId, final String clientSecret) { + final ClientRegistration clientRegistration = + ClientRegistration.builder() + .credentials( + Credentials.builder().clientId(clientId).clientSecret(clientSecret).build()) + .providerDetails(providerDetails(host, realm)) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .build(); + return new KeycloakAuthentication(clientRegistration); + } + + public static KeycloakAuthentication withClientSecretJwt( + final String host, final String realm, final String clientId, final String clientSecret) { + final ClientRegistration clientRegistration = + ClientRegistration.builder() + .credentials( + Credentials.builder().clientId(clientId).clientSecret(clientSecret).build()) + .providerDetails(providerDetails(host, realm)) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .build(); + return new KeycloakAuthentication(clientRegistration); + } + + public static KeycloakAuthentication withPrivateKeyJwt( + final String host, final String realm, final String clientId, final String privateKeyBase64) { + final byte[] privateKey = Base64.getDecoder().decode(privateKeyBase64); + return withPrivateKeyJwt(host, realm, clientId, privateKey); + } + + public static KeycloakAuthentication withPrivateKeyJwt( + final String host, final String realm, final String clientId, final byte[] privateKey) { + Algorithm signingAlgorithm = null; + try { + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PKCS8EncodedKeySpec keySpecPKCS8 = new PKCS8EncodedKeySpec(privateKey); + PrivateKey privKey = keyFactory.generatePrivate(keySpecPKCS8); + signingAlgorithm = Algorithm.RSA256(null, (RSAPrivateKey) privKey); + } catch (final NoSuchAlgorithmException | InvalidKeySpecException encryptionException) { + throw new RuntimeException(encryptionException); + } + + final ClientRegistration clientRegistration = + ClientRegistration.builder() + .credentials( + Credentials.builder() + .clientId(clientId) + .jwtSignatureAlgorithm(signingAlgorithm) + .build()) + .providerDetails(providerDetails(host, realm)) + .clientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .build(); + return new KeycloakAuthentication(clientRegistration); + } + + private static ProviderDetails providerDetails(final String host, final String realm) { + final String issuer = issuer(host, realm); + return ProviderDetails.builder() + .issuerUri(issuer) + .authorizationUri(issuer + "/protocol/openid-connect/auth") + .tokenUri(issuer + "/protocol/openid-connect/token") + .userInfoUri(issuer + "/protocol/openid-connect/userinfo") + .jwkSetUri(issuer + "/protocol/openid-connect/certs") + .build(); + } + + private static String issuer(final String host, final String realm) { + return String.format("%s/realms/%s", host, realm); + } +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/OAuth2Authentication.java b/oauth2/src/main/java/feign/auth/oauth2/OAuth2Authentication.java new file mode 100644 index 0000000000..74fe8da905 --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/OAuth2Authentication.java @@ -0,0 +1,152 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2; + +import feign.*; +import feign.auth.oauth2.core.clients.OAuth2IDPClient; +import feign.auth.oauth2.core.registration.ClientRegistration; +import feign.auth.oauth2.core.responses.OAuth2TokenResponse; +import feign.codec.Decoder; +import feign.codec.ErrorDecoder; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.logging.Level; + +public class OAuth2Authentication implements Capability { + private static final java.util.logging.Logger JAVA_LOGGER = + java.util.logging.Logger.getLogger(OAuth2Authentication.class.getName()); + + protected ClientRegistration clientRegistration; + protected OAuth2IDPClient idpClient; + + protected AsyncClient httpClient; + protected Request.Options httpOptions; + protected Decoder jsonDecoder; + protected Logger logger; + private Retryer unauthorizedRetryer; + private ErrorDecoder unauthorizedErrorDecoder; + + private OAuth2TokenResponse oAuth2TokenResponse = null; + private Instant expiresAt = null; + + protected OAuth2Authentication(final ClientRegistration clientRegistration) { + this.clientRegistration = clientRegistration; + } + + @Override + public Client enrich(final Client client) { + this.httpClient = new AsyncClient.Pseudo<>(client); + return client; + } + + @Override + public AsyncClient enrich(final AsyncClient client) { + this.httpClient = client; + return client; + } + + @Override + public Request.Options enrich(final Request.Options options) { + this.httpOptions = options; + return options; + } + + @Override + public Decoder enrich(final Decoder decoder) { + this.jsonDecoder = decoder; + return decoder; + } + + @Override + public Logger enrich(final Logger logger) { + this.logger = new ConfidentialLogger(logger); + return this.logger; + } + + @Override + public Retryer enrich(final Retryer retryer) { + this.unauthorizedRetryer = new UnauthorizedRetryer(this, retryer); + return this.unauthorizedRetryer; + } + + @Override + public ErrorDecoder enrich(final ErrorDecoder decoder) { + this.unauthorizedErrorDecoder = new UnauthorizedErrorDecoder(decoder); + return this.unauthorizedErrorDecoder; + } + + @Override + public , T> B beforeBuild(final B baseBuilder) { + if (httpClient == null) { + throw new IllegalStateException("httpClient is missing"); + } + + if (httpOptions == null) { + throw new IllegalStateException("httpOptions is missing"); + } + + if (jsonDecoder == null) { + throw new IllegalStateException("jsonDecoder is missing"); + } + + idpClient = new OAuth2IDPClient(httpClient, httpOptions, jsonDecoder); + + baseBuilder.requestInterceptor(new AuthenticationInterceptor(this)); + + if (this.unauthorizedRetryer == null) { + baseBuilder.retryer(new UnauthorizedRetryer(this, null)); + } + + if (this.unauthorizedErrorDecoder == null) { + baseBuilder.errorDecoder(new UnauthorizedErrorDecoder(new ErrorDecoder.Default())); + } + + return baseBuilder; + } + + synchronized String getAccessToken() { + if (expiresAt != null && expiresAt.minus(10, ChronoUnit.SECONDS).isBefore(Instant.now())) { + // Access token is expired or about to expire + JAVA_LOGGER.log(Level.INFO, "Access token is about to be expired. Refreshing token."); + expiresAt = null; + oAuth2TokenResponse = null; + } + + if (oAuth2TokenResponse == null) { + return forceAuthentication(); + } + + return oAuth2TokenResponse.getAccessToken(); + } + + synchronized String forceAuthentication() { + JAVA_LOGGER.log(Level.INFO, "Perform authentication against IDP."); + + try { + oAuth2TokenResponse = + idpClient + .authenticateClient(clientRegistration) + .get(httpOptions.readTimeout(), httpOptions.readTimeoutUnit()); + } catch (final InterruptedException | ExecutionException | TimeoutException authException) { + throw new RuntimeException(authException); + } + + expiresAt = Instant.now().plus(oAuth2TokenResponse.getExpiresIn(), ChronoUnit.SECONDS); + return oAuth2TokenResponse.getAccessToken(); + } +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/OpenIdAuthentication.java b/oauth2/src/main/java/feign/auth/oauth2/OpenIdAuthentication.java new file mode 100644 index 0000000000..1896cdc8e3 --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/OpenIdAuthentication.java @@ -0,0 +1,78 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2; + +import feign.BaseBuilder; +import feign.auth.oauth2.core.clients.OpenIdProviderClient; +import feign.auth.oauth2.core.registration.ClientRegistration; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +public class OpenIdAuthentication extends OAuth2Authentication { + private final boolean discovery; + + protected OpenIdAuthentication(final ClientRegistration clientRegistration) { + this(clientRegistration, false); + } + + private OpenIdAuthentication( + final ClientRegistration clientRegistration, final boolean discovery) { + super(clientRegistration); + this.discovery = discovery; + } + + @Override + public , T> B beforeBuild(final B baseBuilder) { + final B builder = super.beforeBuild(baseBuilder); + + idpClient = new OpenIdProviderClient(httpClient, httpOptions, jsonDecoder); + if (discovery) { + try { + clientRegistration = + ((OpenIdProviderClient) idpClient) + .discover(clientRegistration) + .get(httpOptions.readTimeout(), httpOptions.readTimeoutUnit()); + } catch (final InterruptedException | ExecutionException | TimeoutException authException) { + throw new RuntimeException(authException); + } + } + + return builder; + } + + public static OpenIdAuthentication discover(final ClientRegistration clientRegistration) { + if (clientRegistration.getProviderDetails().getIssuerUri() == null + || clientRegistration.getProviderDetails().getIssuerUri().isEmpty()) { + throw new IllegalArgumentException("issuer cannot be empty"); + } + + if (clientRegistration.getCredentials().getClientId() == null + || clientRegistration.getCredentials().getClientId().isEmpty()) { + throw new IllegalArgumentException("clientId cannot be empty"); + } + + final boolean clientSecretPresent = + clientRegistration.getCredentials().getClientSecret() != null + && !clientRegistration.getCredentials().getClientSecret().isEmpty(); + if (!clientSecretPresent + && clientRegistration.getCredentials().getJwtSignatureAlgorithm() == null) { + throw new IllegalArgumentException( + "clientSecret of jwtSignatureAlgorithm should be provided"); + } + + return new OpenIdAuthentication(clientRegistration, true); + } +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/UnauthorizedErrorDecoder.java b/oauth2/src/main/java/feign/auth/oauth2/UnauthorizedErrorDecoder.java new file mode 100644 index 0000000000..0d14915cd9 --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/UnauthorizedErrorDecoder.java @@ -0,0 +1,43 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2; + +import feign.Response; +import feign.RetryableException; +import feign.codec.ErrorDecoder; + +final class UnauthorizedErrorDecoder implements ErrorDecoder { + private final ErrorDecoder delegate; + + UnauthorizedErrorDecoder(ErrorDecoder delegate) { + this.delegate = delegate; + } + + @Override + public Exception decode(final String methodKey, final Response response) { + // wrapper 401 to RetryableException in order to retry + if (response.status() == 401) { + return new RetryableException( + response.status(), + response.reason(), + response.request().httpMethod(), + (Long) null, + response.request()); + } + + return delegate.decode(methodKey, response); + } +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/UnauthorizedRetryer.java b/oauth2/src/main/java/feign/auth/oauth2/UnauthorizedRetryer.java new file mode 100644 index 0000000000..10c8a3aa61 --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/UnauthorizedRetryer.java @@ -0,0 +1,74 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2; + +import feign.RequestTemplate; +import feign.RetryableException; +import feign.Retryer; +import java.util.logging.Level; + +final class UnauthorizedRetryer implements Retryer { + private static final java.util.logging.Logger JAVA_LOGGER = + java.util.logging.Logger.getLogger(UnauthorizedRetryer.class.getName()); + + private final OAuth2Authentication authentication; + private final Retryer optionalDelegate; + private boolean reauthenticated = false; + + UnauthorizedRetryer( + final OAuth2Authentication authentication, /* Nullable */ final Retryer optionalDelegate) { + this.authentication = authentication; + this.optionalDelegate = optionalDelegate; + } + + @Override + public void continueOrPropagate(final RetryableException unauthorizedException) { + if (unauthorizedException.status() != 401) { + if (optionalDelegate != null) { + optionalDelegate.continueOrPropagate(unauthorizedException); + } else { + throw unauthorizedException; + } + } + + if (reauthenticated) { + JAVA_LOGGER.log( + Level.WARNING, + "Client still unauthorized event after access token was updated. Fail request."); + if (optionalDelegate != null) { + optionalDelegate.continueOrPropagate(unauthorizedException); + } else { + throw unauthorizedException; + } + } + + JAVA_LOGGER.log( + Level.INFO, "Request was unauthorized by Resource Server. Refresh access token."); + final String accessToken = authentication.forceAuthentication(); + + final RequestTemplate requestTemplate = unauthorizedException.request().requestTemplate(); + requestTemplate.removeHeader("Authorization"); + requestTemplate.header("Authorization", "Bearer " + accessToken); + + reauthenticated = true; + } + + @Override + public Retryer clone() { + return new UnauthorizedRetryer( + authentication, optionalDelegate != null ? optionalDelegate.clone() : null); + } +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/core/AuthenticationFramework.java b/oauth2/src/main/java/feign/auth/oauth2/core/AuthenticationFramework.java new file mode 100644 index 0000000000..fe650c1ce9 --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/core/AuthenticationFramework.java @@ -0,0 +1,22 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.core; + +public enum AuthenticationFramework { + OAUTH2, + OAUTH2_RFC8705, + OIDC +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/core/AuthorizationGrantType.java b/oauth2/src/main/java/feign/auth/oauth2/core/AuthorizationGrantType.java new file mode 100644 index 0000000000..8b27d8d363 --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/core/AuthorizationGrantType.java @@ -0,0 +1,44 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.core; + +/** + * An authorization grant is a credential representing the resource owner's authorization (to access + * it's protected resources) to the client and used by the client to obtain an access token. + * + *

The OAuth 2.0 Authorization Framework defines four standard grant types: authorization code, + * resource owner password credentials, and client credentials. It also provides an extensibility + * mechanism for defining additional grant types. + * + * @author Alexei KLENIN + * @see Section 1.3 + * Authorization Grant + */ +public enum AuthorizationGrantType { + AUTHORIZATION_CODE("authorization_code"), + REFRESH_TOKEN("refresh_token"), + CLIENT_CREDENTIALS("client_credentials"), + PASSWORD("password"), + JWT_BEARER("urn:ietf:params:oauth:grant-type:jwt-bearer"), + DEVICE_CODE("urn:ietf:params:oauth:grant-type:device_code"), + TOKEN_EXCHANGE("urn:ietf:params:oauth:grant-type:token-exchange"); + + private final String value; + + AuthorizationGrantType(String value) { + this.value = value; + } +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/core/ClientAuthenticationMethod.java b/oauth2/src/main/java/feign/auth/oauth2/core/ClientAuthenticationMethod.java new file mode 100644 index 0000000000..823ab6401f --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/core/ClientAuthenticationMethod.java @@ -0,0 +1,78 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.core; + +import static feign.auth.oauth2.core.AuthenticationFramework.*; + +import java.util.stream.Stream; + +/** + * The authentication method used when authenticating the client with the authorization server in + * OAuth2 and OIDC frameworks. + * + * @author Alexei KLENIN + * @see Section 2.3 Client + * Authentication + * @see Section + * 9 Client Authentication + * @see RFC 8705 - OAuth 2.0 Mutual TLS + * Client Authentication and Certificate-Bound Access Tokens. + */ +public enum ClientAuthenticationMethod { + CLIENT_SECRET_BASIC(OAUTH2, "client_secret_basic", 2), + CLIENT_SECRET_POST(OAUTH2, "client_secret_post", 1), + CLIENT_SECRET_JWT(OIDC, "client_secret_jwt", 3), + PRIVATE_KEY_JWT(OIDC, "private_key_jwt", 4), + TLS_CLIENT_AUTH(OAUTH2_RFC8705, "tls_client_auth", 6), + SELF_SIGNED_TLS_CLIENT_AUTH(OAUTH2_RFC8705, "self_signed_tls_client_auth", 5), + NONE(OIDC, "none", 0); + + private final AuthenticationFramework framework; + private final String value; + private final int securityLevel; // the higher, the better + + public AuthenticationFramework getFramework() { + return framework; + } + + public int getSecurityLevel() { + return securityLevel; + } + + public String getValue() { + return value; + } + + ClientAuthenticationMethod( + final AuthenticationFramework framework, final String value, final int securityLevel) { + this.framework = framework; + this.value = value; + this.securityLevel = securityLevel; + } + + public static ClientAuthenticationMethod parse(final String str) { + if (str == null || str.isEmpty()) { + throw new IllegalArgumentException("value cannot be empty"); + } + + return Stream.of(values()) + .filter(value -> value.value.equals(str)) + .findAny() + .orElseThrow( + () -> new IllegalArgumentException(String.format("Unsupported value %s", str))); + } +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/core/clients/OAuth2IDPClient.java b/oauth2/src/main/java/feign/auth/oauth2/core/clients/OAuth2IDPClient.java new file mode 100644 index 0000000000..673693b542 --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/core/clients/OAuth2IDPClient.java @@ -0,0 +1,117 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.core.clients; + +import static feign.auth.oauth2.core.AuthenticationFramework.OAUTH2; + +import feign.AsyncClient; +import feign.FeignException; +import feign.Request; +import feign.auth.oauth2.core.ClientAuthenticationMethod; +import feign.auth.oauth2.core.registration.ClientRegistration; +import feign.auth.oauth2.core.responses.OAuth2TokenResponse; +import feign.codec.Decoder; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +public class OAuth2IDPClient { + protected final AsyncClient client; + protected final Request.Options options; + protected final Decoder jsonDecoder; + + public OAuth2IDPClient( + final AsyncClient client, final Request.Options options, final Decoder jsonDecoder) { + this.client = client; + this.options = options; + this.jsonDecoder = jsonDecoder; + } + + public CompletableFuture authenticateClient( + final ClientRegistration clientRegistration) { + if (clientRegistration.getClientAuthenticationMethod().getFramework() != OAUTH2) { + throw new IllegalArgumentException( + String.format( + "Authentication method %s is not supported by OAuth2 client.", + clientRegistration.getClientAuthenticationMethod())); + } + + final Request request = clientSecretRequest(clientRegistration); + return executeRequest(request); + } + + protected CompletableFuture executeRequest(final Request request) { + return client + .execute(request, options, Optional.empty()) + .thenApply( + response -> { + try { + if (response.status() >= 200 && response.status() < 300) { + @SuppressWarnings("unchecked") + final Map jsonResponse = + (Map) jsonDecoder.decode(response, Map.class); + return OAuth2TokenResponse.fromMap(jsonResponse); + } else { + throw FeignException.errorStatus("authentication", response, 4000, 2000); + } + } catch (final IOException networkError) { + throw new RuntimeException(networkError); + } + }); + } + + protected static Request clientSecretRequest(final ClientRegistration clientRegistration) { + final Map> headers = new HashMap<>(); + headers.put("Content-Type", Collections.singleton("application/x-www-form-urlencoded")); + + if (clientRegistration.getClientAuthenticationMethod() + == ClientAuthenticationMethod.CLIENT_SECRET_BASIC) { + headers.put( + "Authorization", + Collections.singleton(clientRegistration.getCredentials().basicHeader())); + } + + String body = "grant_type=client_credentials"; + + if (clientRegistration.getAudience() != null && !clientRegistration.getAudience().isEmpty()) { + body += "&audience=" + clientRegistration.getAudience(); + } + + if (clientRegistration.getClientAuthenticationMethod() + == ClientAuthenticationMethod.CLIENT_SECRET_POST) { + body += + String.format( + "&client_id=%s&client_secret=%s", + clientRegistration.getCredentials().getClientId(), + clientRegistration.getCredentials().getClientSecret()); + } + + body += + clientRegistration.getExtraParameters().entrySet().stream() + .map(entry -> String.format("&%s=%s", entry.getKey(), entry.getValue())) + .collect(Collectors.joining()); + + return Request.create( + Request.HttpMethod.POST, + clientRegistration.getProviderDetails().getTokenUri(), + headers, + body.getBytes(), + StandardCharsets.UTF_8, + null); + } +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/core/clients/OpenIdProviderClient.java b/oauth2/src/main/java/feign/auth/oauth2/core/clients/OpenIdProviderClient.java new file mode 100644 index 0000000000..ab7dedc9a4 --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/core/clients/OpenIdProviderClient.java @@ -0,0 +1,218 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.core.clients; + +import static feign.auth.oauth2.core.AuthenticationFramework.OAUTH2; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTCreator; +import com.auth0.jwt.algorithms.Algorithm; +import feign.AsyncClient; +import feign.FeignException; +import feign.Request; +import feign.Util; +import feign.auth.oauth2.core.ClientAuthenticationMethod; +import feign.auth.oauth2.core.registration.ClientRegistration; +import feign.auth.oauth2.core.registration.ProviderDetails; +import feign.auth.oauth2.core.responses.OAuth2TokenResponse; +import feign.auth.oauth2.core.responses.OpenIdProviderConfigurationResponse; +import feign.codec.Decoder; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.CompletableFuture; + +public class OpenIdProviderClient extends OAuth2IDPClient { + private static final String CONFIGURATION_PATH = "/.well-known/openid-configuration"; + + public OpenIdProviderClient( + final AsyncClient client, final Request.Options options, final Decoder jsonDecoder) { + super(client, options, jsonDecoder); + } + + public CompletableFuture discover( + final ClientRegistration clientRegistration) { + final Request request = + Request.create( + Request.HttpMethod.GET, + clientRegistration.getProviderDetails().getIssuerUri() + CONFIGURATION_PATH, + Collections.emptyMap(), + null, + Util.UTF_8, + null); + + return client + .execute(request, options, Optional.empty()) + .thenApply( + response -> { + try { + if (response.status() >= 200 && response.status() < 300) { + @SuppressWarnings("unchecked") + final Map jsonResponse = + (Map) jsonDecoder.decode(response, Map.class); + final OpenIdProviderConfigurationResponse configuration = + OpenIdProviderConfigurationResponse.fromMap(jsonResponse); + validateIssuer(clientRegistration, configuration); + return toClientRegistration(clientRegistration, configuration); + } else { + final byte[] bytes = Util.toByteArray(response.body().asInputStream()); + final String errMessage = new String(bytes, StandardCharsets.UTF_8); + throw new FeignException.FeignClientException( + response.status(), + errMessage, + request, + new byte[] {}, + Collections.emptyMap()); + } + } catch (final IOException networkError) { + throw new RuntimeException(networkError); + } + }); + } + + @Override + public CompletableFuture authenticateClient( + final ClientRegistration clientRegistration) { + if (clientRegistration.getClientAuthenticationMethod().getFramework() == OAUTH2) { + return super.authenticateClient(clientRegistration); + } + + Request request; + + switch (clientRegistration.getClientAuthenticationMethod()) { + case CLIENT_SECRET_JWT: + request = clientSecretJwt(clientRegistration); + break; + case PRIVATE_KEY_JWT: + request = privateKeyJwtRequest(clientRegistration); + break; + default: + throw new UnsupportedOperationException( + String.format( + "Authentication method %s is not yet supported.", + clientRegistration.getClientAuthenticationMethod())); + } + + return executeRequest(request); + } + + private static ClientRegistration toClientRegistration( + final ClientRegistration clientRegistration, + final OpenIdProviderConfigurationResponse openIdConfiguration) { + ClientAuthenticationMethod authenticationMethod = + clientRegistration.getClientAuthenticationMethod(); + if (authenticationMethod == null) { + authenticationMethod = + openIdConfiguration.getClientAuthenticationMethods().stream() + .max(Comparator.comparing(ClientAuthenticationMethod::getValue)) + .orElse(ClientAuthenticationMethod.NONE); + } + + return ClientRegistration.builder() + .credentials(clientRegistration.getCredentials()) + .audience(clientRegistration.getAudience()) + .scopes(openIdConfiguration.getScopesSupported()) + .clientAuthenticationMethod(authenticationMethod) + .extraParameters(clientRegistration.getExtraParameters()) + .providerDetails( + ProviderDetails.builder() + .authorizationUri(openIdConfiguration.getAuthorizationEndpoint()) + .tokenUri(openIdConfiguration.getTokenEndpoint()) + .userInfoUri(openIdConfiguration.getUserinfoEndpoint()) + .jwkSetUri(openIdConfiguration.getJwksUri()) + .issuerUri(openIdConfiguration.getIssuer()) + .build()) + .build(); + } + + private static Request clientSecretJwt(final ClientRegistration clientRegistration) { + if (clientRegistration.getCredentials().getClientSecret() == null) { + throw new IllegalArgumentException( + "No client secret provided. " + + "Client secret is required when client_secret_jwt authentication is used."); + } + + final Algorithm signingAlgorithm = + Algorithm.HMAC256(clientRegistration.getCredentials().getClientSecret()); + return clientJwtRequest(clientRegistration, signingAlgorithm); + } + + private static Request privateKeyJwtRequest(final ClientRegistration clientRegistration) { + if (clientRegistration.getCredentials().getJwtSignatureAlgorithm() == null) { + throw new IllegalArgumentException( + "No signature algorithm provided. " + + "Signature algorithm is required when private_key_jwt authentication is used."); + } + + return clientJwtRequest( + clientRegistration, clientRegistration.getCredentials().getJwtSignatureAlgorithm()); + } + + private static Request clientJwtRequest( + final ClientRegistration clientRegistration, final Algorithm signingAlgorithm) { + final Map> headers = new HashMap<>(); + headers.put("Content-Type", Collections.singleton("application/x-www-form-urlencoded")); + + final String jwt = createJwt(clientRegistration).sign(signingAlgorithm); + final String body = + "grant_type=client_credentials" + + "&client_id=" + + clientRegistration.getCredentials().getClientId() + + "&audience=" + + clientRegistration.getProviderDetails().getTokenUri() + + "&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + + "&client_assertion=" + + jwt; + + return Request.create( + Request.HttpMethod.POST, + clientRegistration.getProviderDetails().getTokenUri(), + headers, + body.getBytes(), + StandardCharsets.UTF_8, + null); + } + + private static JWTCreator.Builder createJwt(final ClientRegistration clientRegistration) { + return JWT.create() + .withIssuer(clientRegistration.getCredentials().getClientId()) + .withSubject(clientRegistration.getCredentials().getClientId()) + .withAudience(clientRegistration.getProviderDetails().getTokenUri()) + .withIssuedAt(new Date()) + .withExpiresAt(new Date(System.currentTimeMillis() + 60 * 1000)) // 1 minute expiration + .withJWTId(UUID.randomUUID().toString()); // Unique identifier + } + + /** + * Prevent impersonation attack + * + * @see https://openid.net/specs/openid-connect-discovery-1_0.html#Impersonation + */ + private static void validateIssuer( + final ClientRegistration clientRegistration, + final OpenIdProviderConfigurationResponse configuration) { + final String originalIssuer = + clientRegistration.getProviderDetails().getIssuerUri().replaceAll("/+$", ""); + final String configurationIssuer = configuration.getIssuer().replaceAll("/+$", ""); + + if (!originalIssuer.equals(configurationIssuer)) { + throw new IllegalStateException( + String.format( + "Issuer in request mismatch issuer in configuration response.\nOriginal: %s\nConfiguration: %s", + originalIssuer, configurationIssuer)); + } + } +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/core/registration/ClientRegistration.java b/oauth2/src/main/java/feign/auth/oauth2/core/registration/ClientRegistration.java new file mode 100644 index 0000000000..fa800fb6b2 --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/core/registration/ClientRegistration.java @@ -0,0 +1,169 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.core.registration; + +import feign.auth.oauth2.core.AuthorizationGrantType; +import feign.auth.oauth2.core.ClientAuthenticationMethod; +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +/** + * A representation of a client registration with an OAuth 2.0 or OpenID Connect 1.0 Provider. + * + * @author Alexei KLENIN + * @see Section 2 Client + * Registration + */ +public final class ClientRegistration { + private final String registrationId; + private final Credentials credentials; + private final String audience; + private final ClientAuthenticationMethod clientAuthenticationMethod; + private final AuthorizationGrantType authorizationGrantType; + private final String redirectUri; + private final Set scopes; + private final ProviderDetails providerDetails; + private final String clientName; + private final Map extraParameters; + + public AuthorizationGrantType getAuthorizationGrantType() { + return authorizationGrantType; + } + + public ClientAuthenticationMethod getClientAuthenticationMethod() { + return clientAuthenticationMethod; + } + + public Credentials getCredentials() { + return credentials; + } + + public String getClientName() { + return clientName; + } + + public String getAudience() { + return audience; + } + + public ProviderDetails getProviderDetails() { + return providerDetails; + } + + public String getRedirectUri() { + return redirectUri; + } + + public String getRegistrationId() { + return registrationId; + } + + public Set getScopes() { + return scopes; + } + + public Map getExtraParameters() { + return extraParameters; + } + + private ClientRegistration(Builder builder) { + registrationId = builder.registrationId; + credentials = builder.credentials; + audience = builder.audience; + clientAuthenticationMethod = builder.clientAuthenticationMethod; + authorizationGrantType = builder.authorizationGrantType; + redirectUri = builder.redirectUri; + scopes = builder.scopes; + providerDetails = builder.providerDetails; + clientName = builder.clientName; + extraParameters = builder.extraParameters; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String registrationId; + private Credentials credentials; + private String audience; + private ClientAuthenticationMethod clientAuthenticationMethod; + private AuthorizationGrantType authorizationGrantType; + private String redirectUri; + private Set scopes = Collections.emptySet(); + private ProviderDetails providerDetails; + private String clientName; + private Map extraParameters = Collections.emptyMap(); + + private Builder() {} + + public Builder registrationId(final String registrationId) { + this.registrationId = registrationId; + return this; + } + + public Builder credentials(final Credentials credentials) { + this.credentials = credentials; + return this; + } + + public Builder audience(final String audience) { + this.audience = audience; + return this; + } + + public Builder clientAuthenticationMethod( + final ClientAuthenticationMethod clientAuthenticationMethod) { + this.clientAuthenticationMethod = clientAuthenticationMethod; + return this; + } + + public Builder authorizationGrantType(final AuthorizationGrantType authorizationGrantType) { + this.authorizationGrantType = authorizationGrantType; + return this; + } + + public Builder redirectUri(final String redirectUri) { + this.redirectUri = redirectUri; + return this; + } + + public Builder scopes(final Set scopes) { + this.scopes = scopes; + return this; + } + + public Builder providerDetails(final ProviderDetails providerDetails) { + this.providerDetails = providerDetails; + return this; + } + + public Builder clientName(final String clientName) { + this.clientName = clientName; + return this; + } + + public Builder extraParameters(final Map extraParameters) { + this.extraParameters = extraParameters; + return this; + } + + public ClientRegistration build() { + return new ClientRegistration(this); + } + } +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/core/registration/Credentials.java b/oauth2/src/main/java/feign/auth/oauth2/core/registration/Credentials.java new file mode 100644 index 0000000000..4fdb28f69b --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/core/registration/Credentials.java @@ -0,0 +1,79 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.core.registration; + +import com.auth0.jwt.algorithms.Algorithm; +import java.util.Base64; + +public final class Credentials { + private final String clientId; + private final String clientSecret; + private final Algorithm jwtSignatureAlgorithm; + + private Credentials(Builder builder) { + clientId = builder.clientId; + clientSecret = builder.clientSecret; + jwtSignatureAlgorithm = builder.jwtSignatureAlgorithm; + } + + public static Builder builder() { + return new Builder(); + } + + public String basicHeader() { + final String credentials = clientId + ':' + clientSecret; + return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes()); + } + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public Algorithm getJwtSignatureAlgorithm() { + return jwtSignatureAlgorithm; + } + + public static class Builder { + private String clientId; + private String clientSecret; + private Algorithm jwtSignatureAlgorithm; + + private Builder() {} + + public Builder clientId(final String clientId) { + this.clientId = clientId; + return this; + } + + public Builder clientSecret(final String clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + public Builder jwtSignatureAlgorithm(final Algorithm jwtSignatureAlgorithm) { + this.jwtSignatureAlgorithm = jwtSignatureAlgorithm; + return this; + } + + public Credentials build() { + return new Credentials(this); + } + } +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/core/registration/ProviderDetails.java b/oauth2/src/main/java/feign/auth/oauth2/core/registration/ProviderDetails.java new file mode 100644 index 0000000000..e53d65d1a9 --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/core/registration/ProviderDetails.java @@ -0,0 +1,110 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.core.registration; + +import java.util.Collections; +import java.util.Map; + +public final class ProviderDetails { + private final String authorizationUri; + private final String tokenUri; + private final String userInfoUri; + private final String jwkSetUri; + private final String issuerUri; + private final Map configurationMetadata; + + public String getAuthorizationUri() { + return authorizationUri; + } + + public Map getConfigurationMetadata() { + return configurationMetadata; + } + + public String getIssuerUri() { + return issuerUri; + } + + public String getJwkSetUri() { + return jwkSetUri; + } + + public String getTokenUri() { + return tokenUri; + } + + public String getUserInfoUri() { + return userInfoUri; + } + + private ProviderDetails(Builder builder) { + authorizationUri = builder.authorizationUri; + tokenUri = builder.tokenUri; + userInfoUri = builder.userInfoUri; + jwkSetUri = builder.jwkSetUri; + issuerUri = builder.issuerUri; + configurationMetadata = builder.configurationMetadata; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String authorizationUri; + private String tokenUri; + private String userInfoUri; + private String jwkSetUri; + private String issuerUri; + private Map configurationMetadata = Collections.emptyMap(); + + private Builder() {} + + public Builder authorizationUri(final String authorizationUri) { + this.authorizationUri = authorizationUri; + return this; + } + + public Builder tokenUri(final String tokenUri) { + this.tokenUri = tokenUri; + return this; + } + + public Builder userInfoUri(final String userInfoUri) { + this.userInfoUri = userInfoUri; + return this; + } + + public Builder jwkSetUri(final String jwkSetUri) { + this.jwkSetUri = jwkSetUri; + return this; + } + + public Builder issuerUri(final String issuerUri) { + this.issuerUri = issuerUri; + return this; + } + + public Builder configurationMetadata(final Map configurationMetadata) { + this.configurationMetadata = configurationMetadata; + return this; + } + + public ProviderDetails build() { + return new ProviderDetails(this); + } + } +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/core/responses/OAuth2TokenResponse.java b/oauth2/src/main/java/feign/auth/oauth2/core/responses/OAuth2TokenResponse.java new file mode 100644 index 0000000000..9eb01fa4ca --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/core/responses/OAuth2TokenResponse.java @@ -0,0 +1,78 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.core.responses; + +import java.util.Map; +import java.util.Optional; + +public final class OAuth2TokenResponse { + private String accessToken; + private String tokenType; + private int expiresIn; + private String refreshToken; + private String scope; + + public OAuth2TokenResponse( + final String accessToken, + final String tokenType, + final int expiresIn, + final String refreshToken, + final String scope) { + if (accessToken == null || accessToken.isEmpty()) { + throw new IllegalArgumentException("accessToken cannot be empty"); + } + + if (tokenType == null || tokenType.isEmpty()) { + throw new IllegalArgumentException("tokenType cannot be empty"); + } + + this.accessToken = accessToken; + this.tokenType = tokenType; + this.expiresIn = expiresIn; + this.refreshToken = refreshToken; + this.scope = scope; + } + + public String getAccessToken() { + return accessToken; + } + + public String getTokenType() { + return tokenType; + } + + public int getExpiresIn() { + return expiresIn; + } + + public Optional getRefreshToken() { + return Optional.ofNullable(refreshToken); + } + + public Optional getScope() { + return Optional.ofNullable(scope); + } + + public static OAuth2TokenResponse fromMap(final Map map) { + final String accessToken = (String) map.get("access_token"); + final String tokenType = (String) map.get("token_type"); + final int expiresIn = (Integer) map.get("expires_in"); + final String refreshToken = (String) map.get("refresh_token"); + final String scope = (String) map.get("scope"); + + return new OAuth2TokenResponse(accessToken, tokenType, expiresIn, refreshToken, scope); + } +} diff --git a/oauth2/src/main/java/feign/auth/oauth2/core/responses/OpenIdProviderConfigurationResponse.java b/oauth2/src/main/java/feign/auth/oauth2/core/responses/OpenIdProviderConfigurationResponse.java new file mode 100644 index 0000000000..1765460861 --- /dev/null +++ b/oauth2/src/main/java/feign/auth/oauth2/core/responses/OpenIdProviderConfigurationResponse.java @@ -0,0 +1,157 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.core.responses; + +import feign.auth.oauth2.core.ClientAuthenticationMethod; +import java.util.*; +import java.util.stream.Collectors; + +/** + * The response from OIDC Discovery endpoint. + * + * @author Alexei KLENIN + * @see Section + * 4.2. OpenID Provider Configuration Response + */ +public final class OpenIdProviderConfigurationResponse { + private final String issuer; + private final String authorizationEndpoint; + private final String tokenEndpoint; + private final String userinfoEndpoint; + private final Set clientAuthenticationMethods; + private final String jwksUri; + private final Set scopesSupported; + + private OpenIdProviderConfigurationResponse(Builder builder) { + issuer = builder.issuer; + authorizationEndpoint = builder.authorizationEndpoint; + tokenEndpoint = builder.tokenEndpoint; + userinfoEndpoint = builder.userinfoEndpoint; + clientAuthenticationMethods = builder.clientAuthenticationMethods; + jwksUri = builder.jwksUri; + scopesSupported = builder.scopesSupported; + } + + public String getAuthorizationEndpoint() { + return authorizationEndpoint; + } + + public Set getClientAuthenticationMethods() { + return clientAuthenticationMethods; + } + + public String getIssuer() { + return issuer; + } + + public String getJwksUri() { + return jwksUri; + } + + public Set getScopesSupported() { + return scopesSupported; + } + + public String getTokenEndpoint() { + return tokenEndpoint; + } + + public String getUserinfoEndpoint() { + return userinfoEndpoint; + } + + public static Builder builder() { + return new Builder(); + } + + public static OpenIdProviderConfigurationResponse fromMap(final Map map) { + final String issuer = (String) map.get("issuer"); + final String authorizationEndpoint = (String) map.get("authorization_endpoint"); + final String tokenEndpoint = (String) map.get("token_endpoint"); + final String userinfoEndpoint = (String) map.get("userinfo_endpoint"); + final String jwksUri = (String) map.get("jwks_uri"); + + final Set clientAuthenticationMethods = + ((Collection) map.get("token_endpoint_auth_methods_supported")) + .stream().map(ClientAuthenticationMethod::parse).collect(Collectors.toSet()); + + final Set scopesSupported = + new HashSet<>((Collection) map.get("scopes_supported")); + + return OpenIdProviderConfigurationResponse.builder() + .issuer(issuer) + .authorizationEndpoint(authorizationEndpoint) + .tokenEndpoint(tokenEndpoint) + .userinfoEndpoint(userinfoEndpoint) + .jwksUri(jwksUri) + .clientAuthenticationMethods(clientAuthenticationMethods) + .scopesSupported(scopesSupported) + .build(); + } + + public static class Builder { + private String issuer; + private String authorizationEndpoint; + private String tokenEndpoint; + private String userinfoEndpoint; + private Set clientAuthenticationMethods = Collections.emptySet(); + private String jwksUri; + private Set scopesSupported = Collections.emptySet(); + + private Builder() {} + + public Builder issuer(final String issuer) { + this.issuer = issuer; + return this; + } + + public Builder authorizationEndpoint(final String authorizationEndpoint) { + this.authorizationEndpoint = authorizationEndpoint; + return this; + } + + public Builder tokenEndpoint(final String tokenEndpoint) { + this.tokenEndpoint = tokenEndpoint; + return this; + } + + public Builder userinfoEndpoint(final String userinfoEndpoint) { + this.userinfoEndpoint = userinfoEndpoint; + return this; + } + + public Builder clientAuthenticationMethods( + final Set clientAuthenticationMethods) { + this.clientAuthenticationMethods = clientAuthenticationMethods; + return this; + } + + public Builder jwksUri(final String jwksUri) { + this.jwksUri = jwksUri; + return this; + } + + public Builder scopesSupported(final Set scopesSupported) { + this.scopesSupported = scopesSupported; + return this; + } + + public OpenIdProviderConfigurationResponse build() { + return new OpenIdProviderConfigurationResponse(this); + } + } +} diff --git a/oauth2/src/test/java/feign/ConfidentialLoggerTest.java b/oauth2/src/test/java/feign/ConfidentialLoggerTest.java new file mode 100644 index 0000000000..bc4b97364c --- /dev/null +++ b/oauth2/src/test/java/feign/ConfidentialLoggerTest.java @@ -0,0 +1,48 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import org.junit.jupiter.api.Test; + +public class ConfidentialLoggerTest { + + @Test + void testFilterHeaders() { + + /* Given */ + Map> headers = + Collections.singletonMap( + "Authorization", + Collections.singleton( + "Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJ0dlhpald3X0tWSnZHY1B5N25mUlg4SjBpRDcxSTZEUWVFR0VjczJwbWxRIn0.eyJleHAiOjE3NDA1MDA0MzgsImlhdCI6MTc0MDUwMDQyOCwianRpIjoiMzljOTVhNzAtNDk4Yi00MWFjLTlmMTctOTA3Yjk4MmIzNDNkIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDozMjc5MC9yZWFsbXMvbWFzdGVyIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImMzZjU5ZjA3LWE4NTItNGY1Ny1hZjEwLTI0OGJjNjg4YWMxYSIsInR5cCI6IkJlYXJlciIsImF6cCI6ImZlaWduLWNsaWVudC1zZWNyZXQiLCJhY3IiOiIxIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtbWFzdGVyIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoicHJvZmlsZSBlbWFpbCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiY2xpZW50SG9zdCI6IjE3Mi4xNy4wLjEiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzZXJ2aWNlLWFjY291bnQtZmVpZ24tY2xpZW50LXNlY3JldCIsImNsaWVudEFkZHJlc3MiOiIxNzIuMTcuMC4xIiwiY2xpZW50X2lkIjoiZmVpZ24tY2xpZW50LXNlY3JldCJ9.mcRl0ex7p4bPd-Kk1KwJoFOWYfkxwtEmAO9X9kdgGq4iCY6UUWGINqYXwI_D0QObclJ9J2ka9qCxo225MfV-zmza60IC3w6tfgsm7mnEZgec47GSoQjUqTLna4pDGdLq4c9QIedzkrhLqI9_qJi1V6iGYd6CNb6Y1u0G0QBoLejzHGVf5avxrlrRHTkGMUvphe7N0WAq5N9JjFrB6pqFsL1a9gMBkyThM6SpOwe1O2rXA07J7IgcL50AHU-4MxXRroz779GYObhm7o9RY7iPgs0BlBjVKxj75R8R57YNJo0LEPqBuCn5tAD7VJRPgCrM91Jfdv4X7mrg39JIndsyAw")); + + /* When */ + Map> filteredHeaders = ConfidentialLogger.filterHeaders(headers); + + /* Then */ + assertThat(filteredHeaders).isNotNull().isNotEmpty().hasSize(1).containsKey("Authorization"); + assertThat(filteredHeaders.get("Authorization")) + .isNotNull() + .isNotEmpty() + .hasSize(1) + .contains("Bearer "); + } +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/AWSCognitoAuthenticationTest.java b/oauth2/src/test/java/feign/auth/oauth2/AWSCognitoAuthenticationTest.java new file mode 100644 index 0000000000..9b8ab0540c --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/AWSCognitoAuthenticationTest.java @@ -0,0 +1,105 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2; + +import static feign.auth.oauth2.support.EnvTestConditions.ENV; +import static org.assertj.core.api.Assertions.assertThat; + +import feign.AsyncFeign; +import feign.Feign; +import feign.auth.oauth2.core.registration.ClientRegistration; +import feign.auth.oauth2.core.registration.Credentials; +import feign.auth.oauth2.core.registration.ProviderDetails; +import feign.auth.oauth2.mock.IcecreamClient; +import feign.auth.oauth2.mock.domain.Mixin; +import feign.hc5.AsyncApacheHttp5Client; +import feign.jackson.JacksonDecoder; +import feign.jackson.JacksonEncoder; +import java.util.Collection; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +@EnabledIf("feign.auth.oauth2.support.EnvTestConditions#testsWithCognitoEnabled") +public class AWSCognitoAuthenticationTest extends AbstractAuthenticationTest { + + @DynamicPropertySource + static void dynamicProperties(DynamicPropertyRegistry registry) { + registry.add( + "spring.security.oauth2.resourceserver.jwt.issuer-uri", () -> ENV.get("AWS_ISSUER_URI")); + registry.add( + "spring.security.oauth2.resourceserver.jwt.jwk-set-uri", () -> ENV.get("AWS_JWK_SET_URI")); + } + + @Test + void testWithCognito() { + AWSCognitoAuthentication awsCognitoAuthentication = + new AWSCognitoAuthentication( + ENV.get("AWS_DOMAIN"), + ENV.get("AWS_REGION"), + ENV.get("AWS_CLIENT_ID"), + ENV.get("AWS_CLIENT_SECRET")); + + IcecreamClient client = + AsyncFeign.builder() + .client(new AsyncApacheHttp5Client()) + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .addCapability(awsCognitoAuthentication) + .target(IcecreamClient.class, "http://localhost:" + randomServerPort); + + Collection mixins = client.getAvailableMixins(); + assertThat(mixins) + .isNotNull() + .isNotEmpty() + .hasSize(6) + .containsExactlyInAnyOrder(Mixin.values()); + } + + @Test + void testWithCognitoDiscovery() { + String issuer = + String.format( + "https://cognito-idp.%s.amazonaws.com/%s", + ENV.get("AWS_REGION"), ENV.get("AWS_TENANT")); + OpenIdAuthentication openIdAuthenticator = + OpenIdAuthentication.discover( + ClientRegistration.builder() + .credentials( + Credentials.builder() + .clientId(ENV.get("AWS_CLIENT_ID")) + .clientSecret(ENV.get("AWS_CLIENT_SECRET")) + .build()) + .providerDetails(ProviderDetails.builder().issuerUri(issuer).build()) + .build()); + + IcecreamClient client = + Feign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .addCapability(openIdAuthenticator) + .target(IcecreamClient.class, "http://localhost:" + randomServerPort); + + Collection mixins = client.getAvailableMixins(); + assertThat(mixins) + .isNotNull() + .isNotEmpty() + .hasSize(6) + .containsExactlyInAnyOrder(Mixin.values()); + } +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/AbstractAuthenticationTest.java b/oauth2/src/test/java/feign/auth/oauth2/AbstractAuthenticationTest.java new file mode 100644 index 0000000000..b75de57af2 --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/AbstractAuthenticationTest.java @@ -0,0 +1,30 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2; + +import feign.auth.oauth2.mock.Application; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.annotation.DirtiesContext; + +@SpringBootTest( + classes = Application.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +public abstract class AbstractAuthenticationTest { + + @LocalServerPort protected int randomServerPort; +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/AbstractKeycloakTest.java b/oauth2/src/test/java/feign/auth/oauth2/AbstractKeycloakTest.java new file mode 100644 index 0000000000..a5e44a085f --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/AbstractKeycloakTest.java @@ -0,0 +1,99 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2; + +import com.adelean.inject.resources.junit.jupiter.GivenTextResource; +import com.adelean.inject.resources.junit.jupiter.TestWithResources; +import dasniko.testcontainers.keycloak.KeycloakContainer; +import feign.auth.oauth2.support.KeyUtils; +import java.util.Collections; +import org.junit.jupiter.api.BeforeAll; +import org.keycloak.admin.client.resource.ClientsResource; +import org.keycloak.representations.idm.ClientRepresentation; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +@TestWithResources +public abstract class AbstractKeycloakTest extends AbstractAuthenticationTest { + + @GivenTextResource("certificate.pem") + protected static String certificatePem; + + @GivenTextResource("private_key.pem") + protected static String privateKeyPem; + + @Container + protected static KeycloakContainer keycloak = + new KeycloakContainer("quay.io/keycloak/keycloak:26.1.0"); + + @DynamicPropertySource + static void dynamicProperties(DynamicPropertyRegistry registry) { + String host = keycloak.getHost(); + int port = keycloak.getHttpPort(); + + registry.add( + "spring.security.oauth2.resourceserver.jwt.issuer-uri", + () -> String.format("http://%s:%d/realms/master", host, port)); + registry.add( + "spring.security.oauth2.resourceserver.jwt.jwk-set-uri", + () -> + String.format("http://%s:%d/realms/master/protocol/openid-connect/certs", host, port)); + } + + @BeforeAll + static void initKeycloak() { + ClientRepresentation clientFeignSecret = new ClientRepresentation(); + clientFeignSecret.setClientId(KeyCloakCredentials.FEIGN_CLIENT_ID_WITH_SECRET); + clientFeignSecret.setSecret(KeyCloakCredentials.CLIENT_SECRET_CREDENTIALS); + clientFeignSecret.setServiceAccountsEnabled(true); + clientFeignSecret.setClientAuthenticatorType("client-secret"); + + ClientRepresentation clientJwtSignedWithSecret = new ClientRepresentation(); + clientJwtSignedWithSecret.setClientId( + KeyCloakCredentials.FEIGN_CLIENT_ID_JWT_SIGNED_WITH_SECRET); + clientJwtSignedWithSecret.setSecret(KeyCloakCredentials.CLIENT_SECRET_SIGNATURE); + clientJwtSignedWithSecret.setServiceAccountsEnabled(true); + clientJwtSignedWithSecret.setClientAuthenticatorType("client-secret-jwt"); + + ClientRepresentation clientPrivateKeyJwt = new ClientRepresentation(); + clientPrivateKeyJwt.setClientId(KeyCloakCredentials.FEIGN_CLIENT_ID_PRIVATE_KEY_JWT); + clientPrivateKeyJwt.setServiceAccountsEnabled(true); + clientPrivateKeyJwt.setClientAuthenticatorType("client-jwt"); + + String certificate = KeyUtils.parseKey(certificatePem); + clientPrivateKeyJwt.setAttributes( + Collections.singletonMap("jwt.credential.certificate", certificate)); + + ClientsResource clientsApi = + keycloak.getKeycloakAdminClient().realms().realm(KeycloakContainer.MASTER_REALM).clients(); + clientsApi.create(clientFeignSecret); + clientsApi.create(clientJwtSignedWithSecret); + clientsApi.create(clientPrivateKeyJwt); + } + + protected static String keycloakHost() { + String host = keycloak.getHost(); + int port = keycloak.getHttpPort(); + return String.format("http://%s:%d", host, port); + } + + protected static String issuer() { + return String.format("%s/realms/%s", keycloakHost(), KeyCloakCredentials.REALM); + } +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/Auth0AuthenticationTest.java b/oauth2/src/test/java/feign/auth/oauth2/Auth0AuthenticationTest.java new file mode 100644 index 0000000000..27a9527f3d --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/Auth0AuthenticationTest.java @@ -0,0 +1,106 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2; + +import static feign.auth.oauth2.support.EnvTestConditions.ENV; +import static org.assertj.core.api.Assertions.assertThat; + +import feign.AsyncFeign; +import feign.Feign; +import feign.auth.oauth2.core.ClientAuthenticationMethod; +import feign.auth.oauth2.core.registration.ClientRegistration; +import feign.auth.oauth2.core.registration.Credentials; +import feign.auth.oauth2.core.registration.ProviderDetails; +import feign.auth.oauth2.mock.IcecreamClient; +import feign.auth.oauth2.mock.domain.Mixin; +import feign.hc5.AsyncApacheHttp5Client; +import feign.jackson.JacksonDecoder; +import feign.jackson.JacksonEncoder; +import java.util.Collection; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +@EnabledIf("feign.auth.oauth2.support.EnvTestConditions#testsWithAuth0Enabled") +public class Auth0AuthenticationTest extends AbstractAuthenticationTest { + + @DynamicPropertySource + static void dynamicProperties(DynamicPropertyRegistry registry) { + registry.add( + "spring.security.oauth2.resourceserver.jwt.issuer-uri", () -> ENV.get("AUTH0_ISSUER_URI")); + registry.add( + "spring.security.oauth2.resourceserver.jwt.jwk-set-uri", + () -> ENV.get("AUTH0_JWK_SET_URI")); + } + + @Test + void testWithAuth0() { + Auth0Authentication auth0Authentication = + new Auth0Authentication( + ENV.get("AUTH0_CLIENT_ID"), + ENV.get("AUTH0_CLIENT_SECRET"), + ENV.get("AUTH0_TENANT"), + ENV.get("AUTH0_AUDIENCE")); + + IcecreamClient client = + AsyncFeign.builder() + .client(new AsyncApacheHttp5Client()) + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .addCapability(auth0Authentication) + .target(IcecreamClient.class, "http://localhost:" + randomServerPort); + + Collection mixins = client.getAvailableMixins(); + assertThat(mixins) + .isNotNull() + .isNotEmpty() + .hasSize(6) + .containsExactlyInAnyOrder(Mixin.values()); + } + + @Test + void testWithAuth0Discovery() { + String issuer = String.format("https://%s.auth0.com", ENV.get("AUTH0_TENANT")); + OpenIdAuthentication openIdAuthenticator = + OpenIdAuthentication.discover( + ClientRegistration.builder() + .credentials( + Credentials.builder() + .clientId(ENV.get("AUTH0_CLIENT_ID")) + .clientSecret(ENV.get("AUTH0_CLIENT_SECRET")) + .build()) + .providerDetails(ProviderDetails.builder().issuerUri(issuer).build()) + .audience(ENV.get("AUTH0_AUDIENCE")) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .build()); + + IcecreamClient client = + Feign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .addCapability(openIdAuthenticator) + .target(IcecreamClient.class, "http://localhost:" + randomServerPort); + + Collection mixins = client.getAvailableMixins(); + assertThat(mixins) + .isNotNull() + .isNotEmpty() + .hasSize(6) + .containsExactlyInAnyOrder(Mixin.values()); + } +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/KeyCloakCredentials.java b/oauth2/src/test/java/feign/auth/oauth2/KeyCloakCredentials.java new file mode 100644 index 0000000000..04db5087da --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/KeyCloakCredentials.java @@ -0,0 +1,28 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2; + +public class KeyCloakCredentials { + public static final String REALM = "master"; + + public static final String FEIGN_CLIENT_ID_WITH_SECRET = "feign-client-secret"; + public static final String CLIENT_SECRET_CREDENTIALS = "bB2uRnSsi8tVnFJtVp0gbn6TT7Nu3bN8"; + + public static final String FEIGN_CLIENT_ID_JWT_SIGNED_WITH_SECRET = "feign-client-secret-jwt"; + public static final String CLIENT_SECRET_SIGNATURE = "DAFBYB4DurpGbPKfSpfFxvokzU6C7JI8"; + + public static final String FEIGN_CLIENT_ID_PRIVATE_KEY_JWT = "feign-private-key-jwt"; +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/KeycloakAuthenticationTest.java b/oauth2/src/test/java/feign/auth/oauth2/KeycloakAuthenticationTest.java new file mode 100644 index 0000000000..6a8b90b36e --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/KeycloakAuthenticationTest.java @@ -0,0 +1,126 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2; + +import static org.assertj.core.api.Assertions.assertThat; + +import feign.Feign; +import feign.auth.oauth2.mock.IcecreamClient; +import feign.auth.oauth2.mock.domain.Mixin; +import feign.auth.oauth2.support.KeyUtils; +import feign.jackson.JacksonDecoder; +import feign.jackson.JacksonEncoder; +import java.util.Collection; +import org.junit.jupiter.api.Test; + +public class KeycloakAuthenticationTest extends AbstractKeycloakTest { + + @Test + void testClientSecretBasic() { + KeycloakAuthentication keycloakAuthentication = + KeycloakAuthentication.withClientSecretBasic( + keycloakHost(), + KeyCloakCredentials.REALM, + KeyCloakCredentials.FEIGN_CLIENT_ID_WITH_SECRET, + KeyCloakCredentials.CLIENT_SECRET_CREDENTIALS); + + IcecreamClient client = + Feign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .addCapability(keycloakAuthentication) + .target(IcecreamClient.class, "http://localhost:" + randomServerPort); + + Collection mixins = client.getAvailableMixins(); + assertThat(mixins) + .isNotNull() + .isNotEmpty() + .hasSize(6) + .containsExactlyInAnyOrder(Mixin.values()); + } + + @Test + void testClientSecretPost() { + KeycloakAuthentication keycloakAuthentication = + KeycloakAuthentication.withClientSecretPost( + keycloakHost(), + KeyCloakCredentials.REALM, + KeyCloakCredentials.FEIGN_CLIENT_ID_WITH_SECRET, + KeyCloakCredentials.CLIENT_SECRET_CREDENTIALS); + + IcecreamClient client = + Feign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .addCapability(keycloakAuthentication) + .target(IcecreamClient.class, "http://localhost:" + randomServerPort); + + Collection mixins = client.getAvailableMixins(); + assertThat(mixins) + .isNotNull() + .isNotEmpty() + .hasSize(6) + .containsExactlyInAnyOrder(Mixin.values()); + } + + @Test + void testClientSecretJwt() { + KeycloakAuthentication keycloakAuthentication = + KeycloakAuthentication.withClientSecretJwt( + keycloakHost(), + KeyCloakCredentials.REALM, + KeyCloakCredentials.FEIGN_CLIENT_ID_JWT_SIGNED_WITH_SECRET, + KeyCloakCredentials.CLIENT_SECRET_SIGNATURE); + + IcecreamClient client = + Feign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .addCapability(keycloakAuthentication) + .target(IcecreamClient.class, "http://localhost:" + randomServerPort); + + Collection mixins = client.getAvailableMixins(); + assertThat(mixins) + .isNotNull() + .isNotEmpty() + .hasSize(6) + .containsExactlyInAnyOrder(Mixin.values()); + } + + @Test + void testPrivateKeyJwt() { + KeycloakAuthentication keycloakAuthentication = + KeycloakAuthentication.withPrivateKeyJwt( + keycloakHost(), + KeyCloakCredentials.REALM, + KeyCloakCredentials.FEIGN_CLIENT_ID_PRIVATE_KEY_JWT, + KeyUtils.parseKey(privateKeyPem)); + + IcecreamClient client = + Feign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .addCapability(keycloakAuthentication) + .target(IcecreamClient.class, "http://localhost:" + randomServerPort); + + Collection mixins = client.getAvailableMixins(); + assertThat(mixins) + .isNotNull() + .isNotEmpty() + .hasSize(6) + .containsExactlyInAnyOrder(Mixin.values()); + } +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/OpenIdAuthenticationTest.java b/oauth2/src/test/java/feign/auth/oauth2/OpenIdAuthenticationTest.java new file mode 100644 index 0000000000..6570787665 --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/OpenIdAuthenticationTest.java @@ -0,0 +1,164 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.auth0.jwt.algorithms.Algorithm; +import feign.Feign; +import feign.auth.oauth2.core.ClientAuthenticationMethod; +import feign.auth.oauth2.core.registration.ClientRegistration; +import feign.auth.oauth2.core.registration.Credentials; +import feign.auth.oauth2.core.registration.ProviderDetails; +import feign.auth.oauth2.mock.IcecreamClient; +import feign.auth.oauth2.mock.domain.Mixin; +import feign.auth.oauth2.support.KeyUtils; +import feign.jackson.JacksonDecoder; +import feign.jackson.JacksonEncoder; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.interfaces.RSAPrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.Collection; +import org.junit.jupiter.api.Test; + +class OpenIdAuthenticationTest extends AbstractKeycloakTest { + + @Test + void testClientSecretBasic() { + OpenIdAuthentication openIdAuthenticator = + OpenIdAuthentication.discover( + ClientRegistration.builder() + .credentials( + Credentials.builder() + .clientId(KeyCloakCredentials.FEIGN_CLIENT_ID_WITH_SECRET) + .clientSecret(KeyCloakCredentials.CLIENT_SECRET_CREDENTIALS) + .build()) + .providerDetails(ProviderDetails.builder().issuerUri(issuer()).build()) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .build()); + + IcecreamClient client = + Feign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .addCapability(openIdAuthenticator) + .target(IcecreamClient.class, "http://localhost:" + randomServerPort); + + Collection mixins = client.getAvailableMixins(); + assertThat(mixins) + .isNotNull() + .isNotEmpty() + .hasSize(6) + .containsExactlyInAnyOrder(Mixin.values()); + } + + @Test + void testClientSecretPost() { + OpenIdAuthentication openIdAuthenticator = + OpenIdAuthentication.discover( + ClientRegistration.builder() + .credentials( + Credentials.builder() + .clientId(KeyCloakCredentials.FEIGN_CLIENT_ID_WITH_SECRET) + .clientSecret(KeyCloakCredentials.CLIENT_SECRET_CREDENTIALS) + .build()) + .providerDetails(ProviderDetails.builder().issuerUri(issuer()).build()) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .build()); + + IcecreamClient client = + Feign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .addCapability(openIdAuthenticator) + .target(IcecreamClient.class, "http://localhost:" + randomServerPort); + + Collection mixins = client.getAvailableMixins(); + assertThat(mixins) + .isNotNull() + .isNotEmpty() + .hasSize(6) + .containsExactlyInAnyOrder(Mixin.values()); + } + + @Test + void testClientSecretJwt() { + OpenIdAuthentication openIdAuthenticator = + OpenIdAuthentication.discover( + ClientRegistration.builder() + .credentials( + Credentials.builder() + .clientId(KeyCloakCredentials.FEIGN_CLIENT_ID_JWT_SIGNED_WITH_SECRET) + .clientSecret(KeyCloakCredentials.CLIENT_SECRET_SIGNATURE) + .build()) + .providerDetails(ProviderDetails.builder().issuerUri(issuer()).build()) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT) + .build()); + + IcecreamClient client = + Feign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .addCapability(openIdAuthenticator) + .target(IcecreamClient.class, "http://localhost:" + randomServerPort); + + Collection mixins = client.getAvailableMixins(); + assertThat(mixins) + .isNotNull() + .isNotEmpty() + .hasSize(6) + .containsExactlyInAnyOrder(Mixin.values()); + } + + @Test + void testPrivateKeyJwt() throws NoSuchAlgorithmException, InvalidKeySpecException { + byte[] key = Base64.getDecoder().decode(KeyUtils.parseKey(privateKeyPem)); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PKCS8EncodedKeySpec keySpecPKCS8 = new PKCS8EncodedKeySpec(key); + PrivateKey privKey = keyFactory.generatePrivate(keySpecPKCS8); + Algorithm signingAlgorithm = Algorithm.RSA256(null, (RSAPrivateKey) privKey); + + OpenIdAuthentication openIdAuthenticator = + OpenIdAuthentication.discover( + ClientRegistration.builder() + .credentials( + Credentials.builder() + .clientId(KeyCloakCredentials.FEIGN_CLIENT_ID_PRIVATE_KEY_JWT) + .jwtSignatureAlgorithm(signingAlgorithm) + .build()) + .providerDetails(ProviderDetails.builder().issuerUri(issuer()).build()) + .clientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT) + .build()); + + IcecreamClient client = + Feign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .addCapability(openIdAuthenticator) + .target(IcecreamClient.class, "http://localhost:" + randomServerPort); + + Collection mixins = client.getAvailableMixins(); + assertThat(mixins) + .isNotNull() + .isNotEmpty() + .hasSize(6) + .containsExactlyInAnyOrder(Mixin.values()); + } +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/core/ClientAuthenticationMethodTest.java b/oauth2/src/test/java/feign/auth/oauth2/core/ClientAuthenticationMethodTest.java new file mode 100644 index 0000000000..efacc94758 --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/core/ClientAuthenticationMethodTest.java @@ -0,0 +1,43 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class ClientAuthenticationMethodTest { + + @ParameterizedTest + @CsvSource({ + "client_secret_basic, CLIENT_SECRET_BASIC", + "client_secret_post, CLIENT_SECRET_POST", + "client_secret_jwt, CLIENT_SECRET_JWT", + "private_key_jwt, PRIVATE_KEY_JWT", + "tls_client_auth, TLS_CLIENT_AUTH", + "self_signed_tls_client_auth, SELF_SIGNED_TLS_CLIENT_AUTH", + "none, NONE", + }) + void testParse(String str, ClientAuthenticationMethod expected) { + + /* When */ + ClientAuthenticationMethod authenticationMethod = ClientAuthenticationMethod.parse(str); + + /* Then */ + assertThat(authenticationMethod).isNotNull().isEqualTo(expected); + } +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/core/responses/OAuth2TokenResponseTest.java b/oauth2/src/test/java/feign/auth/oauth2/core/responses/OAuth2TokenResponseTest.java new file mode 100644 index 0000000000..58bd51d861 --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/core/responses/OAuth2TokenResponseTest.java @@ -0,0 +1,50 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.core.responses; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +public class OAuth2TokenResponseTest { + + @Test + void testFromMap() { + + /* Given */ + Map map = + Map.of( + "access_token", "", + "token_type", "Bearer", + "expires_in", 3600, + "refresh_token", "", + "scope", "openid"); + + /* When */ + OAuth2TokenResponse token = OAuth2TokenResponse.fromMap(map); + + /* Then */ + assertThat(token) + .isNotNull() + .hasFieldOrPropertyWithValue("accessToken", "") + .hasFieldOrPropertyWithValue("tokenType", "Bearer") + .hasFieldOrPropertyWithValue("expiresIn", 3600) + .hasFieldOrPropertyWithValue("refreshToken", Optional.of("")) + .hasFieldOrPropertyWithValue("scope", Optional.of("openid")); + } +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/core/responses/OpenIdProviderConfigurationResponseTest.java b/oauth2/src/test/java/feign/auth/oauth2/core/responses/OpenIdProviderConfigurationResponseTest.java new file mode 100644 index 0000000000..561c244bd2 --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/core/responses/OpenIdProviderConfigurationResponseTest.java @@ -0,0 +1,59 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.core.responses; + +import static feign.auth.oauth2.core.ClientAuthenticationMethod.CLIENT_SECRET_BASIC; +import static feign.auth.oauth2.core.ClientAuthenticationMethod.CLIENT_SECRET_POST; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class OpenIdProviderConfigurationResponseTest { + + @Test + void testFromMap() { + + /* Given */ + Map map = + Map.of( + "issuer", "https://idp.com", + "authorization_endpoint", "https://idp.com/oauth/authorize", + "token_endpoint", "https://idp.com/oauth/token", + "userinfo_endpoint", "https://idp.com/userinfo", + "jwks_uri", "https://idp.com/.well-known/jwks.json", + "token_endpoint_auth_methods_supported", + Set.of("client_secret_basic", "client_secret_post"), + "scopes_supported", Set.of("openid", "email", "phone", "profile")); + + /* When */ + OpenIdProviderConfigurationResponse response = OpenIdProviderConfigurationResponse.fromMap(map); + + /* Then */ + assertThat(response) + .isNotNull() + .hasFieldOrPropertyWithValue("issuer", "https://idp.com") + .hasFieldOrPropertyWithValue("authorizationEndpoint", "https://idp.com/oauth/authorize") + .hasFieldOrPropertyWithValue("tokenEndpoint", "https://idp.com/oauth/token") + .hasFieldOrPropertyWithValue("userinfoEndpoint", "https://idp.com/userinfo") + .hasFieldOrPropertyWithValue("jwksUri", "https://idp.com/.well-known/jwks.json") + .hasFieldOrPropertyWithValue( + "clientAuthenticationMethods", Set.of(CLIENT_SECRET_BASIC, CLIENT_SECRET_POST)) + .hasFieldOrPropertyWithValue( + "scopesSupported", Set.of("openid", "email", "phone", "profile")); + } +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/mock/Application.java b/oauth2/src/test/java/feign/auth/oauth2/mock/Application.java new file mode 100644 index 0000000000..227f6ce7b3 --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/mock/Application.java @@ -0,0 +1,26 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.mock; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/mock/IcecreamClient.java b/oauth2/src/test/java/feign/auth/oauth2/mock/IcecreamClient.java new file mode 100644 index 0000000000..c805b91738 --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/mock/IcecreamClient.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.mock; + +import feign.Headers; +import feign.Param; +import feign.RequestLine; +import feign.auth.oauth2.mock.domain.Bill; +import feign.auth.oauth2.mock.domain.Flavor; +import feign.auth.oauth2.mock.domain.IceCreamOrder; +import feign.auth.oauth2.mock.domain.Mixin; +import java.util.Collection; + +@Headers({"Accept: application/json"}) +public interface IcecreamClient { + + @RequestLine("GET /icecream/flavors") + Collection getAvailableFlavors(); + + @RequestLine("GET /icecream/mixins") + Collection getAvailableMixins(); + + @RequestLine("POST /icecream/orders") + @Headers("Content-Type: application/json") + Bill makeOrder(IceCreamOrder order); + + @RequestLine("GET /icecream/orders/{orderId}") + IceCreamOrder findOrder(@Param("orderId") int orderId); + + @RequestLine("POST /icecream/bills/pay") + @Headers("Content-Type: application/json") + void payBill(Bill bill); +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/mock/IcecreamController.java b/oauth2/src/test/java/feign/auth/oauth2/mock/IcecreamController.java new file mode 100644 index 0000000000..93a1138fec --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/mock/IcecreamController.java @@ -0,0 +1,50 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.mock; + +import feign.auth.oauth2.mock.domain.*; +import java.util.Collection; +import java.util.List; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/icecream") +public class IcecreamController { + private final OrderGenerator orderGenerator = new OrderGenerator(); + + @GetMapping("/flavors") + public Collection getAvailableFlavors() { + return List.of(Flavor.values()); + } + + @GetMapping("/mixins") + public Collection getAvailableMixins() { + return List.of(Mixin.values()); + } + + @PostMapping("/orders") + public Bill makeOrder(@RequestBody IceCreamOrder order) { + return Bill.makeBill(order); + } + + @GetMapping("/orders/{orderId}") + public IceCreamOrder findOrder(@PathVariable int orderId) { + return orderGenerator.generate(); + } + + @PostMapping("/bills/pay") + public void payBill(@RequestBody Bill bill) {} +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/mock/domain/Bill.java b/oauth2/src/test/java/feign/auth/oauth2/mock/domain/Bill.java new file mode 100644 index 0000000000..067af26cb3 --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/mock/domain/Bill.java @@ -0,0 +1,82 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.mock.domain; + +import java.util.Map; +import java.util.Objects; + +/** + * Bill for consumed ice cream. + * + * @author Alexei KLENIN + */ +public class Bill { + private static final Map PRICES = + Map.of( + 1, (float) 2.00, // two euros for one ball (expensive!) + 3, (float) 2.85, // 2.85€ for 3 balls + 5, (float) 4.30, // 4.30€ for 5 balls + 7, (float) 5); // only five euros for seven balls! Wow + + private static final float MIXIN_PRICE = (float) 0.6; // price per mixin + + private Float price; + + public Bill() {} + + public Bill(final Float price) { + this.price = price; + } + + public Float getPrice() { + return price; + } + + public void setPrice(final Float price) { + this.price = price; + } + + /** + * Makes a bill from an order. + * + * @param order ice cream order + * @return bill + */ + public static Bill makeBill(final IceCreamOrder order) { + int nbBalls = order.getBalls().values().stream().mapToInt(Integer::intValue).sum(); + Float price = PRICES.get(nbBalls) + order.getMixins().size() * MIXIN_PRICE; + return new Bill(price); + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } + + if (!(other instanceof Bill)) { + return false; + } + + final Bill another = (Bill) other; + return Objects.equals(price, another.price); + } + + @Override + public int hashCode() { + return Objects.hash(price); + } +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/mock/domain/Flavor.java b/oauth2/src/test/java/feign/auth/oauth2/mock/domain/Flavor.java new file mode 100644 index 0000000000..fdb016fc81 --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/mock/domain/Flavor.java @@ -0,0 +1,38 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.mock.domain; + +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Ice cream flavors. + * + * @author Alexei KLENIN + */ +public enum Flavor { + STRAWBERRY, + CHOCOLATE, + BANANA, + PISTACHIO, + MELON, + VANILLA; + + public static final String FLAVORS_JSON = + Stream.of(Flavor.values()) + .map(flavor -> "\"" + flavor + "\"") + .collect(Collectors.joining(", ", "[ ", " ]")); +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/mock/domain/IceCreamOrder.java b/oauth2/src/test/java/feign/auth/oauth2/mock/domain/IceCreamOrder.java new file mode 100644 index 0000000000..26827a8ebd --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/mock/domain/IceCreamOrder.java @@ -0,0 +1,111 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.mock.domain; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Give me some ice-cream! :p + * + * @author Alexei KLENIN + */ +public class IceCreamOrder { + private final int id; // order id + private final Map balls; // how much balls of flavor + private final Set mixins; // and some mixins ... + private Instant orderTimestamp; // and give it to me right now ! + + public IceCreamOrder() { + this(Instant.now()); + } + + IceCreamOrder(final Instant orderTimestamp) { + this.id = ThreadLocalRandom.current().nextInt(); + this.balls = new HashMap<>(); + this.mixins = new LinkedHashSet<>(); + this.orderTimestamp = orderTimestamp; + } + + public IceCreamOrder addBall(final Flavor ballFlavor) { + final Integer ballCount = balls.containsKey(ballFlavor) ? balls.get(ballFlavor) + 1 : 1; + balls.put(ballFlavor, ballCount); + return this; + } + + IceCreamOrder addMixin(final Mixin mixin) { + mixins.add(mixin); + return this; + } + + IceCreamOrder withOrderTimestamp(final Instant orderTimestamp) { + this.orderTimestamp = orderTimestamp; + return this; + } + + public int getId() { + return id; + } + + public Map getBalls() { + return balls; + } + + public Set getMixins() { + return mixins; + } + + public Instant getOrderTimestamp() { + return orderTimestamp; + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } + + if (!(other instanceof IceCreamOrder)) { + return false; + } + + final IceCreamOrder another = (IceCreamOrder) other; + return id == another.id + && Objects.equals(balls, another.balls) + && Objects.equals(mixins, another.mixins) + && Objects.equals(orderTimestamp, another.orderTimestamp); + } + + @Override + public int hashCode() { + return Objects.hash(id, balls, mixins, orderTimestamp); + } + + @Override + public String toString() { + return "IceCreamOrder{" + + " id=" + + id + + ", balls=" + + balls + + ", mixins=" + + mixins + + ", orderTimestamp=" + + orderTimestamp + + '}'; + } +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/mock/domain/Mixin.java b/oauth2/src/test/java/feign/auth/oauth2/mock/domain/Mixin.java new file mode 100644 index 0000000000..f4f219b6a8 --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/mock/domain/Mixin.java @@ -0,0 +1,38 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.mock.domain; + +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Ice cream mix-ins. + * + * @author Alexei KLENIN + */ +public enum Mixin { + COOKIES, + MNMS, + CHOCOLATE_SIROP, + STRAWBERRY_SIROP, + NUTS, + RAINBOW; + + public static final String MIXINS_JSON = + Stream.of(Mixin.values()) + .map(flavor -> "\"" + flavor + "\"") + .collect(Collectors.joining(", ", "[ ", " ]")); +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/mock/domain/OrderGenerator.java b/oauth2/src/test/java/feign/auth/oauth2/mock/domain/OrderGenerator.java new file mode 100644 index 0000000000..c2fe2f7856 --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/mock/domain/OrderGenerator.java @@ -0,0 +1,59 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.mock.domain; + +import java.util.Random; +import java.util.stream.IntStream; + +/** + * Generator of random ice cream orders. + * + * @author Alexei KLENIN + */ +public class OrderGenerator { + private static final int[] BALLS_NUMBER = {1, 3, 5, 7}; + private static final int[] MIXIN_NUMBER = {1, 2, 3}; + + private static final Random random = new Random(); + + public IceCreamOrder generate() { + final IceCreamOrder order = new IceCreamOrder(); + final int nbBalls = peekBallsNumber(); + final int nbMixins = peekMixinNumber(); + + IntStream.rangeClosed(1, nbBalls).mapToObj(i -> this.peekFlavor()).forEach(order::addBall); + + IntStream.rangeClosed(1, nbMixins).mapToObj(i -> this.peekMixin()).forEach(order::addMixin); + + return order; + } + + private int peekBallsNumber() { + return BALLS_NUMBER[random.nextInt(BALLS_NUMBER.length)]; + } + + private int peekMixinNumber() { + return MIXIN_NUMBER[random.nextInt(MIXIN_NUMBER.length)]; + } + + private Flavor peekFlavor() { + return Flavor.values()[random.nextInt(Flavor.values().length)]; + } + + private Mixin peekMixin() { + return Mixin.values()[random.nextInt(Mixin.values().length)]; + } +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/support/EnvTestConditions.java b/oauth2/src/test/java/feign/auth/oauth2/support/EnvTestConditions.java new file mode 100644 index 0000000000..915ef49daf --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/support/EnvTestConditions.java @@ -0,0 +1,32 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.support; + +import io.github.cdimascio.dotenv.Dotenv; + +public final class EnvTestConditions { + public static Dotenv ENV = Dotenv.configure().filename("test.env").ignoreIfMissing().load(); + + private EnvTestConditions() {} + + public static boolean testsWithCognitoEnabled() { + return ENV.get("AWS_CLIENT_ID") != null; + } + + public static boolean testsWithAuth0Enabled() { + return ENV.get("AUTH0_CLIENT_ID") != null; + } +} diff --git a/oauth2/src/test/java/feign/auth/oauth2/support/KeyUtils.java b/oauth2/src/test/java/feign/auth/oauth2/support/KeyUtils.java new file mode 100644 index 0000000000..7fec8242bb --- /dev/null +++ b/oauth2/src/test/java/feign/auth/oauth2/support/KeyUtils.java @@ -0,0 +1,40 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.auth.oauth2.support; + +public final class KeyUtils { + private KeyUtils() {} + + public static String parseKey(String fileContent) { + String[] lines = fileContent.split("\n"); + boolean started = false; + + String key = ""; + + for (String line : lines) { + if (line.startsWith("-----BEGIN")) { + started = true; + } else if (line.startsWith("-----END")) { + break; + } else if (started) { + key += line; + } + ; + } + + return key; + } +} diff --git a/oauth2/src/test/resources/.gitignore b/oauth2/src/test/resources/.gitignore new file mode 100644 index 0000000000..a67af1f7f3 --- /dev/null +++ b/oauth2/src/test/resources/.gitignore @@ -0,0 +1 @@ +test.env \ No newline at end of file diff --git a/oauth2/src/test/resources/certificate.pem b/oauth2/src/test/resources/certificate.pem new file mode 100644 index 0000000000..2ef921b827 --- /dev/null +++ b/oauth2/src/test/resources/certificate.pem @@ -0,0 +1,22 @@ +Bag Attributes + friendlyName: feign-private-key-jwt + localKeyID: 54 69 6D 65 20 31 37 33 38 37 30 39 37 38 37 33 30 39 +subject=CN = feign-private-key-jwt +issuer=CN = feign-private-key-jwt +-----BEGIN CERTIFICATE----- +MIICuTCCAaECBgGU0SqE1TANBgkqhkiG9w0BAQsFADAgMR4wHAYDVQQDDBVmZWln +bi1wcml2YXRlLWtleS1qd3QwHhcNMjUwMjA0MTMzMTUyWhcNMzUwMjA0MTMzMzMy +WjAgMR4wHAYDVQQDDBVmZWlnbi1wcml2YXRlLWtleS1qd3QwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQC5VgVUtfGlA79XZHPh4cVZo4ApmWZ5r2aAe0K/ +2oIM3DELWETt/MNg2PJmREwE+uZDCf4gjgcVrqkNe1WASeiFg89yxKOAC2hXT6h5 +ipl/7zIwayFpkDWJ2ULzapD9KMDC/mJ7K72e0Kc/u6rkegGb02vsK+m2XrUl1nu1 +u+O0gcG9hq9fcqluGU2fuwdcOjJziQP/TxRQ1T2ZmD0dzweN30JbKzlg5PBqKkmT +SumLxzXdp709TW6mgnO3ahx2zR0jaTe4uC0qo1xDJqRzVqCfB1EqoLZFoxbrMrwG +F6AxIEChER9l9+SOsyHEucYpSY93/U+bBrC9ETU9OuKsIFzfAgMBAAEwDQYJKoZI +hvcNAQELBQADggEBAKEmOWjufvizQlIABjh4MYuPxBQG2bg4wUMOoJRe5MaSg+7H +UZYKYmhwQLTpai/TZZwktU5Zf43bupzWnOxWvSoYL79548ZgRkYej3Pa6bLsrNYq +T3sKbsdmrFSxy6FewCBCMDWoey0JhkSfbskxbyFroRZ3pBWRhrpt71sKvyNOurNN +JLHPL2u72udHvIz7MadCp9in8bZh1iSyPS03Uw1LgmrQOZ2PyV8zROIcD1ew+TUx +eLnud+4ZVFw0zWJt4XbXX/oqzgKIjxptMUKq9Es/8Jcuy5Iv6giQin4Ypj+9z21E +7ICuJbxbcsq/uNfVw0V0RT7gq/GroJ4x81LFh/k= +-----END CERTIFICATE----- diff --git a/oauth2/src/test/resources/extract-private-key.sh b/oauth2/src/test/resources/extract-private-key.sh new file mode 100755 index 0000000000..7ed8675d19 --- /dev/null +++ b/oauth2/src/test/resources/extract-private-key.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +keytool -importkeystore \ + -srckeystore keystore.jks \ + -destkeystore keystore.p12 \ + -srcstoretype JKS \ + -deststoretype PKCS12 \ + -srcstorepass feign \ + -deststorepass OpenFeign + +openssl pkcs12 -in keystore.p12 -nocerts -nodes -passin pass:OpenFeign -out private_key.pem + +openssl pkcs12 -in keystore.p12 -clcerts -nokeys -passin pass:OpenFeign -out certificate.pem + +openssl rsa -in private_key.pem -check \ No newline at end of file diff --git a/oauth2/src/test/resources/keystore.jks b/oauth2/src/test/resources/keystore.jks new file mode 100644 index 0000000000..8128fcd63b Binary files /dev/null and b/oauth2/src/test/resources/keystore.jks differ diff --git a/oauth2/src/test/resources/keystore.p12 b/oauth2/src/test/resources/keystore.p12 new file mode 100644 index 0000000000..1061db8491 Binary files /dev/null and b/oauth2/src/test/resources/keystore.p12 differ diff --git a/oauth2/src/test/resources/private_key.pem b/oauth2/src/test/resources/private_key.pem new file mode 100644 index 0000000000..066202c4c5 --- /dev/null +++ b/oauth2/src/test/resources/private_key.pem @@ -0,0 +1,32 @@ +Bag Attributes + friendlyName: feign-private-key-jwt + localKeyID: 54 69 6D 65 20 31 37 33 38 37 30 39 37 38 37 33 30 39 +Key Attributes: +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC5VgVUtfGlA79X +ZHPh4cVZo4ApmWZ5r2aAe0K/2oIM3DELWETt/MNg2PJmREwE+uZDCf4gjgcVrqkN +e1WASeiFg89yxKOAC2hXT6h5ipl/7zIwayFpkDWJ2ULzapD9KMDC/mJ7K72e0Kc/ +u6rkegGb02vsK+m2XrUl1nu1u+O0gcG9hq9fcqluGU2fuwdcOjJziQP/TxRQ1T2Z +mD0dzweN30JbKzlg5PBqKkmTSumLxzXdp709TW6mgnO3ahx2zR0jaTe4uC0qo1xD +JqRzVqCfB1EqoLZFoxbrMrwGF6AxIEChER9l9+SOsyHEucYpSY93/U+bBrC9ETU9 +OuKsIFzfAgMBAAECggEAT5CNU1OUvLnCIBuA4D0Tgyr8717qrlzNjWTdQA3nfOve +pjcryFuV6PLaBWtWk2C/D5KvpXwzsGw+KQqEp0GTcBYpLeUbJ/I9xhXthtgok9K1 +YjHrsyz0vwwlgOiD/BrNviiFUMlza0W6N1Myx0lVfXcjQs2LVP6NmAPfMiLKW4gJ +ldIUY4KX2knJKMRDHml/D+zN5gdiKAvrshcXHvuoAyIZamJO2vThnjHjpe/Vj5gR +SSSqxsTgDttS8cHKaBOHiunDAYM4Tkt61uuZtbHzZLIqFmRjQKqx+yZm7BOkZo4X +sHURTT8yRXvfC27b29aMy1hFkZ/qdxlsAVe1Q6C3cQKBgQDpNRNAuYIHDvglgXxM +VlNyzqBcmATrcpm/NR9nultrEs+UAuwAsYY4J5JJa+4I1OCN/ah5JK0w1Ap2db47 +JPGG8YzC62MFl8fBEpzGHiJzvOaEKMX5+8gFhm31OFx48oLXIOqzp7XjxOBbGtVl +SbQkqL8noy8hmsrq+XHJwP9VGQKBgQDLczCLLWDplye/2Qsc8LHztGopUgEsy4GH +7Zh/AIU1b8S1oOxPLOJCMziQEJ67UqSELYCzJ4rXnZv1moHZttbCM9nfdTmfaWeh +Ga2KNcLDxmhUdXPm6FxN1vBzeIOQMMVWFA5rgPZnazOhQ5fTc1j1Mm4lHKuZ4TDH +dbuEIPfItwKBgQDK3HmH9xcN7s6bSZ0HhpyGpQlIIpE9Au6NLrfH8osthCp/tV74 +Z3S7CmktZy6kDhHcUkgoQWhKrlj/nQLCzisv2JU6xanYpqSD5h3sFTCg/zSCyDkX +cRcY/0xNYR7HsyVv39lqODx4Cr2jYp84wblrnkLSRxhrogxcBGsgNrulmQKBgQDG +tnSki6cLoJqvdIO5OZLmEMJcJ3+ETCTxKez3pv906Pz91fbZgmJf/H1zoPgYyKht +fbAlzCp9XLC227mWd1Idjt1vt6SXgjE4lcb9pnLcG09NpwaUc0GsuZJThlMiI7Cq +2Z4X/HcvHfLowQdq/U/W9fHFB9LH0r59Iw4LCNRb0wKBgGm5stme9AMDNHTVjv4b +I6upXZMzAiZ5V82CznSDoJOhtlNmqS64K2KpwTQJycdfNwJ9S0YYEs220HCgLyuP +D4BFkgvhDe9LBVlvz5evOf5qjkgguOTIAu/p3fMmBsE4D8r0gHyVdviDTiIl/U63 +qtpje1mAun+sYDHN7gDTqfTJ +-----END PRIVATE KEY----- diff --git a/pom.xml b/pom.xml index 58c21e57f0..dacddcc7ee 100644 --- a/pom.xml +++ b/pom.xml @@ -100,6 +100,7 @@ java11 jakarta json + oauth2 okhttp googlehttpclient ribbon @@ -327,12 +328,24 @@ test + + ${project.groupId} + feign-oauth2 + ${project.version} + + ${project.groupId} feign-okhttp ${project.version} + + ${project.groupId} + feign-hc5 + ${project.version} + + ${project.groupId} feign-ribbon diff --git a/src/docs/overview-mindmap.iuml b/src/docs/overview-mindmap.iuml index d4fa6d0698..d651095740 100644 --- a/src/docs/overview-mindmap.iuml +++ b/src/docs/overview-mindmap.iuml @@ -41,6 +41,7 @@ left side *** Dropwizard Metrics 5 *** Micrometer ** extras +*** OAuth2 *** Hystrix *** SLF4J *** Mock