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
+
+
+
+