From 7559df942603e568d986fa69a7810427fdf890fd Mon Sep 17 00:00:00 2001 From: Pallavi Taneja Date: Mon, 22 Mar 2021 10:15:35 -0700 Subject: [PATCH] ACR authentication support --- .../checkstyle/checkstyle-suppressions.xml | 2 + .../pom.xml | 20 ++- .../authentication/AccessTokenCacheImpl.java | 167 ++++++++++++++++++ .../authentication/AccessTokensImpl.java | 122 +++++++++++++ .../ContainerRegistryCredentialsPolicy.java | 154 ++++++++++++++++ .../ContainerRegistryTokenCredential.java | 43 +++++ .../ContainerRegistryTokenRequestContext.java | 38 ++++ .../ContainerRegistryTokenService.java | 65 +++++++ .../authentication/JsonWebToken.java | 61 +++++++ .../authentication/RefreshTokensImpl.java | 123 +++++++++++++ .../authentication/TokenServiceImpl.java | 72 ++++++++ .../implementation/models/AcrAccessToken.java | 38 ++++ .../implementation/models/AcrErrorInfo.java | 90 ++++++++++ .../implementation/models/AcrErrors.java | 39 ++++ .../models/AcrErrorsException.java | 37 ++++ .../models/AcrRefreshToken.java | 38 ++++ .../models/Oauth2ExchangePostRequestbody.java | 154 ++++++++++++++++ .../models/Oauth2TokenPostRequestbody.java | 115 ++++++++++++ .../models/PostContentSchemaGrantType.java | 38 ++++ .../src/main/java/module-info.java | 1 + ...ContainerRegistryClientPolicyImplTest.java | 130 ++++++++++++++ .../ContainerRegistryTokenServiceTest.java | 100 +++++++++++ .../org.mockito.plugins.MockMaker | 1 + 23 files changed, 1647 insertions(+), 1 deletion(-) create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/AccessTokenCacheImpl.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/AccessTokensImpl.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryCredentialsPolicy.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryTokenCredential.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryTokenRequestContext.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryTokenService.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/JsonWebToken.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/RefreshTokensImpl.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/TokenServiceImpl.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrAccessToken.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrErrorInfo.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrErrors.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrErrorsException.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrRefreshToken.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/Oauth2ExchangePostRequestbody.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/Oauth2TokenPostRequestbody.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/PostContentSchemaGrantType.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/test/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryClientPolicyImplTest.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/test/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryTokenServiceTest.java create mode 100644 sdk/containerregistry/azure-containers-containerregistry/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/eng/code-quality-reports/src/main/resources/checkstyle/checkstyle-suppressions.xml b/eng/code-quality-reports/src/main/resources/checkstyle/checkstyle-suppressions.xml index 5eed93d87bf7a..8cd6d84967247 100755 --- a/eng/code-quality-reports/src/main/resources/checkstyle/checkstyle-suppressions.xml +++ b/eng/code-quality-reports/src/main/resources/checkstyle/checkstyle-suppressions.xml @@ -279,6 +279,7 @@ the main ServiceBusClientBuilder. --> + @@ -287,6 +288,7 @@ the main ServiceBusClientBuilder. --> + diff --git a/sdk/containerregistry/azure-containers-containerregistry/pom.xml b/sdk/containerregistry/azure-containers-containerregistry/pom.xml index 1903b35729df6..00c269627d033 100644 --- a/sdk/containerregistry/azure-containers-containerregistry/pom.xml +++ b/sdk/containerregistry/azure-containers-containerregistry/pom.xml @@ -34,7 +34,7 @@ 0.10 - + false @@ -61,5 +61,23 @@ 5.7.1 test + + org.mockito + mockito-core + 3.6.28 + test + + + io.projectreactor + reactor-test + 3.4.3 + test + + + com.azure + azure-core-test + 1.6.0 + test + diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/AccessTokenCacheImpl.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/AccessTokenCacheImpl.java new file mode 100644 index 0000000000000..9765e5c8dc115 --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/AccessTokenCacheImpl.java @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.containers.containerregistry.implementation.authentication; + +import com.azure.core.credential.AccessToken; +import com.azure.core.util.logging.ClientLogger; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Signal; +import reactor.core.publisher.Sinks; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; + +/** + * A token cache that supports caching a token and refreshing it. + */ +public class AccessTokenCacheImpl { + // The delay after a refresh to attempt another token refresh + private static final Duration REFRESH_DELAY = Duration.ofSeconds(30); + // the offset before token expiry to attempt proactive token refresh + private static final Duration REFRESH_OFFSET = Duration.ofMinutes(5); + private volatile AccessToken cache; + private volatile OffsetDateTime nextTokenRefresh = OffsetDateTime.now(); + private final AtomicReference> wip; + private final ContainerRegistryTokenCredential tokenCredential; + private ContainerRegistryTokenRequestContext tokenRequestContext; + private final Predicate shouldRefresh; + private final ClientLogger logger = new ClientLogger(AccessTokenCacheImpl.class); + + /** + * Creates an instance of AccessTokenCacheImpl with default scheme "Bearer". + * + * @param tokenCredential the credential to be used to acquire token from. + */ + public AccessTokenCacheImpl(ContainerRegistryTokenCredential tokenCredential) { + Objects.requireNonNull(tokenCredential, "The token credential cannot be null"); + this.wip = new AtomicReference<>(); + this.tokenCredential = tokenCredential; + this.shouldRefresh = accessToken -> OffsetDateTime.now() + .isAfter(accessToken.getExpiresAt().minus(REFRESH_OFFSET)); + } + + /** + * Asynchronously get a token from either the cache or replenish the cache with a new token. + * + * @param tokenRequestContext The request context for token acquisition. + * @return The Publisher that emits an AccessToken + */ + public Mono getToken(ContainerRegistryTokenRequestContext tokenRequestContext) { + return Mono.defer(retrieveToken(tokenRequestContext)) + // Keep resubscribing as long as Mono.defer [token acquisition] emits empty(). + .repeatWhenEmpty((Flux longFlux) -> longFlux.concatMap(ignored -> Flux.just(true))); + } + + private Supplier> retrieveToken(ContainerRegistryTokenRequestContext tokenRequestContext) { + return () -> { + try { + if (wip.compareAndSet(null, Sinks.one())) { + final Sinks.One sinksOne = wip.get(); + OffsetDateTime now = OffsetDateTime.now(); + Mono tokenRefresh; + Mono fallback; + + Supplier> tokenSupplier = () -> + tokenCredential.getToken(this.tokenRequestContext); + + boolean forceRefresh = checkIfWeShouldForceRefresh(tokenRequestContext); + + if (forceRefresh) { + this.tokenRequestContext = tokenRequestContext; + tokenRefresh = Mono.defer(() -> tokenCredential.getToken(this.tokenRequestContext)); + fallback = Mono.empty(); + } else if (cache != null && !shouldRefresh.test(cache)) { + // fresh cache & no need to refresh + tokenRefresh = Mono.empty(); + fallback = Mono.just(cache); + } else if (cache == null || cache.isExpired()) { + // no token to use + if (now.isAfter(nextTokenRefresh)) { + // refresh immediately + tokenRefresh = Mono.defer(tokenSupplier); + } else { + // wait for timeout, then refresh + tokenRefresh = Mono.defer(tokenSupplier) + .delaySubscription(Duration.between(now, nextTokenRefresh)); + } + // cache doesn't exist or expired, no fallback + fallback = Mono.empty(); + } else { + // token available, but close to expiry + if (now.isAfter(nextTokenRefresh)) { + // refresh immediately + tokenRefresh = Mono.defer(tokenSupplier); + } else { + // still in timeout, do not refresh + tokenRefresh = Mono.empty(); + } + // cache hasn't expired, ignore refresh error this time + fallback = Mono.just(cache); + } + return tokenRefresh + .materialize() + .flatMap(processTokenRefreshResult(sinksOne, now, fallback)) + .doOnError(sinksOne::tryEmitError) + .doFinally(ignored -> wip.set(null)); + } else { + return Mono.empty(); + } + } catch (Throwable t) { + return Mono.error(t); + } + }; + } + + private boolean checkIfWeShouldForceRefresh(ContainerRegistryTokenRequestContext tokenRequestContext) { + return !(this.tokenRequestContext != null + && (this.tokenRequestContext.getScope() == null ? tokenRequestContext.getScope() == null + : this.tokenRequestContext.getScope().equals(tokenRequestContext.getScope())) + && (this.tokenRequestContext.getServiceName() == null ? tokenRequestContext.getServiceName() == null + : this.tokenRequestContext.getServiceName().equals(tokenRequestContext.getServiceName()))); + } + + private Function, Mono> processTokenRefreshResult( + Sinks.One sinksOne, OffsetDateTime now, Mono fallback) { + return signal -> { + AccessToken accessToken = signal.get(); + Throwable error = signal.getThrowable(); + if (signal.isOnNext() && accessToken != null) { // SUCCESS + logger.info(refreshLog(cache, now, "Acquired a new access token")); + cache = accessToken; + sinksOne.tryEmitValue(accessToken); + nextTokenRefresh = OffsetDateTime.now().plus(REFRESH_DELAY); + return Mono.just(accessToken); + } else if (signal.isOnError() && error != null) { // ERROR + logger.error(refreshLog(cache, now, "Failed to acquire a new access token")); + nextTokenRefresh = OffsetDateTime.now().plus(REFRESH_DELAY); + return fallback.switchIfEmpty(Mono.error(error)); + } else { // NO REFRESH + sinksOne.tryEmitEmpty(); + return fallback; + } + }; + } + + private static String refreshLog(AccessToken cache, OffsetDateTime now, String log) { + StringBuilder info = new StringBuilder(log); + if (cache == null) { + info.append("."); + } else { + Duration tte = Duration.between(now, cache.getExpiresAt()); + info.append(" at ").append(tte.abs().getSeconds()).append(" seconds ") + .append(tte.isNegative() ? "after" : "before").append(" expiry. ") + .append("Retry may be attempted after ").append(REFRESH_DELAY.getSeconds()).append(" seconds."); + if (!tte.isNegative()) { + info.append(" The token currently cached will be used."); + } + } + return info.toString(); + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/AccessTokensImpl.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/AccessTokensImpl.java new file mode 100644 index 0000000000000..ee3e67e712536 --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/AccessTokensImpl.java @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.containers.containerregistry.implementation.authentication; + +import com.azure.containers.containerregistry.implementation.models.AcrAccessToken; +import com.azure.containers.containerregistry.implementation.models.AcrErrorsException; +import com.azure.core.annotation.ExpectedResponses; +import com.azure.core.annotation.Host; +import com.azure.core.annotation.Post; +import com.azure.core.annotation.ServiceInterface; +import com.azure.core.annotation.FormParam; +import com.azure.core.annotation.HeaderParam; +import com.azure.core.annotation.HostParam; +import com.azure.core.annotation.UnexpectedResponseExceptionType; +import com.azure.core.annotation.ReturnType; +import com.azure.core.annotation.ServiceMethod; +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.rest.Response; +import com.azure.core.http.rest.RestProxy; +import com.azure.core.util.Context; +import com.azure.core.util.FluxUtil; +import com.azure.core.util.serializer.SerializerAdapter; +import reactor.core.publisher.Mono; + +/** An instance of this class provides access to all the operations defined in AccessTokensService. */ +public final class AccessTokensImpl { + + /** The proxy service used to perform REST calls. */ + private final AccessTokensServiceImpl service; + + /** Registry login URL. */ + private final String url; + + /** + * Gets Registry login URL. + * + * @return the url value. + */ + public String getUrl() { + return this.url; + } + + /** + * Initializes an instance of AccessTokensImpl. + * + * @param url the service endpoint. + * @param httpPipeline the pipeline to use to make the call. + * @param serializerAdapter the serializer adapter for the rest client. + * + */ + public AccessTokensImpl(String url, HttpPipeline httpPipeline, SerializerAdapter serializerAdapter) { + this.service = + RestProxy.create(AccessTokensServiceImpl.class, httpPipeline, serializerAdapter); + this.url = url; + } + + /** + * Exchange ACR Refresh token for an ACR Access Token. + * + * @param refreshToken The refreshToken parameter. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws AcrErrorsException thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono> getAccessTokenWithResponseAsync( + String grantType, + String serviceName, + String scope, + String refreshToken) { + final String accept = "application/json"; + return FluxUtil.withContext( + context -> service.getAccessToken(getUrl(), grantType, serviceName, scope, refreshToken, accept, context)); + } + /** + * Exchange ACR Refresh token for an ACR Access Token. + * + * @param refreshToken The refreshToken parameter. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws AcrErrorsException thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono getAccessTokenAsync( + String grantType, + String serviceName, + String scope, + String refreshToken) { + return getAccessTokenWithResponseAsync(grantType, serviceName, scope, refreshToken) + .flatMap( + (Response res) -> { + if (res.getValue() != null) { + return Mono.just(res.getValue()); + } else { + return Mono.empty(); + } + }); + } + + /** + * The interface defining all the services for AccessTokens to be used by the proxy service to + * perform REST calls. + */ + @Host("{url}") + @ServiceInterface(name = "ContainerRegistryAcc") + interface AccessTokensServiceImpl { + @Post("/oauth2/token") + @ExpectedResponses({200}) + @UnexpectedResponseExceptionType(AcrErrorsException.class) + Mono> getAccessToken( + @HostParam("url") String url, + @FormParam(value = "grant_type") String grantType, + @FormParam(value = "service") String service, + @FormParam(value = "scope") String scope, + @FormParam(value = "refresh_token") String refreshToken, + @HeaderParam("Accept") String accept, + Context context); + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryCredentialsPolicy.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryCredentialsPolicy.java new file mode 100644 index 0000000000000..d12a3a6db6ed5 --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryCredentialsPolicy.java @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.containers.containerregistry.implementation.authentication; + +import com.azure.core.credential.TokenCredential; +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.HttpPipelineCallContext; +import com.azure.core.http.HttpPipelineNextPolicy; +import com.azure.core.http.HttpResponse; +import com.azure.core.http.policy.HttpPipelinePolicy; +import com.azure.core.util.logging.ClientLogger; +import com.azure.core.util.serializer.SerializerAdapter; +import reactor.core.publisher.Mono; + + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.HashMap; + +/** + *

Credential policy for the container registry. It follows the challenge based authorization scheme.

+ * + *

For example GET /api/v1/acr/repositories translates into the following calls.

+ * + *

Step1: GET /api/v1/acr/repositories + * Return Header: 401: www-authenticate header - Bearer realm="{url}",service="{serviceName}",scope="{scope}",error="invalid_token".

+ * + *

Step2: Parse the serviceName, scope from the service.

+ * + *

Step3: POST /api/oauth2/exchange Request Body : {service, scope, grant-type, aadToken with ARM scope} + * Response Body: {acrRefreshToken}

+ * + *

Step4: POST /api/oauth2/token Request Body: {acrRefreshToken, scope, grant-type} + * Response Body: {acrAccessToken}

+ * + *

Step5: GET /api/v1/acr/repositories + * Request Header: {Bearer acrTokenAccess}

+ */ +public class ContainerRegistryCredentialsPolicy implements HttpPipelinePolicy { + + private static final String BEARER = "Bearer"; + public static final Pattern AUTHENTICATION_CHALLENGE_PARAMS_PATTERN = + Pattern.compile("(?:(\\w+)=\"([^\"\"]*)\")+"); + public static final String WWW_AUTHENTICATE = "WWW-Authenticate"; + public static final String SCOPES_PARAMETER = "scope"; + public static final String SERVICE_PARAMETER = "service"; + public static final String AUTHORIZATION = "Authorization"; + + private final ContainerRegistryTokenService tokenService; + private final ClientLogger logger = new ClientLogger(ContainerRegistryCredentialsPolicy.class); + + /** + * Creates an instance of ContainerRegistryCredentialsPolicy. + * + * @param credential the AAD credentials passed to the client. + * @param url the url for the container registry. + * @param pipeline the http pipeline to be used to make the rest calls. + * @param serializerAdapter the serializer adapter to be used to make the rest calls. + */ + public ContainerRegistryCredentialsPolicy(TokenCredential credential, String url, HttpPipeline pipeline, SerializerAdapter serializerAdapter) { + this(new ContainerRegistryTokenService(credential, url, pipeline, serializerAdapter)); + } + + /** + * Creates an instance of ContainerRegistryCredentialsPolicy. + * + * @param tokenService the token generation service. + */ + ContainerRegistryCredentialsPolicy(ContainerRegistryTokenService tokenService) { + this.tokenService = tokenService; + } + + /** + * Creates an instance of ContainerRegistryCredentialsPolicy. + * + * @param context call context for the http pipeline. + * @param next next http policy to run. + */ + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + if ("http".equals(context.getHttpRequest().getUrl().getProtocol())) { + return Mono.error(new RuntimeException("token credentials require a URL using the HTTPS protocol scheme")); + } else { + HttpPipelineNextPolicy nextPolicy = next.clone(); + return next.process() + .flatMap((httpResponse) -> { + String authHeader = httpResponse.getHeaderValue(WWW_AUTHENTICATE); + return httpResponse.getStatusCode() == 401 && authHeader != null + ? this.onChallenge(context, httpResponse).flatMap((retry) -> { + return retry ? nextPolicy.process() : Mono.just(httpResponse); }) + : Mono.just(httpResponse); + }); + } + } + + /** + * Authorizes the request with the bearer token acquired using the specified {@code tokenRequestContext} + * + * @param context the HTTP pipeline context. + * @param tokenRequestContext the token request conext to be used for token acquisition. + * @return a {@link Mono} containing {@link Void} + */ + public Mono authorizeRequest(HttpPipelineCallContext context, ContainerRegistryTokenRequestContext tokenRequestContext) { + return tokenService.getToken(tokenRequestContext) + .flatMap((token) -> { + context.getHttpRequest().getHeaders().set(AUTHORIZATION, BEARER + " " + token.getToken()); + return Mono.empty(); + }); + } + + /** + * Handles the authentication challenge in the event a 401 response with a WWW-Authenticate authentication + * challenge header is received after the initial request and returns appropriate {@link ContainerRegistryTokenRequestContext} to + * be used for re-authentication. + * + * @param context The request context. + * @param response The Http Response containing the authentication challenge header. + * @return A {@link Mono} containing {@link ContainerRegistryTokenRequestContext} + */ + public Mono onChallenge(HttpPipelineCallContext context, HttpResponse response) { + return Mono.defer(() -> { + String authHeader = response.getHeaderValue(WWW_AUTHENTICATE); + if (!(response.getStatusCode() == 401 && authHeader != null)) { + return Mono.just(false); + } else { + Map extractedChallengeParams = parseBearerChallenge(authHeader); + if (extractedChallengeParams != null && extractedChallengeParams.containsKey(SCOPES_PARAMETER)) { + String scope = extractedChallengeParams.get(SCOPES_PARAMETER); + String serviceName = extractedChallengeParams.get(SERVICE_PARAMETER); + return authorizeRequest(context, new ContainerRegistryTokenRequestContext(serviceName, scope)).then(Mono.just(true)); + } + return Mono.just(false); + } + }); + } + + private Map parseBearerChallenge(String header) { + if (header.startsWith(BEARER)) { + String challengeParams = header.substring(BEARER.length()); + + Matcher matcher2 = AUTHENTICATION_CHALLENGE_PARAMS_PATTERN.matcher(challengeParams); + + Map challengeParameters = new HashMap<>(); + while (matcher2.find()) { + challengeParameters.put(matcher2.group(1), matcher2.group(2)); + } + return challengeParameters; + } + + return null; + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryTokenCredential.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryTokenCredential.java new file mode 100644 index 0000000000000..231a7c764b4f5 --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryTokenCredential.java @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.containers.containerregistry.implementation.authentication; + +import com.azure.core.credential.AccessToken; +import com.azure.core.credential.TokenCredential; +import com.azure.core.credential.TokenRequestContext; +import reactor.core.publisher.Mono; + +/** + * Token credentials representing the container registry refresh token. + * This token is unique per registry operation. + */ +class ContainerRegistryTokenCredential { + + private final TokenCredential tokenCredential; + private final TokenServiceImpl tokenService; + public static final String AAD_DEFAULT_SCOPE = "https://management.core.windows.net/.default"; + + /** + * Creates an instance of RefreshTokenCredential with default scheme "Bearer". + * @param tokenService the container registry token service that calls the token rest APIs. + * @param aadTokenCredential the ARM access token. + */ + ContainerRegistryTokenCredential(TokenServiceImpl tokenService, TokenCredential aadTokenCredential) { + this.tokenService = tokenService; + this.tokenCredential = aadTokenCredential; + } + + /** + * Creates the container registry refresh token for the given context. + * + * @param context the context for the token to be generated. + */ + public Mono getToken(ContainerRegistryTokenRequestContext context) { + String serviceName = context.getServiceName(); + + return Mono.defer(() -> tokenCredential.getToken(new TokenRequestContext().addScopes(AAD_DEFAULT_SCOPE)) + .flatMap(token -> this.tokenService.getAcrRefreshTokenAsync(token.getToken(), serviceName))); + } + +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryTokenRequestContext.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryTokenRequestContext.java new file mode 100644 index 0000000000000..7121240116308 --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryTokenRequestContext.java @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.containers.containerregistry.implementation.authentication; + +/** + * A token request context associated with a given container registry token. + */ +class ContainerRegistryTokenRequestContext { + private final String scope; + private final String serviceName; + + /** + * Creates an instance of TokenRequestContext. + * @param serviceName the service name of the registry. + * @param scope token scope. + */ + ContainerRegistryTokenRequestContext(String serviceName, String scope) { + this.serviceName = serviceName; + this.scope = scope; + } + + /** + * Get the service name. + * @return service name. + */ + String getServiceName() { + return this.serviceName; + } + + /** + * Get's the token scope. + * @return scope for the context. + */ + String getScope() { + return this.scope; + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryTokenService.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryTokenService.java new file mode 100644 index 0000000000000..7e2e5ebac0ec4 --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryTokenService.java @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.containers.containerregistry.implementation.authentication; + +import com.azure.core.credential.AccessToken; +import com.azure.core.credential.TokenCredential; +import com.azure.core.http.HttpPipeline; +import com.azure.core.util.logging.ClientLogger; +import com.azure.core.util.serializer.SerializerAdapter; +import reactor.core.publisher.Mono; + +import java.util.Objects; + +/** + * A token service for obtaining tokens to be used by the container registry service. + */ +class ContainerRegistryTokenService { + private AccessTokenCacheImpl refreshTokenCache; + private TokenServiceImpl tokenService; + private final ClientLogger logger = new ClientLogger(ContainerRegistryTokenService.class); + + /** + * Creates an instance of AccessTokenCache with default scheme "Bearer". + * + * @param tokenCredential the credential to be used to acquire the token. + * @param url the container registry endpoint. + * @param pipeline the pipeline to be used for the rest calls to the service. + * @param serializerAdapter the serializer adapter to be used for the rest calls to the service. + */ + ContainerRegistryTokenService(TokenCredential tokenCredential, String url, HttpPipeline pipeline, SerializerAdapter serializerAdapter) { + Objects.requireNonNull(tokenCredential); + Objects.requireNonNull(url); + Objects.requireNonNull(pipeline); + Objects.requireNonNull(serializerAdapter); + + this.tokenService = new TokenServiceImpl(url, pipeline, serializerAdapter); + this.refreshTokenCache = new AccessTokenCacheImpl(new ContainerRegistryTokenCredential(tokenService, tokenCredential)); + } + + ContainerRegistryTokenService setTokenService(TokenServiceImpl tokenServiceImpl) { + this.tokenService = tokenServiceImpl; + return this; + } + + ContainerRegistryTokenService setRefreshTokenCache(AccessTokenCacheImpl tokenCache) { + this.refreshTokenCache = tokenCache; + return this; + } + + /** + * Gets a token against the token request context. + * + * @param tokenRequestContext the token request context to be used to get the token. + */ + public Mono getToken(ContainerRegistryTokenRequestContext tokenRequestContext) { + String scope = tokenRequestContext.getScope(); + String serviceName = tokenRequestContext.getServiceName(); + + return Mono.defer(() -> this.refreshTokenCache.getToken(tokenRequestContext) + .flatMap(refreshToken -> { + return this.tokenService.getAcrAccessTokenAsync(refreshToken.getToken(), scope, serviceName); + }).doOnError(err -> logger.error("Could not fetch the ACR error token.", err))); + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/JsonWebToken.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/JsonWebToken.java new file mode 100644 index 0000000000000..d733e11d457bf --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/JsonWebToken.java @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.containers.containerregistry.implementation.authentication; + +import com.azure.core.util.CoreUtils; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Base64; + +class JsonWebToken { + + /** + * Retrieves the expiration date from the specified JWT value. + * + * @param jwtValue The JWT value. + * @return The date the JWT expires or null if the expiration couldn't be retrieved. + * @throws IllegalArgumentException If the {@code jwtValue} is null or empty. + */ + public static OffsetDateTime retrieveExpiration(String jwtValue) { + if (CoreUtils.isNullOrEmpty(jwtValue)) { + throw new IllegalArgumentException("Value cannot be null or empty: 'jwtValue'."); + } + + String[] jwtParts = jwtValue.split("[.]"); + + // Would normally be 3, but 2 is the minimum here since Java's split ignores trailing empty strings. + if (jwtParts.length < 2) { + return null; + } + + String jwtPayloadEncoded = jwtParts[1]; + + if (CoreUtils.isNullOrEmpty(jwtPayloadEncoded)) { + return null; + } + + byte[] jwtPayloadDecodedData = Base64.getDecoder().decode(jwtPayloadEncoded); + + ObjectMapper mapper = new ObjectMapper(); + + JsonNode rootNode; + try { + rootNode = mapper.readTree(jwtPayloadDecodedData); + } catch (IOException exception) { + return null; + } + + if (!rootNode.has("exp")) { + return null; + } + + long expirationValue = rootNode.get("exp").asLong(); + return OffsetDateTime.ofInstant(Instant.ofEpochSecond(expirationValue), ZoneOffset.UTC); + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/RefreshTokensImpl.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/RefreshTokensImpl.java new file mode 100644 index 0000000000000..e51bf4ec187c8 --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/RefreshTokensImpl.java @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.containers.containerregistry.implementation.authentication; + +import com.azure.containers.containerregistry.implementation.models.AcrErrorsException; +import com.azure.containers.containerregistry.implementation.models.AcrRefreshToken; +import com.azure.core.annotation.ExpectedResponses; +import com.azure.core.annotation.Host; +import com.azure.core.annotation.Post; +import com.azure.core.annotation.ServiceInterface; +import com.azure.core.annotation.FormParam; +import com.azure.core.annotation.HeaderParam; +import com.azure.core.annotation.HostParam; +import com.azure.core.annotation.UnexpectedResponseExceptionType; +import com.azure.core.annotation.ReturnType; +import com.azure.core.annotation.ServiceMethod; +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.rest.Response; +import com.azure.core.http.rest.RestProxy; +import com.azure.core.util.Context; +import com.azure.core.util.FluxUtil; +import com.azure.core.util.serializer.SerializerAdapter; +import reactor.core.publisher.Mono; + +/** An instance of this class provides access to all the operations defined in RefreshTokensService. */ +public final class RefreshTokensImpl { + /** The proxy service used to perform REST calls. */ + private final RefreshTokenService service; + + /** Registry login URL. */ + private final String url; + + /** + * Gets Registry login URL. + * + * @return the url value. + */ + private String getUrl() { + return this.url; + } + + /** + * Initializes an instance of RefreshTokensImpl. + * + * @param url the service endpoint. + * @param httpPipeline the pipeline to use to make the call. + * @param serializerAdapter the serializer adapter for the rest client. + * + */ + public RefreshTokensImpl(String url, HttpPipeline httpPipeline, SerializerAdapter serializerAdapter) { + this.service = + RestProxy.create( + RefreshTokenService.class, + httpPipeline, + serializerAdapter); + this.url = url; + } + + /** + * The interface defining all the services for RegistryRefreshTokens to be used by the + * proxy service to perform REST calls. + */ + @Host("{url}") + @ServiceInterface(name = "ContainerRegistryCon") + private interface RefreshTokenService { + // @Multipart not supported by RestProxy + @Post("/oauth2/exchange") + @ExpectedResponses({200}) + @UnexpectedResponseExceptionType(AcrErrorsException.class) + Mono> getRefreshToken( + @HostParam("url") String url, + @FormParam(value = "grant-type", encoded = true) String grantType, + @FormParam(value = "service", encoded = true) String service, + @FormParam(value = "access_token", encoded = true) String accessToken, + @FormParam(value = "tenant", encoded = true) String tenant, + @HeaderParam("Accept") String accept, + Context context); + } + + /** + * Exchange AAD tokens for an ACR refresh Token. + * + * @param accessToken The accessToken parameter. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws AcrErrorsException thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono> getRefreshTokenWithResponseAsync( + String grantType, + String accessToken, + String tenant, + String serviceValue) { + final String accept = "application/json"; + return FluxUtil.withContext( + context -> service.getRefreshToken(this.getUrl(), grantType, serviceValue, accessToken, tenant, accept, context)); + } + + /** + * Exchange AAD tokens for an ACR refresh Token. + * + * @param accessToken The accessToken parameter. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws AcrErrorsException thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono getRefreshTokenAsync( + String grantType, String accessToken, String tenant, String service) { + return getRefreshTokenWithResponseAsync(grantType, accessToken, tenant, service) + .flatMap( + (Response res) -> { + if (res.getValue() != null) { + return Mono.just(res.getValue()); + } else { + return Mono.empty(); + } + }); + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/TokenServiceImpl.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/TokenServiceImpl.java new file mode 100644 index 0000000000000..cec2a3453a3bb --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/authentication/TokenServiceImpl.java @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.containers.containerregistry.implementation.authentication; + +import com.azure.containers.containerregistry.implementation.models.PostContentSchemaGrantType; +import com.azure.core.credential.AccessToken; +import com.azure.core.http.HttpPipeline; +import com.azure.core.util.serializer.JacksonAdapter; +import com.azure.core.util.serializer.SerializerAdapter; +import reactor.core.publisher.Mono; + +import java.time.OffsetDateTime; + +/** + * Token service implementation that wraps the authentication rest APIs for ACR. + */ +public class TokenServiceImpl { + + private final AccessTokensImpl accessTokensImpl; + private final RefreshTokensImpl refreshTokenImpl; + + /** + * Creates an instance of the token service impl class.TokenServiceImpl.java + * @param url the service endpoint. + * @param pipeline the pipeline to use to make the call. + * @param serializerAdapter the serializer adapter for the rest client. + * + */ + public TokenServiceImpl(String url, HttpPipeline pipeline, SerializerAdapter serializerAdapter) { + if (serializerAdapter == null) { + serializerAdapter = JacksonAdapter.createDefaultSerializerAdapter(); + } + + this.accessTokensImpl = new AccessTokensImpl(url, pipeline, serializerAdapter); + this.refreshTokenImpl = new RefreshTokensImpl(url, pipeline, serializerAdapter); + } + + /** + * Gets the ACR access token. + * @param acrRefreshToken Given the ACRs refresh token. + * @param scope - Token scope. + * @param serviceName The name of the service. + * + */ + public Mono getAcrAccessTokenAsync(String acrRefreshToken, String scope, String serviceName) { + return this.accessTokensImpl.getAccessTokenAsync(PostContentSchemaGrantType.REFRESH_TOKEN.toString(), serviceName, scope, acrRefreshToken) + .map(token -> { + String accessToken = token.getAccessToken(); + OffsetDateTime expirationTime = JsonWebToken.retrieveExpiration(accessToken); + return new AccessToken(accessToken, expirationTime); + }); + } + + /** + * Gets an ACR refresh token. + * @param aadAccessToken Given the ACR access token. + * @param serviceName Given the ACR service. + * + */ + public Mono getAcrRefreshTokenAsync(String aadAccessToken, String serviceName) { + return this.refreshTokenImpl.getRefreshTokenAsync( + PostContentSchemaGrantType.ACCESS_TOKEN.toString(), + aadAccessToken, + null, + serviceName).map(token -> { + String refreshToken = token.getRefreshToken(); + OffsetDateTime expirationTime = JsonWebToken.retrieveExpiration(refreshToken); + return new AccessToken(refreshToken, expirationTime); + }); + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrAccessToken.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrAccessToken.java new file mode 100644 index 0000000000000..d9ff2319f464b --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrAccessToken.java @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.containers.containerregistry.implementation.models; + +import com.azure.core.annotation.Fluent; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** The AccessToken model. */ +@Fluent +public final class AcrAccessToken { + /* + * The access token for performing authenticated requests + */ + @JsonProperty(value = "access_token") + private String accessToken; + + /** + * Get the accessToken property: The access token for performing authenticated requests. + * + * @return the accessToken value. + */ + public String getAccessToken() { + return this.accessToken; + } + + /** + * Set the accessToken property: The access token for performing authenticated requests. + * + * @param accessToken the accessToken value to set. + * @return the AccessToken object itself. + */ + public AcrAccessToken setAccessToken(String accessToken) { + this.accessToken = accessToken; + return this; + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrErrorInfo.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrErrorInfo.java new file mode 100644 index 0000000000000..190232330ee15 --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrErrorInfo.java @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.containers.containerregistry.implementation.models; + +import com.azure.core.annotation.Fluent; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Error information. */ +@Fluent +public final class AcrErrorInfo { + /* + * Error code + */ + @JsonProperty(value = "code") + private String code; + + /* + * Error message + */ + @JsonProperty(value = "message") + private String message; + + /* + * Error details + */ + @JsonProperty(value = "detail") + private Object detail; + + /** + * Get the code property: Error code. + * + * @return the code value. + */ + public String getCode() { + return this.code; + } + + /** + * Set the code property: Error code. + * + * @param code the code value to set. + * @return the AcrErrorInfo object itself. + */ + public AcrErrorInfo setCode(String code) { + this.code = code; + return this; + } + + /** + * Get the message property: Error message. + * + * @return the message value. + */ + public String getMessage() { + return this.message; + } + + /** + * Set the message property: Error message. + * + * @param message the message value to set. + * @return the AcrErrorInfo object itself. + */ + public AcrErrorInfo setMessage(String message) { + this.message = message; + return this; + } + + /** + * Get the detail property: Error details. + * + * @return the detail value. + */ + public Object getDetail() { + return this.detail; + } + + /** + * Set the detail property: Error details. + * + * @param detail the detail value to set. + * @return the AcrErrorInfo object itself. + */ + public AcrErrorInfo setDetail(Object detail) { + this.detail = detail; + return this; + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrErrors.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrErrors.java new file mode 100644 index 0000000000000..e8b607810439e --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrErrors.java @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.containers.containerregistry.implementation.models; + +import com.azure.core.annotation.Fluent; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** Acr error response describing why the operation failed. */ +@Fluent +public final class AcrErrors { + /* + * Array of detailed error + */ + @JsonProperty(value = "errors") + private List errors; + + /** + * Get the errors property: Array of detailed error. + * + * @return the errors value. + */ + public List getErrors() { + return this.errors; + } + + /** + * Set the errors property: Array of detailed error. + * + * @param errors the errors value to set. + * @return the AcrErrors object itself. + */ + public AcrErrors setErrors(List errors) { + this.errors = errors; + return this; + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrErrorsException.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrErrorsException.java new file mode 100644 index 0000000000000..9684100072526 --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrErrorsException.java @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.containers.containerregistry.implementation.models; + +import com.azure.core.exception.HttpResponseException; +import com.azure.core.http.HttpResponse; + +/** Exception thrown for an invalid response with AcrErrors information. */ +public final class AcrErrorsException extends HttpResponseException { + /** + * Initializes a new instance of the AcrErrorsException class. + * + * @param message the exception message or the response content if a message is not available. + * @param response the HTTP response. + */ + public AcrErrorsException(String message, HttpResponse response) { + super(message, response); + } + + /** + * Initializes a new instance of the AcrErrorsException class. + * + * @param message the exception message or the response content if a message is not available. + * @param response the HTTP response. + * @param value the deserialized response value. + */ + public AcrErrorsException(String message, HttpResponse response, AcrErrors value) { + super(message, response, value); + } + + @Override + public AcrErrors getValue() { + return (AcrErrors) super.getValue(); + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrRefreshToken.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrRefreshToken.java new file mode 100644 index 0000000000000..d30a6fc8f47b4 --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/AcrRefreshToken.java @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.containers.containerregistry.implementation.models; + +import com.azure.core.annotation.Fluent; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** The RefreshToken model. */ +@Fluent +public final class AcrRefreshToken { + /* + * The refresh token to be used for generating access tokens + */ + @JsonProperty(value = "refresh_token") + private String refreshToken; + + /** + * Get the refreshToken property: The refresh token to be used for generating access tokens. + * + * @return the refreshToken value. + */ + public String getRefreshToken() { + return this.refreshToken; + } + + /** + * Set the refreshToken property: The refresh token to be used for generating access tokens. + * + * @param refreshToken the refreshToken value to set. + * @return the RefreshToken object itself. + */ + public AcrRefreshToken setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + return this; + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/Oauth2ExchangePostRequestbody.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/Oauth2ExchangePostRequestbody.java new file mode 100644 index 0000000000000..040c4014fe568 --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/Oauth2ExchangePostRequestbody.java @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.containers.containerregistry.implementation.models; + +import com.azure.core.annotation.Fluent; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** The Paths108HwamOauth2ExchangePostRequestbodyContentApplicationXWwwFormUrlencodedSchema model. */ +@Fluent +public final class Oauth2ExchangePostRequestbody { + /* + * Can take a value of access_token_refresh_token, or access_token, or + * refresh_token + */ + @JsonProperty(value = "grant_type", required = true) + private PostContentSchemaGrantType grantType; + + /* + * Indicates the name of your Azure container registry. + */ + @JsonProperty(value = "service", required = true) + private String service; + + /* + * AAD tenant associated to the AAD credentials. + */ + @JsonProperty(value = "tenant") + private String tenant; + + /* + * AAD refresh token, mandatory when grant_type is + * access_token_refresh_token or refresh_token + */ + @JsonProperty(value = "refresh_token") + private String refreshToken; + + /* + * AAD access token, mandatory when grant_type is + * access_token_refresh_token or access_token. + */ + @JsonProperty(value = "access_token") + private String accessToken; + + /** + * Get the grantType property: Can take a value of access_token_refresh_token, or access_token, or refresh_token. + * + * @return the grantType value. + */ + public PostContentSchemaGrantType getGrantType() { + return this.grantType; + } + + /** + * Set the grantType property: Can take a value of access_token_refresh_token, or access_token, or refresh_token. + * + * @param grantType the grantType value to set. + * @return the Paths108HwamOauth2ExchangePostRequestbodyContentApplicationXWwwFormUrlencodedSchema object itself. + */ + public Oauth2ExchangePostRequestbody setGrantType( + PostContentSchemaGrantType grantType) { + this.grantType = grantType; + return this; + } + + /** + * Get the service property: Indicates the name of your Azure container registry. + * + * @return the service value. + */ + public String getService() { + return this.service; + } + + /** + * Set the service property: Indicates the name of your Azure container registry. + * + * @param service the service value to set. + * @return the Paths108HwamOauth2ExchangePostRequestbodyContentApplicationXWwwFormUrlencodedSchema object itself. + */ + public Oauth2ExchangePostRequestbody setService( + String service) { + this.service = service; + return this; + } + + /** + * Get the tenant property: AAD tenant associated to the AAD credentials. + * + * @return the tenant value. + */ + public String getTenant() { + return this.tenant; + } + + /** + * Set the tenant property: AAD tenant associated to the AAD credentials. + * + * @param tenant the tenant value to set. + * @return the Paths108HwamOauth2ExchangePostRequestbodyContentApplicationXWwwFormUrlencodedSchema object itself. + */ + public Oauth2ExchangePostRequestbody setTenant( + String tenant) { + this.tenant = tenant; + return this; + } + + /** + * Get the refreshToken property: AAD refresh token, mandatory when grant_type is access_token_refresh_token or + * refresh_token. + * + * @return the refreshToken value. + */ + public String getRefreshToken() { + return this.refreshToken; + } + + /** + * Set the refreshToken property: AAD refresh token, mandatory when grant_type is access_token_refresh_token or + * refresh_token. + * + * @param refreshToken the refreshToken value to set. + * @return the Paths108HwamOauth2ExchangePostRequestbodyContentApplicationXWwwFormUrlencodedSchema object itself. + */ + public Oauth2ExchangePostRequestbody setRefreshToken( + String refreshToken) { + this.refreshToken = refreshToken; + return this; + } + + /** + * Get the accessToken property: AAD access token, mandatory when grant_type is access_token_refresh_token or + * access_token. + * + * @return the accessToken value. + */ + public String getAccessToken() { + return this.accessToken; + } + + /** + * Set the accessToken property: AAD access token, mandatory when grant_type is access_token_refresh_token or + * access_token. + * + * @param accessToken the accessToken value to set. + * @return the Paths108HwamOauth2ExchangePostRequestbodyContentApplicationXWwwFormUrlencodedSchema object itself. + */ + public Oauth2ExchangePostRequestbody setAccessToken( + String accessToken) { + this.accessToken = accessToken; + return this; + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/Oauth2TokenPostRequestbody.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/Oauth2TokenPostRequestbody.java new file mode 100644 index 0000000000000..26737e9bddeb6 --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/Oauth2TokenPostRequestbody.java @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.containers.containerregistry.implementation.models; + +import com.azure.core.annotation.Fluent; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** The PathsV3R3RxOauth2TokenPostRequestbodyContentApplicationXWwwFormUrlencodedSchema model. */ +@Fluent +public final class Oauth2TokenPostRequestbody { + /* + * Grant type is expected to be refresh_token + */ + @JsonProperty(value = "grant_type", required = true) + private String grantType; + + /* + * Indicates the name of your Azure container registry. + */ + @JsonProperty(value = "service", required = true) + private String service; + + /* + * Which is expected to be a valid scope, and can be specified more than + * once for multiple scope requests. You obtained this from the + * Www-Authenticate response header from the challenge. + */ + @JsonProperty(value = "scope", required = true) + private String scope; + + /* + * Must be a valid ACR refresh token + */ + @JsonProperty(value = "refresh_token", required = true) + private String refreshToken; + + /** Creates an instance of PathsV3R3RxOauth2TokenPostRequestbodyContentApplicationXWwwFormUrlencodedSchema class. */ + public Oauth2TokenPostRequestbody() { + grantType = "refresh_token"; + } + + /** + * Get the grantType property: Grant type is expected to be refresh_token. + * + * @return the grantType value. + */ + public String getGrantType() { + return this.grantType; + } + + /** + * Get the service property: Indicates the name of your Azure container registry. + * + * @return the service value. + */ + public String getService() { + return this.service; + } + + /** + * Set the service property: Indicates the name of your Azure container registry. + * + * @param service the service value to set. + * @return the PathsV3R3RxOauth2TokenPostRequestbodyContentApplicationXWwwFormUrlencodedSchema object itself. + */ + public Oauth2TokenPostRequestbody setService(String service) { + this.service = service; + return this; + } + + /** + * Get the scope property: Which is expected to be a valid scope, and can be specified more than once for multiple + * scope requests. You obtained this from the Www-Authenticate response header from the challenge. + * + * @return the scope value. + */ + public String getScope() { + return this.scope; + } + + /** + * Set the scope property: Which is expected to be a valid scope, and can be specified more than once for multiple + * scope requests. You obtained this from the Www-Authenticate response header from the challenge. + * + * @param scope the scope value to set. + * @return the PathsV3R3RxOauth2TokenPostRequestbodyContentApplicationXWwwFormUrlencodedSchema object itself. + */ + public Oauth2TokenPostRequestbody setScope(String scope) { + this.scope = scope; + return this; + } + + /** + * Get the refreshToken property: Must be a valid ACR refresh token. + * + * @return the refreshToken value. + */ + public String getRefreshToken() { + return this.refreshToken; + } + + /** + * Set the refreshToken property: Must be a valid ACR refresh token. + * + * @param refreshToken the refreshToken value to set. + * @return the PathsV3R3RxOauth2TokenPostRequestbodyContentApplicationXWwwFormUrlencodedSchema object itself. + */ + public Oauth2TokenPostRequestbody setRefreshToken( + String refreshToken) { + this.refreshToken = refreshToken; + return this; + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/PostContentSchemaGrantType.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/PostContentSchemaGrantType.java new file mode 100644 index 0000000000000..07b369a481162 --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/models/PostContentSchemaGrantType.java @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.containers.containerregistry.implementation.models; + +import com.azure.core.util.ExpandableStringEnum; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.util.Collection; + +/** Defines values for PostContentSchemaGrantType. */ +public final class PostContentSchemaGrantType extends ExpandableStringEnum { + /** Static value access_token_refresh_token for PostContentSchemaGrantType. */ + public static final PostContentSchemaGrantType ACCESS_TOKEN_REFRESH_TOKEN = + fromString("access_token_refresh_token"); + + /** Static value access_token for PostContentSchemaGrantType. */ + public static final PostContentSchemaGrantType ACCESS_TOKEN = fromString("access_token"); + + /** Static value refresh_token for PostContentSchemaGrantType. */ + public static final PostContentSchemaGrantType REFRESH_TOKEN = fromString("refresh_token"); + + /** + * Creates or finds a PostContentSchemaGrantType from its string representation. + * + * @param name a name to look for. + * @return the corresponding PostContentSchemaGrantType. + */ + @JsonCreator + public static PostContentSchemaGrantType fromString(String name) { + return fromString(name, PostContentSchemaGrantType.class); + } + + /** @return known PostContentSchemaGrantType values. */ + public static Collection values() { + return values(PostContentSchemaGrantType.class); + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/module-info.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/module-info.java index 159d77dbbfd0d..11e0465500d4c 100644 --- a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/module-info.java +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/module-info.java @@ -2,5 +2,6 @@ // Licensed under the MIT License. module com.azure.containers.containerregistry { + requires transitive com.azure.core; exports com.azure.containers.containerregistry; } diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/test/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryClientPolicyImplTest.java b/sdk/containerregistry/azure-containers-containerregistry/src/test/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryClientPolicyImplTest.java new file mode 100644 index 0000000000000..3b6105994b613 --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/test/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryClientPolicyImplTest.java @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.containers.containerregistry.implementation.authentication; + +import com.azure.core.credential.AccessToken; +import com.azure.core.http.HttpPipelineCallContext; +import com.azure.core.http.HttpPipelineNextPolicy; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; +import com.azure.core.test.http.MockHttpResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import reactor.core.publisher.Mono; + +import java.time.OffsetDateTime; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class ContainerRegistryClientPolicyImplTest { + + public static final String WWW_AUTHENTICATE = "WWW-Authenticate"; + public static final String AUTHENTICATE_HEADER = "Bearer realm=\"https://mytest.azurecr.io/oauth2/token\",service=\"mytest.azurecr.io\",scope=\"registry:catalog:*\",error=\"invalid_token\""; + public static final Integer UNAUTHORIZED = 401; + public static final Integer SUCCESS = 200; + public static final String AUTHORIZATION = "Authorization"; + public static final String BEARER = "Bearer"; + public static final String TOKENVALUE = "tokenValue"; + public static final String SERVICENAME = "mytest.azurecr.io"; + public static final String SCOPENAME = "registry:catalog:*"; + + private ContainerRegistryTokenService service; + private HttpRequest request; + private HttpResponse unauthorizedHttpResponse; + private HttpResponse unauthorizedHttpResponseWithoutHeader; + private HttpPipelineCallContext callContext; + private HttpResponse successResponse; + private HttpPipelineNextPolicy nextPolicy; + private HttpPipelineNextPolicy nextClonePolicy; + + @BeforeEach + public void setup() { + AccessToken accessToken = new AccessToken("tokenValue", OffsetDateTime.now().plusMinutes(30)); + + ContainerRegistryTokenService mockService = mock(ContainerRegistryTokenService.class); + when(mockService.getToken(any(ContainerRegistryTokenRequestContext.class))).thenReturn(Mono.just(accessToken)); + + HttpRequest request = new HttpRequest(HttpMethod.GET, "https://mytest.azurecr.io"); + + HttpPipelineCallContext context = mock(HttpPipelineCallContext.class); + when(context.getHttpRequest()).thenReturn(request); + + MockHttpResponse unauthorizedResponseWithHeader = new MockHttpResponse( + mock(HttpRequest.class), + UNAUTHORIZED, + new HttpHeaders().set(WWW_AUTHENTICATE, AUTHENTICATE_HEADER) + ); + + MockHttpResponse unauthorizedResponseWithoutHeader = new MockHttpResponse( + mock(HttpRequest.class), + UNAUTHORIZED); + + MockHttpResponse successResponse = new MockHttpResponse( + mock(HttpRequest.class), + SUCCESS + ); + + HttpPipelineNextPolicy mockNextClone = mock(HttpPipelineNextPolicy.class); + when(mockNextClone.process()).thenReturn(Mono.just(successResponse)); + HttpPipelineNextPolicy mockNext = mock(HttpPipelineNextPolicy.class); + when(mockNext.clone()).thenReturn(mockNextClone); + when(mockNext.process()).thenReturn(Mono.just(unauthorizedResponseWithHeader)); + + this.service = mockService; + this.unauthorizedHttpResponse = unauthorizedResponseWithHeader; + this.unauthorizedHttpResponseWithoutHeader = unauthorizedResponseWithoutHeader; + this.callContext = context; + this.request = request; + this.successResponse = successResponse; + this.nextClonePolicy = mockNextClone; + this.nextPolicy = mockNext; + } + + @Test + public void requestNoRetryOnOtherErrorCodes() { + ContainerRegistryCredentialsPolicy policy = new ContainerRegistryCredentialsPolicy(this.service); + ContainerRegistryCredentialsPolicy spyPolicy = Mockito.spy(policy); + + when(nextPolicy.process()).thenReturn(Mono.just(successResponse)); + policy.process(this.callContext, this.nextPolicy).block(); + + // Make sure no call being done to the authorize request. + verify(spyPolicy, times(0)).authorizeRequest(any(HttpPipelineCallContext.class), any(ContainerRegistryTokenRequestContext.class)); + + when(nextPolicy.process()).thenReturn(Mono.just(unauthorizedHttpResponseWithoutHeader)); + policy.process(this.callContext, this.nextPolicy).block(); + + // Make sure no call being done to the authorize request. + verify(spyPolicy, times(0)).authorizeRequest(any(HttpPipelineCallContext.class), any(ContainerRegistryTokenRequestContext.class)); + } + + @Test + public void requestAddBearerTokenToRequest() { + ContainerRegistryCredentialsPolicy policy = new ContainerRegistryCredentialsPolicy(this.service); + ContainerRegistryCredentialsPolicy spyPolicy = Mockito.spy(policy); + Boolean onChallenge = spyPolicy.onChallenge(this.callContext, this.unauthorizedHttpResponse).block(); + + // Validate that the onChallenge ran successfully. + assertTrue(onChallenge); + + // Validate that the request has the correct authorization header. + String tokenValue = this.callContext.getHttpRequest().getHeaders().getValue(AUTHORIZATION); + assertFalse(tokenValue.isEmpty()); + assertTrue(tokenValue.startsWith(BEARER)); + assertTrue(tokenValue.endsWith(tokenValue)); + + // Validate that the token creation was called with the correct arguments. + ArgumentCaptor argument = ArgumentCaptor.forClass(ContainerRegistryTokenRequestContext.class); + verify(spyPolicy).authorizeRequest(any(HttpPipelineCallContext.class), argument.capture()); + + ContainerRegistryTokenRequestContext requestContext = argument.getValue(); + assertEquals(SERVICENAME, requestContext.getServiceName()); + assertEquals(SCOPENAME, requestContext.getScope()); + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/test/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryTokenServiceTest.java b/sdk/containerregistry/azure-containers-containerregistry/src/test/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryTokenServiceTest.java new file mode 100644 index 0000000000000..17fcd96206fb2 --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/test/java/com/azure/containers/containerregistry/implementation/authentication/ContainerRegistryTokenServiceTest.java @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.containers.containerregistry.implementation.authentication; + +import com.azure.core.credential.AccessToken; +import com.azure.core.credential.TokenCredential; +import com.azure.core.credential.TokenRequestContext; +import com.azure.core.http.HttpPipeline; +import com.azure.core.util.serializer.SerializerAdapter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import java.time.OffsetDateTime; +import java.util.concurrent.CountDownLatch; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +public class ContainerRegistryTokenServiceTest { + + private TokenCredential tokenCredential; + private HttpPipeline httpPipeline; + private SerializerAdapter serializerAdapter; + private TokenServiceImpl tokenServiceImpl; + private AccessTokenCacheImpl refreshTokenCache; + private ContainerRegistryTokenRequestContext requestContext; + private ContainerRegistryTokenCredential refreshTokenCredential; + + private static final String SCOPE = "scope"; + private static final String SERVICENAME = "serviceName"; + private static final String REFRESHTOKEN = "refresh_token"; + private static final String ACCESSTOKEN = "access_token"; + + + @BeforeEach + public void setup() { + this.httpPipeline = mock(HttpPipeline.class); + this.serializerAdapter = mock(SerializerAdapter.class); + + TokenServiceImpl impl = mock(TokenServiceImpl.class); + AccessToken refreshToken = new AccessToken(REFRESHTOKEN, OffsetDateTime.now().plusMinutes(30)); + AccessToken accessToken = new AccessToken(ACCESSTOKEN, OffsetDateTime.now().plusMinutes(30)); + when(impl.getAcrAccessTokenAsync(anyString(), anyString(), anyString())).thenReturn(Mono.just(accessToken)); + when(impl.getAcrRefreshTokenAsync(anyString(), anyString())).thenReturn(Mono.just(refreshToken)); + + TokenCredential tokenCredential = mock(TokenCredential.class); + when(tokenCredential.getToken(any(TokenRequestContext.class))).thenReturn(Mono.just(accessToken)); + + ContainerRegistryTokenRequestContext tokenRequestContext = mock(ContainerRegistryTokenRequestContext.class); + when(tokenRequestContext.getScope()).thenReturn(SCOPE); + when(tokenRequestContext.getServiceName()).thenReturn(SERVICENAME); + + ContainerRegistryTokenCredential spyRefreshTokenCredential = spy(mock(ContainerRegistryTokenCredential.class)); + doReturn(Mono.just(refreshToken)).when(spyRefreshTokenCredential).getToken(any(ContainerRegistryTokenRequestContext.class)); + + + + AccessTokenCacheImpl refreshTokenCache = new AccessTokenCacheImpl(spyRefreshTokenCredential); + this.tokenCredential = tokenCredential; + this.refreshTokenCache = refreshTokenCache; + this.refreshTokenCredential = spyRefreshTokenCredential; + this.requestContext = tokenRequestContext; + this.tokenServiceImpl = impl; + } + + @Test + public void refreshTokenRestAPICalledOnlyOnce() throws Exception { + ContainerRegistryTokenService service = new ContainerRegistryTokenService( + this.tokenCredential, + "myString", + this.httpPipeline, + this.serializerAdapter + ); + + service.setTokenService(this.tokenServiceImpl); + service.setRefreshTokenCache(this.refreshTokenCache); + + CountDownLatch latch = new CountDownLatch(1); + + Flux.range(1, 10) + .flatMap(i -> Mono.just(OffsetDateTime.now()) + // Runs cache.getToken() on 10 different threads + .subscribeOn(Schedulers.newParallel("pool", 10)) + .flatMap( + start -> service.getToken(this.requestContext).map(accessToken -> 1)) + ) + .doOnComplete(latch::countDown) + .subscribe(); + + latch.await(); + + // We call the acrrefreshToken method only once. + verify(this.refreshTokenCredential, times(1)).getToken(this.requestContext); + + } +} diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/sdk/containerregistry/azure-containers-containerregistry/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000000..ca6ee9cea8ec1 --- /dev/null +++ b/sdk/containerregistry/azure-containers-containerregistry/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file