diff --git a/pom.xml b/pom.xml index 255ef7bfd..766c2baea 100644 --- a/pom.xml +++ b/pom.xml @@ -51,6 +51,7 @@ credentials oauth2_http appengine + reactor bom diff --git a/reactor/java/com/google/auth/oauth2/CacheableTokenProvider.java b/reactor/java/com/google/auth/oauth2/CacheableTokenProvider.java new file mode 100644 index 000000000..98e06f4ea --- /dev/null +++ b/reactor/java/com/google/auth/oauth2/CacheableTokenProvider.java @@ -0,0 +1,66 @@ +/* + * Copyright 2017-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.auth.oauth2; + +import java.util.concurrent.atomic.AtomicReference; +import reactor.core.publisher.Mono; + +public class CacheableTokenProvider implements ReactiveTokenProvider { + + private ReactiveTokenProvider reactiveTokenProvider; + + private AtomicReference> atomicReference; + + private final RefreshThreshold refreshThreshold; + + public CacheableTokenProvider(ReactiveTokenProvider reactiveTokenProvider) { + this(reactiveTokenProvider, new RefreshThreshold()); + } + + public CacheableTokenProvider( + ReactiveTokenProvider reactiveTokenProvider, RefreshThreshold refreshThreshold) { + this.reactiveTokenProvider = reactiveTokenProvider; + this.atomicReference = new AtomicReference<>(cachedRetrieval()); + this.refreshThreshold = refreshThreshold; + } + + @Override + public Mono retrieve() { + Mono currentInstance = atomicReference.get(); + return currentInstance.flatMap(t -> createNewIfCloseToExpiration(currentInstance, t)); + } + + private Mono createNewIfCloseToExpiration( + Mono currentInstance, AccessToken t) { + boolean expiresSoon = refreshThreshold.over(t); + if (expiresSoon) { + return refreshOnExpiration(currentInstance); + } else { + return Mono.just(t); + } + } + + private Mono refreshOnExpiration(Mono expected) { + Mono toExecute = cachedRetrieval(); + atomicReference.compareAndSet(expected, toExecute); + return atomicReference.get(); + } + + Mono cachedRetrieval() { + return reactiveTokenProvider.retrieve().cache(); + } +} diff --git a/reactor/java/com/google/auth/oauth2/ComputeEngineTokenProvider.java b/reactor/java/com/google/auth/oauth2/ComputeEngineTokenProvider.java new file mode 100644 index 000000000..9855d5689 --- /dev/null +++ b/reactor/java/com/google/auth/oauth2/ComputeEngineTokenProvider.java @@ -0,0 +1,76 @@ +/* + * Copyright 2017-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.auth.oauth2; + +import static com.google.auth.oauth2.Constants.ACCESS_TOKEN; +import static com.google.auth.oauth2.Constants.ERROR_PARSING_TOKEN_REFRESH_RESPONSE; +import static com.google.auth.oauth2.Constants.EXPIRES_IN; + +import com.google.api.client.util.GenericData; +import java.io.IOException; +import java.util.Date; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +public class ComputeEngineTokenProvider implements ReactiveTokenProvider { + + private final WebClient webClient; + private final ComputeEngineCredentials computeEngineCredentials; + + private final String tokenUrl; + + public ComputeEngineTokenProvider( + WebClient webClient, ComputeEngineCredentials computeEngineCredentials) { + this.webClient = webClient; + this.computeEngineCredentials = computeEngineCredentials; + tokenUrl = computeEngineCredentials.createTokenUrlWithScopes(); + } + + public ComputeEngineTokenProvider( + WebClient webClient, ComputeEngineCredentials computeEngineCredentials, String tokenUrl) { + this.webClient = webClient; + this.computeEngineCredentials = computeEngineCredentials; + this.tokenUrl = tokenUrl; + } + + @Override + public Mono retrieve() { + return webClient + .get() + .uri(tokenUrl) + .header("Metadata-Flavor", "Google") + .retrieve() + .bodyToMono(GenericData.class) + .flatMap( + gd -> { + try { + String tokenValue = + OAuth2Utils.validateString( + gd, ACCESS_TOKEN, ERROR_PARSING_TOKEN_REFRESH_RESPONSE); + int expiresInSeconds = + OAuth2Utils.validateInt32(gd, EXPIRES_IN, ERROR_PARSING_TOKEN_REFRESH_RESPONSE); + long expiresAtMilliseconds = + computeEngineCredentials.clock.currentTimeMillis() + (expiresInSeconds * 1000L); + AccessToken accessToken = + new AccessToken(tokenValue, new Date(expiresAtMilliseconds)); + return Mono.just(accessToken); + } catch (IOException e) { + return Mono.error(e); + } + }); + } +} diff --git a/reactor/java/com/google/auth/oauth2/Constants.java b/reactor/java/com/google/auth/oauth2/Constants.java new file mode 100644 index 000000000..b961505a5 --- /dev/null +++ b/reactor/java/com/google/auth/oauth2/Constants.java @@ -0,0 +1,27 @@ +/* + * Copyright 2017-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.auth.oauth2; + +public class Constants { + + static final String ACCESS_TOKEN = "access_token"; + static final String EXPIRES_IN = "expires_in"; + static final String ERROR_PARSING_TOKEN_REFRESH_RESPONSE = + "Error parsing token refresh response. "; + + private Constants() {} +} diff --git a/reactor/java/com/google/auth/oauth2/ReactiveTokenProvider.java b/reactor/java/com/google/auth/oauth2/ReactiveTokenProvider.java new file mode 100644 index 000000000..468598768 --- /dev/null +++ b/reactor/java/com/google/auth/oauth2/ReactiveTokenProvider.java @@ -0,0 +1,49 @@ +/* + * Copyright 2017-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.auth.oauth2; + +import com.google.auth.Credentials; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +public interface ReactiveTokenProvider { + + Mono retrieve(); + + static ReactiveTokenProvider createCacheable(Credentials credentials) { + ReactiveTokenProvider reactiveTokenProvider = create(credentials); + return new CacheableTokenProvider(reactiveTokenProvider); + } + + static ReactiveTokenProvider create(Credentials credentials) { + WebClient webClient = WebClient.builder().build(); + return create(credentials, webClient); + } + + static ReactiveTokenProvider create(Credentials credentials, WebClient webClient) { + if (credentials instanceof UserCredentials) { + return new UserCredentialsTokenProvider(webClient, (UserCredentials) credentials); + } else if (credentials instanceof ServiceAccountCredentials) { + return new ServiceAccountTokenProvider(webClient, (ServiceAccountCredentials) credentials); + } else if (credentials instanceof ComputeEngineCredentials) { + return new ComputeEngineTokenProvider(webClient, (ComputeEngineCredentials) credentials); + } else { + throw new UnsupportedOperationException( + "Unsupported credentials type. UserCredentials,ServiceAccountCredentials,ComputeEngineCredentials are supported"); + } + } +} diff --git a/reactor/java/com/google/auth/oauth2/RefreshThreshold.java b/reactor/java/com/google/auth/oauth2/RefreshThreshold.java new file mode 100644 index 000000000..b24da954c --- /dev/null +++ b/reactor/java/com/google/auth/oauth2/RefreshThreshold.java @@ -0,0 +1,37 @@ +/* + * Copyright 2017-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.auth.oauth2; + +class RefreshThreshold { + + static final int DEFAULT_MILLIS_BEFORE_EXPIRATION = 60 * 1000; + private final int millisBeforeExpiration; + + public RefreshThreshold() { + this(DEFAULT_MILLIS_BEFORE_EXPIRATION); + } + + public RefreshThreshold(int millisBeforeExpiration) { + this.millisBeforeExpiration = millisBeforeExpiration; + } + + boolean over(AccessToken accessToken) { + long currentMillis = System.currentTimeMillis(); + long expirationMillis = accessToken.getExpirationTime().getTime(); + return currentMillis > expirationMillis - millisBeforeExpiration; + } +} diff --git a/reactor/java/com/google/auth/oauth2/ServiceAccountTokenProvider.java b/reactor/java/com/google/auth/oauth2/ServiceAccountTokenProvider.java new file mode 100644 index 000000000..e7182e9d6 --- /dev/null +++ b/reactor/java/com/google/auth/oauth2/ServiceAccountTokenProvider.java @@ -0,0 +1,96 @@ +/* + * Copyright 2017-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.auth.oauth2; + +import com.google.api.client.json.JsonFactory; +import com.google.api.client.util.GenericData; +import java.io.IOException; +import java.util.Date; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +public class ServiceAccountTokenProvider implements ReactiveTokenProvider { + + private static final String GRANT_TYPE = "grant_type"; + private static final String ASSERTION = "assertion"; + private final WebClient webClient; + private final ServiceAccountCredentials serviceAccountCredentials; + + private final String tokenUrl; + + public ServiceAccountTokenProvider( + WebClient webClient, ServiceAccountCredentials serviceAccountCredentials) { + this.webClient = webClient; + this.serviceAccountCredentials = serviceAccountCredentials; + tokenUrl = OAuth2Utils.TOKEN_SERVER_URI.toString(); + } + + public ServiceAccountTokenProvider( + WebClient webClient, ServiceAccountCredentials serviceAccountCredentials, String tokenUrl) { + this.webClient = webClient; + this.serviceAccountCredentials = serviceAccountCredentials; + this.tokenUrl = tokenUrl; + } + + @Override + public Mono retrieve() { + JsonFactory jsonFactory = OAuth2Utils.JSON_FACTORY; + long currentTime = serviceAccountCredentials.clock.currentTimeMillis(); + + try { + String assertion = serviceAccountCredentials.createAssertion(jsonFactory, currentTime); + + return webClient + .post() + .uri(tokenUrl) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(refreshForm(assertion)) + .retrieve() + .bodyToMono(GenericData.class) + .flatMap( + gd -> { + try { + AccessToken accessToken = getAccessToken(gd); + return Mono.just(accessToken); + } catch (IOException e) { + return Mono.error(e); + } + }); + + } catch (IOException e) { + return Mono.error(e); + } + } + + private AccessToken getAccessToken(GenericData gd) throws IOException { + String tokenValue = + OAuth2Utils.validateString(gd, "access_token", "Error parsing token refresh response. "); + int expiresInSeconds = + OAuth2Utils.validateInt32(gd, "expires_in", "Error parsing token refresh response. "); + long expiresAtMilliseconds = + serviceAccountCredentials.clock.currentTimeMillis() + (long) expiresInSeconds * 1000L; + AccessToken accessToken = new AccessToken(tokenValue, new Date(expiresAtMilliseconds)); + return accessToken; + } + + private static BodyInserters.FormInserter refreshForm(String assertion) { + return BodyInserters.fromFormData(GRANT_TYPE, OAuth2Utils.GRANT_TYPE_JWT_BEARER) + .with(ASSERTION, assertion); + } +} diff --git a/reactor/java/com/google/auth/oauth2/UserCredentialsTokenProvider.java b/reactor/java/com/google/auth/oauth2/UserCredentialsTokenProvider.java new file mode 100644 index 000000000..cd1a17920 --- /dev/null +++ b/reactor/java/com/google/auth/oauth2/UserCredentialsTokenProvider.java @@ -0,0 +1,99 @@ +/* + * Copyright 2017-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.auth.oauth2; + +import com.google.api.client.util.GenericData; +import java.io.IOException; +import java.util.Date; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +public class UserCredentialsTokenProvider implements ReactiveTokenProvider { + + private final WebClient webClient; + private final UserCredentials userCredentials; + + private final String tokenUrl; + + public UserCredentialsTokenProvider(WebClient webClient, UserCredentials userCredentials) { + this.webClient = webClient; + this.userCredentials = userCredentials; + this.tokenUrl = OAuth2Utils.TOKEN_SERVER_URI.toString(); + } + + public UserCredentialsTokenProvider( + WebClient webClient, UserCredentials userCredentials, String tokenUrl) { + this.webClient = webClient; + this.userCredentials = userCredentials; + this.tokenUrl = tokenUrl; + } + + @Override + public Mono retrieve() { + if (userCredentials.getRefreshToken() == null) { + throw new IllegalStateException( + "UserCredentials instance cannot refresh because there is no refresh token."); + } else { + GenericData tokenRequest = new GenericData(); + tokenRequest.set("client_id", this.userCredentials.getClientId()); + tokenRequest.set("client_secret", this.userCredentials.getClientSecret()); + tokenRequest.set("refresh_token", this.userCredentials.getRefreshToken()); + tokenRequest.set("grant_type", "refresh_token"); + + return webClient + .post() + .uri(tokenUrl) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body( + BodyInserters.fromFormData("client_id", this.userCredentials.getClientId()) + .with("client_secret", this.userCredentials.getClientSecret()) + .with("refresh_token", this.userCredentials.getRefreshToken()) + .with("grant_type", "refresh_token")) + .retrieve() + .bodyToMono(GenericData.class) + .flatMap( + gd -> { + try { + AccessToken accessToken = toAccessToken(gd); + return Mono.just(accessToken); + } catch (IOException e) { + return Mono.error(e); + } + }); + } + } + + private AccessToken toAccessToken(GenericData gd) throws IOException { + String accessTokenValue = + OAuth2Utils.validateString(gd, "access_token", "Error parsing token refresh response. "); + int expiresInSeconds = + OAuth2Utils.validateInt32(gd, "expires_in", "Error parsing token refresh response. "); + long expiresAtMilliseconds = + userCredentials.clock.currentTimeMillis() + (long) (expiresInSeconds * 1000); + String scopes = + OAuth2Utils.validateOptionalString(gd, "scope", "Error parsing token refresh response. "); + AccessToken accessToken = + AccessToken.newBuilder() + .setExpirationTime(new Date(expiresAtMilliseconds)) + .setTokenValue(accessTokenValue) + .setScopes(scopes) + .build(); + return accessToken; + } +} diff --git a/reactor/javatests/com/google/auth/oauth2/CacheableTokenProviderTest.java b/reactor/javatests/com/google/auth/oauth2/CacheableTokenProviderTest.java new file mode 100644 index 000000000..9689ab6c1 --- /dev/null +++ b/reactor/javatests/com/google/auth/oauth2/CacheableTokenProviderTest.java @@ -0,0 +1,72 @@ +/* + * Copyright 2017-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.auth.oauth2; + +import java.util.Date; +import java.util.UUID; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +public class CacheableTokenProviderTest { + + @Test + void testRetrieveCache() { + ReactiveTokenProvider reactiveTokenProvider = + () -> + Mono.fromSupplier( + () -> { + String token = UUID.randomUUID().toString(); + Date date = new Date(); + return new AccessToken(token, date); + }); + + ReactiveTokenProvider cacheableTokenProvider = + new CacheableTokenProvider(reactiveTokenProvider); + + StepVerifier.create( + Mono.zip(cacheableTokenProvider.retrieve(), cacheableTokenProvider.retrieve()) + .map(tuple -> tuple.getT1().equals(tuple.getT2()))) + .expectNext(true) + .verifyComplete(); + } + + @Test + void testRetrieveExpired() throws InterruptedException { + ReactiveTokenProvider reactiveTokenProvider = + () -> + Mono.fromSupplier( + () -> { + String token = UUID.randomUUID().toString(); + return new AccessToken(token, new Date(System.currentTimeMillis() + 4 * 1000)); + }); + + ReactiveTokenProvider cacheableTokenProvider = + new CacheableTokenProvider(reactiveTokenProvider); + + Mono firstMono = cacheableTokenProvider.retrieve(); + + AccessToken accessToken = firstMono.block(); + + Thread.sleep(1000L); + Mono secondMono = cacheableTokenProvider.retrieve(); + + StepVerifier.create(secondMono.map(at -> at.equals(accessToken))) + .expectNext(false) + .verifyComplete(); + } +} diff --git a/reactor/javatests/com/google/auth/oauth2/ComputeEngineTokenProviderTest.java b/reactor/javatests/com/google/auth/oauth2/ComputeEngineTokenProviderTest.java new file mode 100644 index 000000000..4d3cd0ca8 --- /dev/null +++ b/reactor/javatests/com/google/auth/oauth2/ComputeEngineTokenProviderTest.java @@ -0,0 +1,87 @@ +/* + * Copyright 2017-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.auth.oauth2; + +import static com.google.auth.oauth2.TokenProviderBase.*; + +import java.io.IOException; +import java.net.URISyntaxException; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.test.StepVerifier; + +public class ComputeEngineTokenProviderTest { + + private static final Long SECONDS = 3600L; + private static final String ACCESS_TOKEN = "ya29.a0AfH6SMAa-dKy_..."; + + private static String COMPUTE_ENGINE_TOKEN = + "{\"access_token\":\"" + + ACCESS_TOKEN + + "\",\"expires_in\":" + + SECONDS + + ",\"token_type\":\"Bearer\"}"; + private static String COMPUTE_ENGINE_TOKEN_BAD_RESPONSE = + "{\"token\":\"" + ACCESS_TOKEN + "\",\"in\":" + SECONDS + ",\"token_type\":\"Bearer\"}"; + + private MockWebServer mockWebServer; + + private WebClient webClient; + + private String tokenUri; + + @Before + public void setUp() throws IOException, URISyntaxException { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + + webClient = WebClient.builder().build(); + tokenUri = mockWebServer.url("/").toString(); + } + + @Test + public void testRetrieve() { + mockWebServer.enqueue(successfulResponse(COMPUTE_ENGINE_TOKEN)); + ComputeEngineCredentials computeEngineCredentials = ComputeEngineCredentials.create(); + ReactiveTokenProvider tokenProvider = + new ComputeEngineTokenProvider(webClient, computeEngineCredentials, tokenUri); + Long expirationWindowStart = addExpiration(System.currentTimeMillis()); + StepVerifier.create(tokenProvider.retrieve()) + .expectNextMatches(at -> expectedToken(expirationWindowStart, at)) + .expectNext() + .verifyComplete(); + } + + @Test + public void testRetrieveErrorParsingResponse() { + mockWebServer.enqueue(successfulResponse(COMPUTE_ENGINE_TOKEN_BAD_RESPONSE)); + ComputeEngineCredentials computeEngineCredentials = ComputeEngineCredentials.create(); + ReactiveTokenProvider tokenProvider = + new ComputeEngineTokenProvider(webClient, computeEngineCredentials, tokenUri); + StepVerifier.create(tokenProvider.retrieve()) + .expectNext() + .verifyErrorMatches(TokenProviderBase::tokenParseError); + } + + @After + public void tearDown() throws IOException { + mockWebServer.shutdown(); + } +} diff --git a/reactor/javatests/com/google/auth/oauth2/ReactiveTokenProviderTest.java b/reactor/javatests/com/google/auth/oauth2/ReactiveTokenProviderTest.java new file mode 100644 index 000000000..43f61ed9a --- /dev/null +++ b/reactor/javatests/com/google/auth/oauth2/ReactiveTokenProviderTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2017-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.auth.oauth2; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +import java.io.IOException; +import org.junit.Test; +import org.springframework.core.io.ClassPathResource; + +public class ReactiveTokenProviderTest { + + @Test + public void testCreateCacheable() throws IOException { + ClassPathResource classPathResource = new ClassPathResource("fake-credential-key.json"); + ServiceAccountCredentials serviceAccountCredentials = + ServiceAccountCredentials.fromStream(classPathResource.getInputStream()); + ReactiveTokenProvider reactiveTokenProvider = + ReactiveTokenProvider.createCacheable(serviceAccountCredentials); + assertTrue(reactiveTokenProvider instanceof CacheableTokenProvider); + } + + @Test + public void testCreateUserCredentialsTokenProvider() { + UserCredentials userCredentials = mock(UserCredentials.class); + ReactiveTokenProvider reactiveTokenProvider = ReactiveTokenProvider.create(userCredentials); + assertTrue(reactiveTokenProvider instanceof UserCredentialsTokenProvider); + } + + @Test + public void testCreateServiceAccountTokenProvider() { + ServiceAccountCredentials serviceAccountCredentials = mock(ServiceAccountCredentials.class); + ReactiveTokenProvider reactiveTokenProvider = + ReactiveTokenProvider.create(serviceAccountCredentials); + assertTrue(reactiveTokenProvider instanceof ServiceAccountTokenProvider); + } + + @Test + public void testCreateComputeEngineTokenProvider() { + ComputeEngineCredentials computeEngineCredentials = mock(ComputeEngineCredentials.class); + ReactiveTokenProvider reactiveTokenProvider = + ReactiveTokenProvider.create(computeEngineCredentials); + assertTrue(reactiveTokenProvider instanceof ComputeEngineTokenProvider); + } +} diff --git a/reactor/javatests/com/google/auth/oauth2/RefreshThresholdTest.java b/reactor/javatests/com/google/auth/oauth2/RefreshThresholdTest.java new file mode 100644 index 000000000..c8c4246d4 --- /dev/null +++ b/reactor/javatests/com/google/auth/oauth2/RefreshThresholdTest.java @@ -0,0 +1,42 @@ +/* + * Copyright 2017-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.auth.oauth2; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Date; +import org.junit.Test; + +public class RefreshThresholdTest { + + @Test + public void testCloseToExpirationTrue() { + long currentDate = System.currentTimeMillis(); + RefreshThreshold refreshThreshold = new RefreshThreshold(10_000); + AccessToken accessToken = new AccessToken("token", new Date(currentDate + 9_500)); + assertTrue(refreshThreshold.over(accessToken)); + } + + @Test + public void testCloseToExpirationFalse() { + long currentDate = System.currentTimeMillis(); + RefreshThreshold refreshThreshold = new RefreshThreshold(10_000); + AccessToken accessToken = new AccessToken("token", new Date(currentDate + 20_000)); + assertFalse(refreshThreshold.over(accessToken)); + } +} diff --git a/reactor/javatests/com/google/auth/oauth2/ServiceAccountTokenProviderTest.java b/reactor/javatests/com/google/auth/oauth2/ServiceAccountTokenProviderTest.java new file mode 100644 index 000000000..21cc1fb9a --- /dev/null +++ b/reactor/javatests/com/google/auth/oauth2/ServiceAccountTokenProviderTest.java @@ -0,0 +1,108 @@ +/* + * Copyright 2017-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.auth.oauth2; + +import static com.google.auth.oauth2.TokenProviderBase.ACCESS_TOKEN; +import static com.google.auth.oauth2.TokenProviderBase.SECONDS; +import static com.google.auth.oauth2.TokenProviderBase.addExpiration; +import static com.google.auth.oauth2.TokenProviderBase.expectedToken; +import static com.google.auth.oauth2.TokenProviderBase.successfulResponse; + +import java.io.IOException; +import java.net.URISyntaxException; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.test.StepVerifier; + +public class ServiceAccountTokenProviderTest { + + private static final ClassPathResource SA_RESOURCE = + new ClassPathResource("fake-credential-key.json"); + + private static final String SERVICE_ACCOUNT_TOKEN = + "{\n" + + " \"access_token\": \"" + + ACCESS_TOKEN + + "\",\n" + + " \"expires_in\": " + + SECONDS + + ",\n" + + " \"scope\": \"https://www.googleapis.com/auth/drive.readonly\",\n" + + " \"token_type\": \"Bearer\"\n" + + "}"; + + private static final String SERVICE_ACCOUNT_TOKEN_BAD_RESPONSE = + "{\n" + + " \"token\": \"" + + ACCESS_TOKEN + + "\",\n" + + " \"in\": " + + SECONDS + + ",\n" + + " \"scope\": \"https://www.googleapis.com/auth/drive.readonly\",\n" + + " \"token_type\": \"Bearer\"\n" + + "}"; + + private MockWebServer mockWebServer; + + private WebClient webClient; + + private String tokenUrl; + + @Before + void setUp() throws IOException, URISyntaxException { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + + webClient = WebClient.builder().build(); + tokenUrl = mockWebServer.url("/").toString(); + } + + @Test + void testRetrieve() throws IOException { + mockWebServer.enqueue(successfulResponse(SERVICE_ACCOUNT_TOKEN)); + ServiceAccountCredentials serviceAccountCredentials = + ServiceAccountCredentials.fromStream(SA_RESOURCE.getInputStream()); + ReactiveTokenProvider tokenProvider = + new ServiceAccountTokenProvider(webClient, serviceAccountCredentials, tokenUrl); + Long expirationWindowStart = addExpiration(System.currentTimeMillis()); + StepVerifier.create(tokenProvider.retrieve()) + .expectNextMatches(at -> expectedToken(expirationWindowStart, at)) + .verifyComplete(); + } + + @Test + void testRetrieveErrorParsingResponse() throws IOException { + mockWebServer.enqueue(successfulResponse(SERVICE_ACCOUNT_TOKEN_BAD_RESPONSE)); + ServiceAccountCredentials serviceAccountCredentials = + ServiceAccountCredentials.fromStream(SA_RESOURCE.getInputStream()); + ReactiveTokenProvider tokenProvider = + new ServiceAccountTokenProvider(webClient, serviceAccountCredentials, tokenUrl); + StepVerifier.create(tokenProvider.retrieve()) + .expectNext() + .verifyErrorMatches(TokenProviderBase::tokenParseError); + } + + @After + void tearDown() throws IOException { + mockWebServer.shutdown(); + } +} diff --git a/reactor/javatests/com/google/auth/oauth2/TokenProviderBase.java b/reactor/javatests/com/google/auth/oauth2/TokenProviderBase.java new file mode 100644 index 000000000..729ac9f24 --- /dev/null +++ b/reactor/javatests/com/google/auth/oauth2/TokenProviderBase.java @@ -0,0 +1,59 @@ +/* + * Copyright 2017-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.auth.oauth2; + +import static com.google.auth.oauth2.Constants.ERROR_PARSING_TOKEN_REFRESH_RESPONSE; + +import java.io.IOException; +import okhttp3.mockwebserver.MockResponse; + +public class TokenProviderBase { + + static final Long SECONDS = 3600L; + static final String ACCESS_TOKEN = "ya29.a0AfH6SMAa-dKy_..."; + + private TokenProviderBase() {} + + static MockResponse successfulResponse(String response) { + return new MockResponse() + .setHeader("Content-Type", "application/json") + .setResponseCode(200) + .setBody(response); + } + + static long addExpiration(Long millis) { + return millis + (SECONDS * 1000L); + } + + static boolean expectedToken(Long expirationWindowStart, AccessToken at) { + boolean isExpirationWithinWindow = isExpirationWithinWindow(expirationWindowStart, at); + return isExpirationWithinWindow && at.getTokenValue().equals(ACCESS_TOKEN); + } + + static boolean tokenParseError(Throwable e) { + return e instanceof IOException + && e.getMessage().contains(ERROR_PARSING_TOKEN_REFRESH_RESPONSE); + } + + static boolean isExpirationWithinWindow(Long expirationWindowStart, AccessToken at) { + Long expirationWindowEnd = addExpiration(System.currentTimeMillis()); + Long expiration = at.getExpirationTimeMillis(); + boolean expirationWithinLimits = + expiration <= expirationWindowEnd && expiration >= expirationWindowStart; + return expirationWithinLimits; + } +} diff --git a/reactor/javatests/com/google/auth/oauth2/UserCredentialsTokenProviderTest.java b/reactor/javatests/com/google/auth/oauth2/UserCredentialsTokenProviderTest.java new file mode 100644 index 000000000..b24788e44 --- /dev/null +++ b/reactor/javatests/com/google/auth/oauth2/UserCredentialsTokenProviderTest.java @@ -0,0 +1,91 @@ +/* + * Copyright 2017-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.auth.oauth2; + +import static com.google.auth.oauth2.TokenProviderBase.ACCESS_TOKEN; +import static com.google.auth.oauth2.TokenProviderBase.successfulResponse; + +import java.io.IOException; +import java.net.URISyntaxException; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.test.StepVerifier; + +public class UserCredentialsTokenProviderTest { + + private static String USER_CREDENTIALS_TOKEN = + "{\"access_token\":\"" + + ACCESS_TOKEN + + "\",\"token_type\":\"Bearer\",\"expires_in\":3599,\"refresh_token\":\"test_refresh_token\",\"scope\":\"https://www.googleapis.com/auth/cloud-platform\"}"; + + private static String USER_CREDENTIALS_TOKEN_BAD_RESPONSE = + "{\"token\":\"" + + ACCESS_TOKEN + + "\",\"token_type\":\"Bearer\",\"in\":3599,\"refresh_token\":\"test_refresh_token\",\"scope\":\"https://www.googleapis.com/auth/cloud-platform\"}"; + + private MockWebServer mockWebServer; + + private WebClient webClient; + + private String tokenUrl; + + @Before + public void setUp() throws IOException, URISyntaxException { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + webClient = WebClient.builder().build(); + tokenUrl = mockWebServer.url("/").toString(); + } + + @Test + public void testRetrieve() throws Exception { + mockWebServer.enqueue(successfulResponse(USER_CREDENTIALS_TOKEN)); + UserCredentials userCredentials = createUserCredentials(); + ReactiveTokenProvider tokenProvider = + new UserCredentialsTokenProvider(webClient, userCredentials, tokenUrl); + StepVerifier.create(tokenProvider.retrieve().map(AccessToken::getTokenValue)) + .expectNext(ACCESS_TOKEN) + .verifyComplete(); + } + + @Test + public void testRetrieveErrorParsingResponse() { + mockWebServer.enqueue(successfulResponse(USER_CREDENTIALS_TOKEN_BAD_RESPONSE)); + UserCredentials userCredentials = createUserCredentials(); + ReactiveTokenProvider tokenProvider = + new UserCredentialsTokenProvider(webClient, userCredentials, tokenUrl); + StepVerifier.create(tokenProvider.retrieve()) + .expectNext() + .verifyErrorMatches(TokenProviderBase::tokenParseError); + } + + @After + public void tearDown() throws IOException { + mockWebServer.shutdown(); + } + + private static UserCredentials createUserCredentials() { + return UserCredentials.newBuilder() + .setClientId("test_client_id") + .setClientSecret("test_client_secret") + .setRefreshToken("refresh_token") + .build(); + } +} diff --git a/reactor/javatests/fake-credential-key.json b/reactor/javatests/fake-credential-key.json new file mode 100644 index 000000000..56b44a223 --- /dev/null +++ b/reactor/javatests/fake-credential-key.json @@ -0,0 +1,12 @@ +{ + "type": "service_account", + "project_id": "fake-project-id", + "private_key_id": "fake-key", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBANJrTOsRMUr/Mkvq\nVLfTkwASU3YmQEioDD/7kmrGLHzQDHl8H/d4AdnGJ9i6XXQTJnfZScIsEfzXrK/B\n3Zavpf3u41VJR9n8VqTNtlR9aCic+h4H7vhZAKD2HvkI1KW69ST+WYZNeZpAU5Z/\nzF1ndrmkJCu70usGX1Pzbz3SnnBvAgMBAAECgYAgzbuTFf4STBCiRyjn86MCKtk6\nHSJ+cTxfqS+dV8HNv32CXvh40wuu0LabkgpJs0aW/pgCHm67dUAlslqCSGXfFUCJ\n46doBP7B5Fh1uNUQNFRGxHXNKKhXX2b1si5Py5zRtM4KP0Of61ExRO+Z5MODwFad\n9kSmDShyAC02z7/AUQJBAO3pFt65VHsdE5JRg376195PbQaRtDHeTFkdHVtKSFYF\nQI5GsAOy8OgLXHPzsl+ot2bbiHFMfptAD8nBXRogwScCQQDiaxgACPrO56lCJnMZ\nxM+etTzZ3IL1G/co6cbNb9sagSkgYDwPJcVCThNBhSjncmPcwfpW6aNr/JWa7Z8M\nkNN5AkAzzmEDiPnjgTZk00k+GmNtboBAQPQrM8wOT6+31FoiGSywjqX/eDTLYsX0\nHeoGuJePV1jDyzN6nR2TAn9ClEVbAkEAgKrxYZu4w/ncMu5cvIken4dJBFmOxjHV\nPBfV1Qs6zQ4XTAHEP6tsNOjfgn1kqFpWK67ET73IE+bfMcLVfrOSqQJAFkDMY8B7\nG88fVmSVUnh5NHFP+oE5W5GjuMfnWi5VWfgJORyhzVB2zX1LhEOOy8RRyfUAfq1Q\nrz7IoJTmHNfn9A==\n-----END PRIVATE KEY-----\n", + "client_email": "test@fake-project.iam.gserviceaccount.com", + "client_id": "45678", + "auth_uri": "https://fake.auth.url.com/o/oauth2/auth", + "token_uri": "https://fake.token.url.com/token", + "auth_provider_x509_cert_url": "https://fake.auth.provider.cert.url.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.fake.client.cert.com/robot/v1/metadata/x509/test@fake-project.iam.gserviceaccount.com" +} \ No newline at end of file diff --git a/reactor/pom.xml b/reactor/pom.xml new file mode 100644 index 000000000..bcd85e0bf --- /dev/null +++ b/reactor/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + com.google.auth + google-auth-library-parent + 1.19.1-SNAPSHOT + ../pom.xml + + + google-auth-library-reactor + Google Auth Library for Java - Reactor Binding + + + + ossrh + https://google.oss.sonatype.org/content/repositories/snapshots + + + + + java + javatests + + + javatests + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + + + org.apache.maven.plugins + maven-source-plugin + + + org.apache.maven.plugins + maven-javadoc-plugin + + + org.apache.maven.plugins + maven-jar-plugin + + + + com.google.auth + + + + + + + + + + org.springframework.boot + spring-boot-starter-webflux + 2.7.8 + true + + + com.google.auth + google-auth-library-oauth2-http + + + io.projectreactor + reactor-test + test + 3.5.10 + + + com.squareup.okhttp3 + mockwebserver + 4.10.0 + test + + + junit + junit + test + + + org.mockito + mockito-core + 4.11.0 + test + + + +