diff --git a/CHANGELOG.md b/CHANGELOG.md index fdaa6be84..1faa2086e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.0] - 2024-09-30 + +### Added +- Adds an `AuthorizationHandler` that authenticates requests using a provided `BaseBearerTokenAuthenticationProvider`. Opting in to use this middleware can be done +via `KiotaClientFactory.create(authProvider)`. + + ## [1.4.0] - 2024-09-11 ### Changed diff --git a/README.md b/README.md index 960a94716..06e7245c8 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,14 @@ Read more about Kiota [here](https://github.com/microsoft/kiota/blob/main/README In `build.gradle` in the `dependencies` section: ```Groovy -implementation 'com.microsoft.kiota:microsoft-kiota-abstractions:1.3.0' -implementation 'com.microsoft.kiota:microsoft-kiota-authentication-azure:1.3.0' -implementation 'com.microsoft.kiota:microsoft-kiota-http-okHttp:1.3.0' -implementation 'com.microsoft.kiota:microsoft-kiota-serialization-json:1.3.0' -implementation 'com.microsoft.kiota:microsoft-kiota-serialization-text:1.3.0' -implementation 'com.microsoft.kiota:microsoft-kiota-serialization-form:1.3.0' -implementation 'com.microsoft.kiota:microsoft-kiota-serialization-multipart:1.3.0' -implementation 'com.microsoft.kiota:microsoft-kiota-bundle:1.3.0' +implementation 'com.microsoft.kiota:microsoft-kiota-abstractions:1.5.0' +implementation 'com.microsoft.kiota:microsoft-kiota-authentication-azure:1.5.0' +implementation 'com.microsoft.kiota:microsoft-kiota-http-okHttp:1.5.0' +implementation 'com.microsoft.kiota:microsoft-kiota-serialization-json:1.5.0' +implementation 'com.microsoft.kiota:microsoft-kiota-serialization-text:1.5.0' +implementation 'com.microsoft.kiota:microsoft-kiota-serialization-form:1.5.0' +implementation 'com.microsoft.kiota:microsoft-kiota-serialization-multipart:1.5.0' +implementation 'com.microsoft.kiota:microsoft-kiota-bundle:1.5.0' implementation 'jakarta.annotation:jakarta.annotation-api:2.1.1' ``` @@ -40,37 +40,37 @@ In `pom.xml` in the `dependencies` section: com.microsoft.kiota microsoft-kiota-abstractions - 1.3.0 + 1.5.0 com.microsoft.kiota microsoft-kiota-authentication-azure - 1.3.0 + 1.5.0 com.microsoft.kiota microsoft-kiota-http-okHttp - 1.3.0 + 1.5.0 com.microsoft.kiota microsoft-kiota-serialization-json - 1.3.0 + 1.5.0 com.microsoft.kiota microsoft-kiota-serialization-text - 1.3.0 + 1.5.0 com.microsoft.kiota microsoft-kiota-serialization-form - 1.3.0 + 1.5.0 com.microsoft.kiota microsoft-kiota-serialization-multipart - 1.3.0 + 1.5.0 jakarta.annotation diff --git a/components/abstractions/src/main/java/com/microsoft/kiota/authentication/BaseBearerTokenAuthenticationProvider.java b/components/abstractions/src/main/java/com/microsoft/kiota/authentication/BaseBearerTokenAuthenticationProvider.java index d8dda1c47..819c63bf9 100644 --- a/components/abstractions/src/main/java/com/microsoft/kiota/authentication/BaseBearerTokenAuthenticationProvider.java +++ b/components/abstractions/src/main/java/com/microsoft/kiota/authentication/BaseBearerTokenAuthenticationProvider.java @@ -50,4 +50,8 @@ public void authenticateRequest( } } } + + public @Nonnull AccessTokenProvider getAccessTokenProvider() { + return this.accessTokenProvider; + } } diff --git a/components/http/okHttp/spotBugsExcludeFilter.xml b/components/http/okHttp/spotBugsExcludeFilter.xml index 1ab81c79f..a242f453f 100644 --- a/components/http/okHttp/spotBugsExcludeFilter.xml +++ b/components/http/okHttp/spotBugsExcludeFilter.xml @@ -29,7 +29,7 @@ - + @@ -37,6 +37,6 @@ - + - \ No newline at end of file + diff --git a/components/http/okHttp/src/main/java/com/microsoft/kiota/http/ContinuousAccessEvaluationClaims.java b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/ContinuousAccessEvaluationClaims.java new file mode 100644 index 000000000..96295ed84 --- /dev/null +++ b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/ContinuousAccessEvaluationClaims.java @@ -0,0 +1,60 @@ +package com.microsoft.kiota.http; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; + +import okhttp3.Response; + +import java.util.List; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Helper class to extract the claims from the WWW-Authenticate header in a response. + * https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-continuous-access-evaluation + */ +public final class ContinuousAccessEvaluationClaims { + + private static final Pattern bearerPattern = + Pattern.compile("^Bearer\\s.*", Pattern.CASE_INSENSITIVE); + private static final Pattern claimsPattern = + Pattern.compile("\\s?claims=\"([^\"]+)\"", Pattern.CASE_INSENSITIVE); + + private static final String WWW_AUTHENTICATE_HEADER = "WWW-Authenticate"; + + private ContinuousAccessEvaluationClaims() {} + + /** + * Extracts the claims from the WWW-Authenticate header in a response. + * @param response the response to extract the claims from. + * @return the claims + */ + public static @Nullable String getClaimsFromResponse(@Nonnull Response response) { + Objects.requireNonNull(response, "parameter response cannot be null"); + if (response.code() != 401) { + return null; + } + final List authenticateHeader = response.headers(WWW_AUTHENTICATE_HEADER); + if (!authenticateHeader.isEmpty()) { + String rawHeaderValue = null; + for (final String authenticateEntry : authenticateHeader) { + final Matcher matcher = bearerPattern.matcher(authenticateEntry); + if (matcher.matches()) { + rawHeaderValue = authenticateEntry.replaceFirst("^Bearer\\s", ""); + break; + } + } + if (rawHeaderValue != null) { + final String[] parameters = rawHeaderValue.split(","); + for (final String parameter : parameters) { + final Matcher matcher = claimsPattern.matcher(parameter); + if (matcher.matches()) { + return matcher.group(1); + } + } + } + } + return null; + } +} diff --git a/components/http/okHttp/src/main/java/com/microsoft/kiota/http/KiotaClientFactory.java b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/KiotaClientFactory.java index cb81973c8..5f86565e4 100644 --- a/components/http/okHttp/src/main/java/com/microsoft/kiota/http/KiotaClientFactory.java +++ b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/KiotaClientFactory.java @@ -1,5 +1,7 @@ package com.microsoft.kiota.http; +import com.microsoft.kiota.authentication.BaseBearerTokenAuthenticationProvider; +import com.microsoft.kiota.http.middleware.AuthorizationHandler; import com.microsoft.kiota.http.middleware.HeadersInspectionHandler; import com.microsoft.kiota.http.middleware.ParametersNameDecodingHandler; import com.microsoft.kiota.http.middleware.RedirectHandler; @@ -13,6 +15,9 @@ import okhttp3.OkHttpClient; import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; /** This class is used to build the HttpClient instance used by the core service. */ public class KiotaClientFactory { @@ -23,7 +28,7 @@ private KiotaClientFactory() {} * @return an OkHttpClient Builder instance. */ @Nonnull public static OkHttpClient.Builder create() { - return create(null); + return create(createDefaultInterceptors()); } /** @@ -48,6 +53,31 @@ private KiotaClientFactory() {} return builder; } + /** + * Creates an OkHttpClient Builder with the default configuration and middleware. + * @param interceptors The interceptors to add to the client. Will default to createDefaultInterceptors() if null. + * @return an OkHttpClient Builder instance. + */ + @Nonnull public static OkHttpClient.Builder create(@Nullable final List interceptors) { + if (interceptors == null) { + return create(); + } + return create( + (new ArrayList<>(interceptors)).toArray(new Interceptor[interceptors.size()])); + } + + /** + * Creates an OkHttpClient Builder with the default configuration and middleware including the AuthorizationHandler. + * @param authenticationProvider authentication provider to use for the AuthorizationHandler. + * @return an OkHttpClient Builder instance. + */ + @Nonnull public static OkHttpClient.Builder create( + @Nonnull final BaseBearerTokenAuthenticationProvider authenticationProvider) { + ArrayList interceptors = new ArrayList<>(createDefaultInterceptorsAsList()); + interceptors.add(new AuthorizationHandler(authenticationProvider)); + return create(interceptors); + } + /** * Creates the default interceptors for the client. * @return an array of interceptors. @@ -61,4 +91,12 @@ private KiotaClientFactory() {} new HeadersInspectionHandler() }; } + + /** + * Creates the default interceptors for the client. + * @return an array of interceptors. + */ + @Nonnull public static List createDefaultInterceptorsAsList() { + return new ArrayList<>(Arrays.asList(createDefaultInterceptors())); + } } diff --git a/components/http/okHttp/src/main/java/com/microsoft/kiota/http/OkHttpRequestAdapter.java b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/OkHttpRequestAdapter.java index 028afe64c..b634cf7da 100644 --- a/components/http/okHttp/src/main/java/com/microsoft/kiota/http/OkHttpRequestAdapter.java +++ b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/OkHttpRequestAdapter.java @@ -48,7 +48,6 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; -import java.util.regex.Matcher; import java.util.regex.Pattern; /** RequestAdapter implementation for OkHttp */ @@ -753,11 +752,6 @@ private String getHeaderValue(final Response response, String key) { return null; } - private static final Pattern bearerPattern = - Pattern.compile("^Bearer\\s.*", Pattern.CASE_INSENSITIVE); - private static final Pattern claimsPattern = - Pattern.compile("\\s?claims=\"([^\"]+)\"", Pattern.CASE_INSENSITIVE); - /** Key used for events when an authentication challenge is returned by the API */ @Nonnull public static final String authenticateChallengedEventKey = "com.microsoft.kiota.authenticate_challenge_received"; @@ -804,26 +798,7 @@ String getClaimsFromResponse( && (claims == null || claims.isEmpty()) && // we avoid infinite loops and retry only once (requestInfo.content == null || requestInfo.content.markSupported())) { - final List authenticateHeader = response.headers("WWW-Authenticate"); - if (!authenticateHeader.isEmpty()) { - String rawHeaderValue = null; - for (final String authenticateEntry : authenticateHeader) { - final Matcher matcher = bearerPattern.matcher(authenticateEntry); - if (matcher.matches()) { - rawHeaderValue = authenticateEntry.replaceFirst("^Bearer\\s", ""); - break; - } - } - if (rawHeaderValue != null) { - final String[] parameters = rawHeaderValue.split(","); - for (final String parameter : parameters) { - final Matcher matcher = claimsPattern.matcher(parameter); - if (matcher.matches()) { - return matcher.group(1); - } - } - } - } + return ContinuousAccessEvaluationClaims.getClaimsFromResponse(response); } return null; } diff --git a/components/http/okHttp/src/main/java/com/microsoft/kiota/http/TelemetrySemanticConventions.java b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/TelemetrySemanticConventions.java index d5546409a..6fdb412fc 100644 --- a/components/http/okHttp/src/main/java/com/microsoft/kiota/http/TelemetrySemanticConventions.java +++ b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/TelemetrySemanticConventions.java @@ -5,30 +5,79 @@ import io.opentelemetry.api.common.AttributeKey; +/** + * This class contains the telemetry attribute keys used by this library. + */ public final class TelemetrySemanticConventions { private TelemetrySemanticConventions() {} // https://opentelemetry.io/docs/specs/semconv/attributes-registry/ + + /** + * HTTP Response status code + */ public static final AttributeKey HTTP_RESPONSE_STATUS_CODE = longKey("http.response.status_code"); // stable + + /** + * HTTP Request resend count + */ public static final AttributeKey HTTP_REQUEST_RESEND_COUNT = longKey("http.request.resend_count"); // stable + + /** + * HTTP Request method + */ public static final AttributeKey HTTP_REQUEST_METHOD = stringKey("http.request.method"); // stable + + /** + * Network connection protocol version + */ public static final AttributeKey NETWORK_PROTOCOL_VERSION = stringKey("network.protocol.version"); // stable + + /** + * Full HTTP request URL + */ public static final AttributeKey URL_FULL = stringKey("url.full"); // stable + + /** + * HTTP request URL scheme + */ public static final AttributeKey URL_SCHEME = stringKey("url.scheme"); // stable + + /** + * HTTP request destination server address + */ public static final AttributeKey SERVER_ADDRESS = stringKey("server.address"); // stable + + /** + * HTTP request destination server port + */ public static final AttributeKey SERVER_PORT = longKey("server.port"); // stable + /** + * HTTP response body size + */ public static final AttributeKey EXPERIMENTAL_HTTP_RESPONSE_BODY_SIZE = longKey("http.response.body.size"); // experimental + + /** + * HTTP request body size + */ public static final AttributeKey EXPERIMENTAL_HTTP_REQUEST_BODY_SIZE = longKey("http.request.body.size"); // experimental + /** + * HTTP response content type + */ public static final AttributeKey CUSTOM_HTTP_RESPONSE_CONTENT_TYPE = stringKey("http.response_content_type"); // custom + + /** + * HTTP request content type + */ public static final AttributeKey CUSTOM_HTTP_REQUEST_CONTENT_TYPE = stringKey("http.request_content_type"); // custom } diff --git a/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/AuthorizationHandler.java b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/AuthorizationHandler.java new file mode 100644 index 000000000..c80b16177 --- /dev/null +++ b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/AuthorizationHandler.java @@ -0,0 +1,140 @@ +package com.microsoft.kiota.http.middleware; + +import static com.microsoft.kiota.http.TelemetrySemanticConventions.HTTP_REQUEST_RESEND_COUNT; + +import com.microsoft.kiota.authentication.AccessTokenProvider; +import com.microsoft.kiota.authentication.BaseBearerTokenAuthenticationProvider; +import com.microsoft.kiota.http.ContinuousAccessEvaluationClaims; +import com.microsoft.kiota.http.ObservabilityOptions; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Scope; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; + +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * This interceptor is responsible for adding the Authorization header to the request + * if the header is not already present. It also handles Continuous Access Evaluation (CAE) claims + * challenges if the token request was made using this interceptor. It does this using the provided AuthenticationProvider + */ +public class AuthorizationHandler implements Interceptor { + + @Nonnull private final BaseBearerTokenAuthenticationProvider authenticationProvider; + private static final String AUTHORIZATION_HEADER = "Authorization"; + + /** + * Instantiates a new AuthorizationHandler. + * @param authenticationProvider the authentication provider. + */ + public AuthorizationHandler( + @Nonnull final BaseBearerTokenAuthenticationProvider authenticationProvider) { + this.authenticationProvider = + Objects.requireNonNull( + authenticationProvider, "AuthenticationProvider cannot be null"); + } + + @Override + @SuppressWarnings("UnknownNullness") + public @Nonnull Response intercept(final Chain chain) throws IOException { + Objects.requireNonNull(chain, "parameter chain cannot be null"); + final Request request = chain.request(); + + final Span span = + GlobalOpenTelemetry.getTracer( + (new ObservabilityOptions()).getTracerInstrumentationName()) + .spanBuilder("AuthorizationHandler_Intercept") + .startSpan(); + Scope scope = null; + if (span != null) { + scope = span.makeCurrent(); + span.setAttribute("com.microsoft.kiota.handler.authorization.enable", true); + } + + try { + // Auth provider already added auth header + if (request.headers().names().contains(AUTHORIZATION_HEADER)) { + if (span != null) + span.setAttribute( + "com.microsoft.kiota.handler.authorization.token_present", true); + return chain.proceed(request); + } + + final HashMap additionalContext = new HashMap<>(); + additionalContext.put("parent-span", span); + final Request authenticatedRequest = + authenticateRequest(request, additionalContext, span); + final Response response = chain.proceed(authenticatedRequest); + + if (response != null && response.code() != HttpURLConnection.HTTP_UNAUTHORIZED) { + return response; + } + + // Attempt CAE claims challenge + final String claims = ContinuousAccessEvaluationClaims.getClaimsFromResponse(response); + if (claims == null || claims.isEmpty()) { + return response; + } + + if (span != null) + span.addEvent("com.microsoft.kiota.handler.authorization.challenge_received"); + + // We cannot replay one-shot requests after claims challenge + final RequestBody requestBody = request.body(); + if (requestBody != null && requestBody.isOneShot()) { + return response; + } + + response.close(); + additionalContext.put("claims", claims); + // Retry claims challenge only once + if (span != null) { + span.setAttribute(HTTP_REQUEST_RESEND_COUNT, 1); + } + final Request authenticatedRequestAfterCAE = + authenticateRequest(request, additionalContext, span); + return chain.proceed(authenticatedRequestAfterCAE); + } finally { + if (scope != null) { + scope.close(); + } + if (span != null) { + span.end(); + } + } + } + + private @Nonnull Request authenticateRequest( + @Nonnull final Request request, + @Nullable final Map additionalAuthenticationContext, + final Span span) { + + final AccessTokenProvider accessTokenProvider = + authenticationProvider.getAccessTokenProvider(); + if (!accessTokenProvider.getAllowedHostsValidator().isUrlHostValid(request.url().uri())) { + return request; + } + final String accessToken = + accessTokenProvider.getAuthorizationToken( + request.url().uri(), additionalAuthenticationContext); + if (accessToken != null && !accessToken.isEmpty()) { + span.setAttribute("com.microsoft.kiota.handler.authorization.token_obtained", true); + final Request.Builder requestBuilder = request.newBuilder(); + requestBuilder.addHeader(AUTHORIZATION_HEADER, "Bearer " + accessToken); + return requestBuilder.build(); + } + return request; + } +} diff --git a/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/options/UserAgentHandlerOption.java b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/options/UserAgentHandlerOption.java index c8dba6011..910546e9a 100644 --- a/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/options/UserAgentHandlerOption.java +++ b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/options/UserAgentHandlerOption.java @@ -13,7 +13,7 @@ public UserAgentHandlerOption() {} private boolean enabled = true; @Nonnull private String productName = "kiota-java"; - @Nonnull private String productVersion = "1.4.0"; + @Nonnull private String productVersion = "1.5.0"; /** * Gets the product name to be used in the user agent header diff --git a/components/http/okHttp/src/test/java/com/microsoft/kiota/http/OkHttpRequestAdapterTest.java b/components/http/okHttp/src/test/java/com/microsoft/kiota/http/OkHttpRequestAdapterTest.java index 636a97111..b14d32c84 100644 --- a/components/http/okHttp/src/test/java/com/microsoft/kiota/http/OkHttpRequestAdapterTest.java +++ b/components/http/okHttp/src/test/java/com/microsoft/kiota/http/OkHttpRequestAdapterTest.java @@ -9,6 +9,7 @@ import com.microsoft.kiota.NativeResponseHandler; import com.microsoft.kiota.RequestInformation; import com.microsoft.kiota.authentication.AuthenticationProvider; +import com.microsoft.kiota.http.middleware.MockResponseHandler; import com.microsoft.kiota.serialization.Parsable; import com.microsoft.kiota.serialization.ParsableFactory; import com.microsoft.kiota.serialization.ParseNode; @@ -20,7 +21,6 @@ import okhttp3.Call; import okhttp3.Callback; import okhttp3.Dispatcher; -import okhttp3.Interceptor; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Protocol; @@ -574,33 +574,4 @@ public ParseNodeFactory creatMockParseNodeFactory( when(mockFactory.getValidContentType()).thenReturn(validContentType); return mockFactory; } - - // Returns request body as response body - static class MockResponseHandler implements Interceptor { - @Override - public Response intercept(Chain chain) throws IOException { - final var request = chain.request(); - final var requestBody = request.body(); - if (request != null && requestBody != null) { - final var buffer = new Buffer(); - requestBody.writeTo(buffer); - return new Response.Builder() - .code(200) - .message("OK") - .protocol(Protocol.HTTP_1_1) - .request(request) - .body( - ResponseBody.create( - buffer.readByteArray(), - MediaType.parse("application/json"))) - .build(); - } - return new Response.Builder() - .code(200) - .message("OK") - .protocol(Protocol.HTTP_1_1) - .request(request) - .build(); - } - } } diff --git a/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/AuthorizationHandlerTest.java b/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/AuthorizationHandlerTest.java new file mode 100644 index 000000000..605aaf69d --- /dev/null +++ b/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/AuthorizationHandlerTest.java @@ -0,0 +1,213 @@ +package com.microsoft.kiota.http.middleware; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.microsoft.kiota.authentication.AccessTokenProvider; +import com.microsoft.kiota.authentication.AllowedHostsValidator; +import com.microsoft.kiota.authentication.BaseBearerTokenAuthenticationProvider; +import com.microsoft.kiota.http.KiotaClientFactory; + +import okhttp3.Interceptor.Chain; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +import org.junit.jupiter.api.Test; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.util.Arrays; + +class AuthorizationHandlerTest { + + private static final String ACCESS_TOKEN_STRING = "token"; + private static final String TOKEN_AFTER_CAE = "TOKEN_AFTER_CAE"; + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String PREV_AUTHORIZATION_HEADER_VALUE = "Bearer 123"; + private static final String NEW_AUTHORIZATION_HEADER_VALUE = "Bearer " + ACCESS_TOKEN_STRING; + private static final String CLAIMS_CHALLENGE_HEADER_VALUE = + "Bearer authorization_uri=\"https://login.windows.net/common/oauth2/authorize\"," + + "error=\"insufficient_claims\"," + + "claims=\"eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwNDEwNjY1MSJ9fX0=\""; + + @Test + void testDoesNotAddAuthorizationHeaderIfAlreadyPresent() throws IOException { + final Request request = + new Request.Builder() + .url("https://graph.microsoft.com/v1.0/me") + .addHeader("Authorization", "Bearer 123") + .build(); + final Chain mockChain = getMockChain(request, mock(Response.class)); + final AuthorizationHandler handler = + new AuthorizationHandler(getMockAuthenticationProvider()); + Response response = handler.intercept(mockChain); + + assertTrue(response.request().headers().names().contains(AUTHORIZATION_HEADER)); + assertEquals( + PREV_AUTHORIZATION_HEADER_VALUE, response.request().header(AUTHORIZATION_HEADER)); + } + + @Test + void testAddsAuthorizationHeaderIfNotPresent() throws IOException { + final Request request = + new Request.Builder().url("https://graph.microsoft.com/v1.0/me").build(); + final Chain mockChain = getMockChain(request, mock(Response.class)); + final AuthorizationHandler handler = + new AuthorizationHandler(getMockAuthenticationProvider()); + Response response = handler.intercept(mockChain); + + assertTrue(response.request().headers().names().contains(AUTHORIZATION_HEADER)); + assertEquals( + NEW_AUTHORIZATION_HEADER_VALUE, response.request().header(AUTHORIZATION_HEADER)); + } + + @Test + void testAddsAuthHeaderOnlyToAllowedHosts() throws IOException { + final Request request = + new Request.Builder().url("https://canary.graph.microsoft.com/v1.0/me").build(); + final Chain mockChain = getMockChain(request, mock(Response.class)); + final BaseBearerTokenAuthenticationProvider authProvider = getMockAuthenticationProvider(); + final AuthorizationHandler handler = new AuthorizationHandler(authProvider); + Response response = handler.intercept(mockChain); + + assertTrue(!response.request().headers().names().contains(AUTHORIZATION_HEADER)); + } + + @Test + void testAttemptsCAEChallenge() throws IOException { + final Request request = + new Request.Builder().url("https://graph.microsoft.com/v1.0/me").build(); + final Chain mockChain = + getMockChain(request, getMockResponseWithClaimsChallengeHeader(request)); + final BaseBearerTokenAuthenticationProvider authProvider = getMockAuthenticationProvider(); + final AuthorizationHandler handler = new AuthorizationHandler(authProvider); + Response response = handler.intercept(mockChain); + + assertTrue(response.request().headers().names().contains(AUTHORIZATION_HEADER)); + assertEquals("Bearer " + TOKEN_AFTER_CAE, response.request().header(AUTHORIZATION_HEADER)); + } + + @Test + void testOtherRequestPropertiesAreNotAltered() throws IOException { + final Request request = + new Request.Builder() + .url("https://graph.microsoft.com/v1.0/me") + .addHeader("content-type", "application/json") + .get() + .build(); + final Chain mockChain = getMockChain(request, mock(Response.class)); + final AuthorizationHandler handler = + new AuthorizationHandler(getMockAuthenticationProvider()); + Response response = handler.intercept(mockChain); + + assertEquals(request.url(), response.request().url()); + assertEquals(request.method(), response.request().method()); + assertTrue(response.request().headers().names().contains("content-type")); + assertEquals("application/json", response.request().header("content-type")); + assertTrue(response.request().headers().names().contains(AUTHORIZATION_HEADER)); + assertEquals( + NEW_AUTHORIZATION_HEADER_VALUE, response.request().header(AUTHORIZATION_HEADER)); + } + + @Test + void testDoesNotRetryCAEChallengeForOneShotBodyRequests() throws IOException { + final RequestBody mockRequestBody = mock(RequestBody.class); + when(mockRequestBody.isOneShot()).thenReturn(true); + final Request request = + new Request.Builder() + .url("https://graph.microsoft.com/v1.0/me") + .post(mockRequestBody) + .build(); + final Chain mockChain = + getMockChain(request, getMockResponseWithClaimsChallengeHeader(request)); + final BaseBearerTokenAuthenticationProvider authProvider = getMockAuthenticationProvider(); + final AuthorizationHandler handler = new AuthorizationHandler(authProvider); + Response response = handler.intercept(mockChain); + + assertTrue(response.request().headers().names().contains(AUTHORIZATION_HEADER)); + assertEquals( + NEW_AUTHORIZATION_HEADER_VALUE, response.request().header(AUTHORIZATION_HEADER)); + } + + @Test + void testDoesNotAttemptCAEChallengeIfNoClaimsPresent() throws IOException { + final Request request = + new Request.Builder().url("https://graph.microsoft.com/v1.0/me").build(); + final Response mockResponse = mock(Response.class); + when(mockResponse.code()).thenReturn(HttpURLConnection.HTTP_UNAUTHORIZED); + final Chain mockChain = getMockChain(request, mockResponse); + final BaseBearerTokenAuthenticationProvider authProvider = getMockAuthenticationProvider(); + final AuthorizationHandler handler = new AuthorizationHandler(authProvider); + Response response = handler.intercept(mockChain); + + assertTrue(response.request().headers().names().contains(AUTHORIZATION_HEADER)); + assertEquals( + NEW_AUTHORIZATION_HEADER_VALUE, response.request().header(AUTHORIZATION_HEADER)); + assertEquals(401, response.code()); + } + + @Test + void testAuthorizationHandlerAddedByClientFactory() throws IOException { + final BaseBearerTokenAuthenticationProvider authProvider = getMockAuthenticationProvider(); + OkHttpClient okHttpClient = + KiotaClientFactory.create(authProvider) + .addInterceptor(new MockResponseHandler()) + .build(); + + final Request request = + new Request.Builder().url("https://graph.microsoft.com/v1.0/me").build(); + Response response = okHttpClient.newCall(request).execute(); + + assertTrue(response.request().headers().names().contains(AUTHORIZATION_HEADER)); + assertEquals( + NEW_AUTHORIZATION_HEADER_VALUE, response.request().header(AUTHORIZATION_HEADER)); + } + + private Chain getMockChain(Request mockRequest, Response mockResponse) throws IOException { + Chain mockChain = mock(Chain.class); + when(mockChain.request()).thenReturn(mockRequest); + when(mockChain.proceed(any(Request.class))) + .thenAnswer( + new Answer() { + public Response answer(InvocationOnMock invocation) { + Object[] args = invocation.getArguments(); + Request request = (Request) args[0]; + when(mockResponse.request()).thenReturn(request); + return mockResponse; + } + }); + return mockChain; + } + + private BaseBearerTokenAuthenticationProvider getMockAuthenticationProvider() { + final AccessTokenProvider mockAccessTokenProvider = mock(AccessTokenProvider.class); + final AllowedHostsValidator allowedHostsValidator = + new AllowedHostsValidator("graph.microsoft.com"); + when(mockAccessTokenProvider.getAllowedHostsValidator()).thenReturn(allowedHostsValidator); + when(mockAccessTokenProvider.getAuthorizationToken(any(URI.class), anyMap())) + .thenReturn(ACCESS_TOKEN_STRING, TOKEN_AFTER_CAE); + final BaseBearerTokenAuthenticationProvider mockAuthenticationProvider = + mock(BaseBearerTokenAuthenticationProvider.class); + when(mockAuthenticationProvider.getAccessTokenProvider()) + .thenReturn(mockAccessTokenProvider); + return mockAuthenticationProvider; + } + + private Response getMockResponseWithClaimsChallengeHeader(Request request) { + final Response mockResponse = mock(Response.class); + when(mockResponse.code()).thenReturn(HttpURLConnection.HTTP_UNAUTHORIZED); + when(mockResponse.headers("WWW-Authenticate")) + .thenReturn(Arrays.asList(CLAIMS_CHALLENGE_HEADER_VALUE)); + when(mockResponse.request()).thenReturn(request); + return mockResponse; + } +} diff --git a/components/http/okHttp/src/test/java/com/microsoft/kiota/http/HeadersInspectionHandlerTest.java b/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/HeadersInspectionHandlerTest.java similarity index 97% rename from components/http/okHttp/src/test/java/com/microsoft/kiota/http/HeadersInspectionHandlerTest.java rename to components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/HeadersInspectionHandlerTest.java index 4dfa1db88..a358fe724 100644 --- a/components/http/okHttp/src/test/java/com/microsoft/kiota/http/HeadersInspectionHandlerTest.java +++ b/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/HeadersInspectionHandlerTest.java @@ -1,4 +1,4 @@ -package com.microsoft.kiota.http; +package com.microsoft.kiota.http.middleware; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -6,7 +6,6 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import com.microsoft.kiota.http.middleware.HeadersInspectionHandler; import com.microsoft.kiota.http.middleware.options.HeadersInspectionOption; import okhttp3.Headers; diff --git a/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/MockResponseHandler.java b/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/MockResponseHandler.java new file mode 100644 index 000000000..456597668 --- /dev/null +++ b/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/MockResponseHandler.java @@ -0,0 +1,52 @@ +package com.microsoft.kiota.http.middleware; + +import okhttp3.Interceptor; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Response; +import okhttp3.ResponseBody; + +import okio.Buffer; + +import java.io.IOException; + +/** + * Returns the request body as the response body + */ +public class MockResponseHandler implements Interceptor { + private int statusCode; + + public MockResponseHandler(int statusCode) { + this.statusCode = statusCode; + } + + public MockResponseHandler() { + this.statusCode = 200; + } + + @Override + public Response intercept(Chain chain) throws IOException { + final var request = chain.request(); + final var requestBody = request.body(); + if (request != null && requestBody != null) { + final var buffer = new Buffer(); + requestBody.writeTo(buffer); + return new Response.Builder() + .code(this.statusCode) + .message("OK") + .protocol(Protocol.HTTP_1_1) + .request(request) + .body( + ResponseBody.create( + buffer.readByteArray(), MediaType.parse("application/json"))) + .build(); + } + return new Response.Builder() + .code(this.statusCode) + .message("OK") + .protocol(Protocol.HTTP_1_1) + .request(request) + .body(ResponseBody.create("", MediaType.parse("application/json"))) + .build(); + } +} diff --git a/components/http/okHttp/src/test/java/com/microsoft/kiota/http/ParametersNameDecodingHandlerTest.java b/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/ParametersNameDecodingHandlerTest.java similarity index 95% rename from components/http/okHttp/src/test/java/com/microsoft/kiota/http/ParametersNameDecodingHandlerTest.java rename to components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/ParametersNameDecodingHandlerTest.java index f555ff457..2452c0526 100644 --- a/components/http/okHttp/src/test/java/com/microsoft/kiota/http/ParametersNameDecodingHandlerTest.java +++ b/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/ParametersNameDecodingHandlerTest.java @@ -1,9 +1,9 @@ -package com.microsoft.kiota.http; +package com.microsoft.kiota.http.middleware; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import com.microsoft.kiota.http.middleware.ParametersNameDecodingHandler; +import com.microsoft.kiota.http.KiotaClientFactory; import com.microsoft.kiota.http.middleware.options.ParametersNameDecodingOption; import okhttp3.Interceptor; diff --git a/components/http/okHttp/src/test/java/com/microsoft/kiota/http/UrlReplaceHandlerTest.java b/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/UrlReplaceHandlerTest.java similarity index 91% rename from components/http/okHttp/src/test/java/com/microsoft/kiota/http/UrlReplaceHandlerTest.java rename to components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/UrlReplaceHandlerTest.java index 21b49281c..0c04f014e 100644 --- a/components/http/okHttp/src/test/java/com/microsoft/kiota/http/UrlReplaceHandlerTest.java +++ b/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/UrlReplaceHandlerTest.java @@ -1,9 +1,9 @@ -package com.microsoft.kiota.http; +package com.microsoft.kiota.http.middleware; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import com.microsoft.kiota.http.middleware.UrlReplaceHandler; +import com.microsoft.kiota.http.KiotaClientFactory; import com.microsoft.kiota.http.middleware.options.UrlReplaceHandlerOption; import okhttp3.Interceptor; @@ -18,7 +18,7 @@ class UrlReplaceHandlerTest { - private static final String defaultUsersWithTokenUrl = + private static final String DEFAULT_URL_WITH_TOKEN = "https://graph.microsoft.com/v1.0/users/TokenToReplace"; private static final HashMap defaultReplacementPairs = new HashMap<>(); @@ -27,12 +27,12 @@ void testUrlReplaceHandler_no_replacementPairs() throws IOException { Interceptor[] interceptors = new Interceptor[] {new UrlReplaceHandler(new UrlReplaceHandlerOption())}; final OkHttpClient client = KiotaClientFactory.create(interceptors).build(); - final Request request = new Request.Builder().url(defaultUsersWithTokenUrl).build(); + final Request request = new Request.Builder().url(DEFAULT_URL_WITH_TOKEN).build(); final Response response = client.newCall(request).execute(); assertNotNull(response); assertEquals( - defaultUsersWithTokenUrl, + DEFAULT_URL_WITH_TOKEN, response.request() .url() .toString()); // url should remain the same without replacement pairs @@ -46,7 +46,7 @@ void testUrlReplaceHandler_default_url() throws IOException { new UrlReplaceHandler(new UrlReplaceHandlerOption(defaultReplacementPairs)) }; final OkHttpClient client = KiotaClientFactory.create(interceptors).build(); - final Request request = new Request.Builder().url(defaultUsersWithTokenUrl).build(); + final Request request = new Request.Builder().url(DEFAULT_URL_WITH_TOKEN).build(); final Response response = client.newCall(request).execute(); final String expectedNewUrl = "https://graph.microsoft.com/v1.0/me"; diff --git a/components/http/okHttp/src/test/java/com/microsoft/kiota/http/UserAgentHandlerTest.java b/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/UserAgentHandlerTest.java similarity index 95% rename from components/http/okHttp/src/test/java/com/microsoft/kiota/http/UserAgentHandlerTest.java rename to components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/UserAgentHandlerTest.java index bcfca8fba..1b8fe4498 100644 --- a/components/http/okHttp/src/test/java/com/microsoft/kiota/http/UserAgentHandlerTest.java +++ b/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/UserAgentHandlerTest.java @@ -1,11 +1,10 @@ -package com.microsoft.kiota.http; +package com.microsoft.kiota.http.middleware; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.Mockito.*; -import com.microsoft.kiota.http.middleware.UserAgentHandler; import com.microsoft.kiota.http.middleware.options.UserAgentHandlerOption; import okhttp3.Headers; @@ -60,8 +59,8 @@ void addsTheProductOnce() throws IOException { final UserAgentHandler handler = new UserAgentHandler(); final Request request = new Request.Builder().url("http://localhost").build(); when(mockChain.request()).thenReturn(request); + handler.intercept(mockChain); Response response = handler.intercept(mockChain); - response = handler.intercept(mockChain); final Request result = response.request(); assertNotNull(response); assertNotNull(result); diff --git a/gradle.properties b/gradle.properties index 434f746a4..d61e8397a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -25,7 +25,7 @@ org.gradle.caching=true mavenGroupId = com.microsoft.kiota mavenMajorVersion = 1 -mavenMinorVersion = 4 +mavenMinorVersion = 5 mavenPatchVersion = 0 mavenArtifactSuffix =