diff --git a/sdk/identity/azure-identity/src/main/java/com/azure/identity/CredentialBuilderBase.java b/sdk/identity/azure-identity/src/main/java/com/azure/identity/CredentialBuilderBase.java index 5f25673b34ed..51b428079412 100644 --- a/sdk/identity/azure-identity/src/main/java/com/azure/identity/CredentialBuilderBase.java +++ b/sdk/identity/azure-identity/src/main/java/com/azure/identity/CredentialBuilderBase.java @@ -84,4 +84,21 @@ public T httpClient(HttpClient client) { this.identityClientOptions.setHttpClient(client); return (T) this; } + + /** + * Sets how long before the actual token expiry to refresh the token. The + * token will be considered expired at and after the time of (actual + * expiry - token refresh offset). The default offset is 2 minutes. + * + * This is useful when network is congested and a request containing the + * token takes longer than normal to get to the server. + * + * @param tokenRefreshOffset the duration before the actual expiry of a token to refresh it + * @return An updated instance of this builder with the token refresh offset set as specified. + */ + @SuppressWarnings("unchecked") + public T tokenRefreshOffset(Duration tokenRefreshOffset) { + this.identityClientOptions.setTokenRefreshOffset(tokenRefreshOffset); + return (T) this; + } } diff --git a/sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/IdentityClient.java b/sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/IdentityClient.java index f017d56fbc07..1819a2d7f01f 100644 --- a/sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/IdentityClient.java +++ b/sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/IdentityClient.java @@ -46,7 +46,6 @@ import java.nio.file.Paths; import java.time.Duration; import java.time.OffsetDateTime; -import java.time.ZoneOffset; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -147,10 +146,8 @@ public Mono authenticateWithClientSecret(String clientSecret, Token ConfidentialClientApplication application = applicationBuilder.build(); return Mono.fromFuture(application.acquireToken( ClientCredentialParameters.builder(new HashSet<>(request.getScopes())) - .build())) - .map(ar -> new AccessToken(ar.accessToken(), OffsetDateTime.ofInstant(ar.expiresOnDate().toInstant(), - ZoneOffset.UTC))); - + .build())) + .map(ar -> new MsalToken(ar, options)); } catch (MalformedURLException e) { return Mono.error(e); } @@ -194,8 +191,7 @@ public Mono authenticateWithPfxCertificate(String pfxCertificatePat return applicationBuilder.build(); }).flatMap(application -> Mono.fromFuture(application.acquireToken( ClientCredentialParameters.builder(new HashSet<>(request.getScopes())).build()))) - .map(ar -> new AccessToken(ar.accessToken(), OffsetDateTime.ofInstant(ar.expiresOnDate().toInstant(), - ZoneOffset.UTC))); + .map(ar -> new MsalToken(ar, options)); } /** @@ -226,8 +222,7 @@ public Mono authenticateWithPemCertificate(String pemCertificatePat return Mono.fromFuture(application.acquireToken( ClientCredentialParameters.builder(new HashSet<>(request.getScopes())) .build())) - .map(ar -> new AccessToken(ar.accessToken(), OffsetDateTime.ofInstant(ar.expiresOnDate().toInstant(), - ZoneOffset.UTC))); + .map(ar -> new MsalToken(ar, options)); } catch (IOException e) { return Mono.error(e); } @@ -246,7 +241,7 @@ public Mono authenticateWithUsernamePassword(TokenRequestContext requ return Mono.fromFuture(publicClientApplication.acquireToken( UserNamePasswordParameters.builder(new HashSet<>(request.getScopes()), username, password.toCharArray()) .build())) - .map(MsalToken::new); + .map(ar -> new MsalToken(ar, options)); } /** @@ -264,7 +259,8 @@ public Mono authenticateWithUserRefreshToken(TokenRequestContext requ } return Mono.defer(() -> { try { - return Mono.fromFuture(publicClientApplication.acquireTokenSilently(parameters)).map(MsalToken::new); + return Mono.fromFuture(publicClientApplication.acquireTokenSilently(parameters)) + .map(ar -> new MsalToken(ar, options)); } catch (MalformedURLException e) { return Mono.error(e); } @@ -288,7 +284,7 @@ public Mono authenticateWithDeviceCode(TokenRequestContext request, dc -> deviceCodeConsumer.accept(new DeviceCodeInfo(dc.userCode(), dc.deviceCode(), dc.verificationUri(), OffsetDateTime.now().plusSeconds(dc.expiresIn()), dc.message()))).build(); return publicClientApplication.acquireToken(parameters); - }).map(MsalToken::new); + }).map(ar -> new MsalToken(ar, options)); } /** @@ -305,7 +301,7 @@ public Mono authenticateWithAuthorizationCode(TokenRequestContext req AuthorizationCodeParameters.builder(authorizationCode, redirectUrl) .scopes(new HashSet<>(request.getScopes())) .build())) - .map(MsalToken::new); + .map(ar -> new MsalToken(ar, options)); } /** @@ -362,11 +358,11 @@ public Mono authenticateWithBrowserInteraction(TokenRequestContext re */ public Mono authenticateToManagedIdentityEndpoint(String msiEndpoint, String msiSecret, TokenRequestContext request) { - String resource = ScopeUtil.scopesToResource(request.getScopes()); - HttpURLConnection connection = null; - StringBuilder payload = new StringBuilder(); + return Mono.fromCallable(() -> { + String resource = ScopeUtil.scopesToResource(request.getScopes()); + HttpURLConnection connection = null; + StringBuilder payload = new StringBuilder(); - try { payload.append("resource="); payload.append(URLEncoder.encode(resource, "UTF-8")); payload.append("&api-version="); @@ -375,32 +371,30 @@ public Mono authenticateToManagedIdentityEndpoint(String msiEndpoin payload.append("&clientid="); payload.append(URLEncoder.encode(clientId, "UTF-8")); } - } catch (IOException exception) { - return Mono.error(exception); - } - try { - URL url = new URL(String.format("%s?%s", msiEndpoint, payload)); - connection = (HttpURLConnection) url.openConnection(); + try { + URL url = new URL(String.format("%s?%s", msiEndpoint, payload)); + connection = (HttpURLConnection) url.openConnection(); - connection.setRequestMethod("GET"); - if (msiSecret != null) { - connection.setRequestProperty("Secret", msiSecret); - } - connection.setRequestProperty("Metadata", "true"); + connection.setRequestMethod("GET"); + if (msiSecret != null) { + connection.setRequestProperty("Secret", msiSecret); + } + connection.setRequestProperty("Metadata", "true"); - connection.connect(); + connection.connect(); - Scanner s = new Scanner(connection.getInputStream(), StandardCharsets.UTF_8.name()).useDelimiter("\\A"); - String result = s.hasNext() ? s.next() : ""; + Scanner s = new Scanner(connection.getInputStream(), StandardCharsets.UTF_8.name()) + .useDelimiter("\\A"); + String result = s.hasNext() ? s.next() : ""; - return Mono.just(SERIALIZER_ADAPTER.deserialize(result, MSIToken.class, SerializerEncoding.JSON)); - } catch (IOException e) { - return Mono.error(e); - } finally { - if (connection != null) { - connection.disconnect(); + MSIToken msiToken = SERIALIZER_ADAPTER.deserialize(result, MSIToken.class, SerializerEncoding.JSON); + return new IdentityToken(msiToken.getToken(), msiToken.getExpiresAt(), options); + } finally { + if (connection != null) { + connection.disconnect(); + } } - } + }); } /** @@ -446,7 +440,9 @@ public Mono authenticateToIMDSEndpoint(TokenRequestContext request) .useDelimiter("\\A"); String result = s.hasNext() ? s.next() : ""; - return SERIALIZER_ADAPTER.deserialize(result, MSIToken.class, SerializerEncoding.JSON); + MSIToken msiToken = SERIALIZER_ADAPTER.deserialize(result, + MSIToken.class, SerializerEncoding.JSON); + return new IdentityToken(msiToken.getToken(), msiToken.getExpiresAt(), options); } catch (IOException exception) { if (connection == null) { throw logger.logExceptionAsError(new RuntimeException( diff --git a/sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/IdentityClientOptions.java b/sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/IdentityClientOptions.java index 10894e3465af..15fe75bf9dd3 100644 --- a/sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/IdentityClientOptions.java +++ b/sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/IdentityClientOptions.java @@ -22,6 +22,7 @@ public final class IdentityClientOptions { private Function retryTimeout; private ProxyOptions proxyOptions; private HttpPipeline httpPipeline; + private Duration tokenRefreshOffset = Duration.ofMinutes(2); private HttpClient httpClient; /** @@ -125,6 +126,30 @@ public IdentityClientOptions setHttpPipeline(HttpPipeline httpPipeline) { return this; } + /** + * @return how long before the actual token expiry to refresh the token. + */ + public Duration getTokenRefreshOffset() { + return tokenRefreshOffset; + } + + /** + * Sets how long before the actual token expiry to refresh the token. The + * token will be considered expired at and after the time of (actual + * expiry - token refresh offset). The default offset is 2 minutes. + * + * This is useful when network is congested and a request containing the + * token takes longer than normal to get to the server. + * + * @param tokenRefreshOffset the duration before the actual expiry of a token to refresh it + */ + public IdentityClientOptions setTokenRefreshOffset(Duration tokenRefreshOffset) { + if (tokenRefreshOffset != null) { + this.tokenRefreshOffset = tokenRefreshOffset; + } + return this; + } + /** * Specifies the HttpClient to send use for requests. * @param httpClient the http client to use for requests diff --git a/sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/IdentityToken.java b/sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/IdentityToken.java new file mode 100644 index 000000000000..c6973b3d99a8 --- /dev/null +++ b/sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/IdentityToken.java @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.identity.implementation; + +import com.azure.core.credential.AccessToken; + +import java.time.OffsetDateTime; + +/** + * Type representing authentication result from the azure-identity client. + */ +public class IdentityToken extends AccessToken { + /** + * Creates an identity token instance. + * + * @param token the token string. + * @param expiresAt the expiration time. + * @param options the identity client options. + */ + public IdentityToken(String token, OffsetDateTime expiresAt, IdentityClientOptions options) { + super(token, expiresAt.plusMinutes(2).minus(options.getTokenRefreshOffset())); + } +} diff --git a/sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/MsalToken.java b/sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/MsalToken.java index e00e699709bf..5ccc94892a49 100644 --- a/sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/MsalToken.java +++ b/sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/MsalToken.java @@ -3,7 +3,6 @@ package com.azure.identity.implementation; -import com.azure.core.credential.AccessToken; import com.microsoft.aad.msal4j.IAccount; import com.microsoft.aad.msal4j.IAuthenticationResult; @@ -13,7 +12,7 @@ /** * Type representing authentication result from the MSAL (Microsoft Authentication Library). */ -public final class MsalToken extends AccessToken { +public final class MsalToken extends IdentityToken { private IAccount account; @@ -22,9 +21,10 @@ public final class MsalToken extends AccessToken { * * @param msalResult the raw authentication result returned by MSAL */ - public MsalToken(IAuthenticationResult msalResult) { - super(msalResult.accessToken(), OffsetDateTime.ofInstant(msalResult.expiresOnDate().toInstant(), - ZoneOffset.UTC)); + public MsalToken(IAuthenticationResult msalResult, IdentityClientOptions options) { + super(msalResult.accessToken(), + OffsetDateTime.ofInstant(msalResult.expiresOnDate().toInstant(), ZoneOffset.UTC), + options); this.account = msalResult.account(); } diff --git a/sdk/identity/azure-identity/src/test/java/com/azure/identity/ClientSecretCredentialTest.java b/sdk/identity/azure-identity/src/test/java/com/azure/identity/ClientSecretCredentialTest.java index b43feadb7027..3cb4359cb004 100644 --- a/sdk/identity/azure-identity/src/test/java/com/azure/identity/ClientSecretCredentialTest.java +++ b/sdk/identity/azure-identity/src/test/java/com/azure/identity/ClientSecretCredentialTest.java @@ -17,6 +17,7 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import java.time.Duration; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.UUID; @@ -61,6 +62,40 @@ public void testValidSecrets() throws Exception { .verifyComplete(); } + @Test + public void testValidSecretsWithTokenRefreshOffset() throws Exception { + // setup + String secret = "secret"; + String token1 = "token1"; + String token2 = "token2"; + TokenRequestContext request1 = new TokenRequestContext().addScopes("https://management.azure.com"); + TokenRequestContext request2 = new TokenRequestContext().addScopes("https://vault.azure.net"); + OffsetDateTime expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusHours(1); + Duration offset = Duration.ofMinutes(10); + + // mock + IdentityClient identityClient = PowerMockito.mock(IdentityClient.class); + when(identityClient.authenticateWithClientSecret(secret, request1)).thenReturn(TestUtils.getMockAccessToken(token1, expiresAt, offset)); + when(identityClient.authenticateWithClientSecret(secret, request2)).thenReturn(TestUtils.getMockAccessToken(token2, expiresAt, offset)); + PowerMockito.whenNew(IdentityClient.class).withAnyArguments().thenReturn(identityClient); + + // test + ClientSecretCredential credential = new ClientSecretCredentialBuilder() + .tenantId(tenantId) + .clientId(clientId) + .clientSecret(secret) + .tokenRefreshOffset(offset) + .build(); + StepVerifier.create(credential.getToken(request1)) + .expectNextMatches(accessToken -> token1.equals(accessToken.getToken()) + && expiresAt.getSecond() == accessToken.getExpiresAt().getSecond()) + .verifyComplete(); + StepVerifier.create(credential.getToken(request2)) + .expectNextMatches(accessToken -> token2.equals(accessToken.getToken()) + && expiresAt.getSecond() == accessToken.getExpiresAt().getSecond()) + .verifyComplete(); + } + @Test public void testInvalidSecrets() throws Exception { // setup diff --git a/sdk/identity/azure-identity/src/test/java/com/azure/identity/ManagedIdentityCredentialTest.java b/sdk/identity/azure-identity/src/test/java/com/azure/identity/ManagedIdentityCredentialTest.java index 54c9c8b9c37b..ebaf988af222 100644 --- a/sdk/identity/azure-identity/src/test/java/com/azure/identity/ManagedIdentityCredentialTest.java +++ b/sdk/identity/azure-identity/src/test/java/com/azure/identity/ManagedIdentityCredentialTest.java @@ -16,6 +16,7 @@ import org.powermock.modules.junit4.PowerMockRunner; import reactor.test.StepVerifier; +import java.time.Duration; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.UUID; @@ -83,8 +84,68 @@ public void testIMDS() throws Exception { // test ManagedIdentityCredential credential = new ManagedIdentityCredentialBuilder().clientId(clientId).build(); StepVerifier.create(credential.getToken(request)) - .expectNextMatches(token -> token1.equals(token.getToken()) - && expiresOn.getSecond() == token.getExpiresAt().getSecond()) - .verifyComplete(); + .expectNextMatches(token -> token1.equals(token.getToken()) + && expiresOn.getSecond() == token.getExpiresAt().getSecond()) + .verifyComplete(); + } + + @Test + public void testMSIEndpointWithTokenRefreshOffset() throws Exception { + Configuration configuration = Configuration.getGlobalConfiguration(); + + try { + // setup + String endpoint = "http://localhost"; + String secret = "secret"; + String token1 = "token1"; + TokenRequestContext request1 = new TokenRequestContext().addScopes("https://management.azure.com"); + OffsetDateTime expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusHours(1); + configuration.put("MSI_ENDPOINT", endpoint); + configuration.put("MSI_SECRET", secret); + Duration offset = Duration.ofMinutes(10); + + // mock + IdentityClient identityClient = PowerMockito.mock(IdentityClient.class); + when(identityClient.authenticateToManagedIdentityEndpoint(endpoint, secret, request1)).thenReturn(TestUtils.getMockAccessToken(token1, expiresAt, offset)); + PowerMockito.whenNew(IdentityClient.class).withAnyArguments().thenReturn(identityClient); + + // test + ManagedIdentityCredential credential = new ManagedIdentityCredentialBuilder() + .clientId(clientId) + .tokenRefreshOffset(offset) + .build(); + StepVerifier.create(credential.getToken(request1)) + .expectNextMatches(token -> token1.equals(token.getToken()) + && expiresAt.getSecond() == token.getExpiresAt().getSecond()) + .verifyComplete(); + } finally { + // clean up + configuration.remove("MSI_ENDPOINT"); + configuration.remove("MSI_SECRET"); + } + } + + @Test + public void testIMDSWithTokenRefreshOffset() throws Exception { + // setup + String token1 = "token1"; + TokenRequestContext request = new TokenRequestContext().addScopes("https://management.azure.com"); + OffsetDateTime expiresOn = OffsetDateTime.now(ZoneOffset.UTC).plusHours(1); + Duration offset = Duration.ofMinutes(10); + + // mock + IdentityClient identityClient = PowerMockito.mock(IdentityClient.class); + when(identityClient.authenticateToIMDSEndpoint(request)).thenReturn(TestUtils.getMockAccessToken(token1, expiresOn, offset)); + PowerMockito.whenNew(IdentityClient.class).withAnyArguments().thenReturn(identityClient); + + // test + ManagedIdentityCredential credential = new ManagedIdentityCredentialBuilder() + .clientId(clientId) + .tokenRefreshOffset(offset) + .build(); + StepVerifier.create(credential.getToken(request)) + .expectNextMatches(token -> token1.equals(token.getToken()) + && expiresOn.getSecond() == token.getExpiresAt().getSecond()) + .verifyComplete(); } } diff --git a/sdk/identity/azure-identity/src/test/java/com/azure/identity/util/TestUtils.java b/sdk/identity/azure-identity/src/test/java/com/azure/identity/util/TestUtils.java index 78545f83ab39..a041b0977c43 100644 --- a/sdk/identity/azure-identity/src/test/java/com/azure/identity/util/TestUtils.java +++ b/sdk/identity/azure-identity/src/test/java/com/azure/identity/util/TestUtils.java @@ -4,11 +4,13 @@ package com.azure.identity.util; import com.azure.core.credential.AccessToken; +import com.azure.identity.implementation.IdentityClientOptions; import com.azure.identity.implementation.MsalToken; import com.microsoft.aad.msal4j.IAccount; import com.microsoft.aad.msal4j.IAuthenticationResult; import reactor.core.publisher.Mono; +import java.time.Duration; import java.time.OffsetDateTime; import java.util.Date; import java.util.UUID; @@ -82,7 +84,7 @@ public Date expiresOnDate() { */ public static Mono getMockMsalToken(String accessToken, OffsetDateTime expiresOn) { return Mono.fromFuture(getMockAuthenticationResult(accessToken, expiresOn)) - .map(MsalToken::new); + .map(ar -> new MsalToken(ar, new IdentityClientOptions())); } /** @@ -95,6 +97,17 @@ public static Mono getMockAccessToken(String accessToken, OffsetDat return Mono.just(new AccessToken(accessToken, expiresOn.plusMinutes(2))); } + /** + * Creates a mock {@link AccessToken} instance. + * @param accessToken the access token to return + * @param expiresOn the expiration time + * @param tokenRefreshOffset how long before the actual expiry to refresh the token + * @return a Mono publisher of the result + */ + public static Mono getMockAccessToken(String accessToken, OffsetDateTime expiresOn, Duration tokenRefreshOffset) { + return Mono.just(new AccessToken(accessToken, expiresOn.plusMinutes(2).minus(tokenRefreshOffset))); + } + private TestUtils() { } }