Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement an Event Hubs Shared Access Key Credential #21228

Merged
merged 14 commits into from
Jun 9, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
v-xuto marked this conversation as resolved.
Show resolved Hide resolved
// Licensed under the MIT License.

package com.azure.identity;

import com.azure.core.annotation.Immutable;
import com.azure.core.credential.AccessToken;
import com.azure.core.credential.TokenCredential;
import com.azure.core.credential.TokenRequestContext;
import com.azure.core.util.CoreUtils;
import com.azure.core.util.logging.ClientLogger;
import reactor.core.publisher.Mono;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Base64;
import java.util.Locale;

import static java.nio.charset.StandardCharsets.UTF_8;

/**
* Authorizes with Azure Event Hub service using a shared access key from either an Event Hub namespace or a specific
* Event Hub.
*
* <p>
* The shared access key can be obtained by creating a <i>shared access policy</i> for the Event Hub namespace or for
* a specific Event Hub instance. See
* <a href="https://docs.microsoft.com/en-us/azure/event-hubs/authorize-access-shared-access-signature#shared-access-authorization-policies">Shared access authorization policies</a> for more information.
* </p>
*
* @see <a href="https://docs.microsoft.com/en-us/azure/event-hubs/authorize-access-shared-access-signature">Authorizeaccess with shared access signature.</a>
*/
@Immutable
public class EventHubSharedKeyCredential implements TokenCredential {

private static final String SHARED_ACCESS_SIGNATURE_FORMAT = "SharedAccessSignature sr=%s&sig=%s&se=%s&skn=%s";
private static final String HASH_ALGORITHM = "HMACSHA256";
public static final Duration TOKEN_VALIDITY = Duration.ofMinutes(20);

private final String sharedAccessPolicy;
private final SecretKeySpec secretKeySpec;
private final String sharedAccessSignature;
private final ClientLogger logger = new ClientLogger(EventHubSharedKeyCredential.class);

/**
* Creates an instance that authorizes using the {@code sharedAccessPolicy} and {@code sharedAccessKey}
* and {@code sharedAccessSignature}.
*
* @param sharedAccessPolicy Name of the shared access key policy.
* @param secretKeySpec Value of the shared access key.
* @param sharedAccessSignature Value of the shared access signature.
*/
public EventHubSharedKeyCredential(String sharedAccessPolicy, SecretKeySpec secretKeySpec,
String sharedAccessSignature) {
this.sharedAccessPolicy = sharedAccessPolicy;
this.secretKeySpec = secretKeySpec;
this.sharedAccessSignature = sharedAccessSignature;
}

/**
* Retrieves the token, given the audience/resources requested, for use in authorization against an Event Hub
* namespace or a specific Event Hub instance.
*
* @param request The details of a token request
* @return A Mono that completes and returns the shared access signature.
* @throws IllegalArgumentException if {@code scopes} does not contain a single value, which is the token
* audience.
*/
@Override
public Mono<AccessToken> getToken(TokenRequestContext request) {
return Mono.fromCallable(() -> generateSharedAccessSignature(request.getScopes().get(0)));
}

private AccessToken generateSharedAccessSignature(final String resource) throws UnsupportedEncodingException {
if (CoreUtils.isNullOrEmpty(resource)) {
throw logger.logExceptionAsError(new IllegalArgumentException("resource cannot be empty"));
}

if (sharedAccessSignature != null) {
return new AccessToken(sharedAccessSignature, getExpirationTime(sharedAccessSignature));
}

final Mac hmac;
try {
hmac = Mac.getInstance(HASH_ALGORITHM);
hmac.init(secretKeySpec);
} catch (NoSuchAlgorithmException e) {
throw logger.logExceptionAsError(new UnsupportedOperationException(
String.format("Unable to create hashing algorithm '%s'", HASH_ALGORITHM), e));
} catch (InvalidKeyException e) {
throw logger.logExceptionAsError(new IllegalArgumentException(
"'sharedAccessKey' is an invalid value for the hashing algorithm.", e));
}

final String utf8Encoding = UTF_8.name();
final OffsetDateTime expiresOn = OffsetDateTime.now(ZoneOffset.UTC).plus(TOKEN_VALIDITY);
final String expiresOnEpochSeconds = Long.toString(expiresOn.toEpochSecond());
final String audienceUri = URLEncoder.encode(resource, utf8Encoding);
final String secretToSign = audienceUri + "\n" + expiresOnEpochSeconds;

final byte[] signatureBytes = hmac.doFinal(secretToSign.getBytes(utf8Encoding));
final String signature = Base64.getEncoder().encodeToString(signatureBytes);

final String token = String.format(Locale.US, SHARED_ACCESS_SIGNATURE_FORMAT,
audienceUri,
URLEncoder.encode(signature, utf8Encoding),
URLEncoder.encode(expiresOnEpochSeconds, utf8Encoding),
URLEncoder.encode(sharedAccessPolicy, utf8Encoding));
return new AccessToken(token, expiresOn);
}

private OffsetDateTime getExpirationTime(String sharedAccessSignature) {
String[] parts = sharedAccessSignature.split("&");
return Arrays.stream(parts)
.map(part -> part.split("="))
.filter(pair -> pair.length == 2 && pair[0].equalsIgnoreCase("se"))
.findFirst()
.map(pair -> pair[1])
.map(expirationTimeStr -> {
try {
long epochSeconds = Long.parseLong(expirationTimeStr);
return Instant.ofEpochSecond(epochSeconds).atOffset(ZoneOffset.UTC);
} catch (NumberFormatException exception) {
logger.verbose("Invalid expiration time format in the SAS token: {}. Falling back to max "
+ "expiration time.", expirationTimeStr);
return OffsetDateTime.MAX;
}
})
.orElse(OffsetDateTime.MAX);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package com.azure.identity;

import com.azure.identity.implementation.util.ValidationUtil;
import javax.crypto.spec.SecretKeySpec;
import java.util.HashMap;
import java.util.Map;

import static java.nio.charset.StandardCharsets.UTF_8;

/**
* Fluent credential builder for instantiating a {@link EventHubSharedKeyCredential}.
*
* @see EventHubSharedKeyCredential
*/
public class EventHubSharedKeyCredentialBuilder extends AadCredentialBuilderBase<EventHubSharedKeyCredentialBuilder> {

private static final String HASH_ALGORITHM = "HMACSHA256";

private String sharedAccessPolicy;
private SecretKeySpec secretKeySpec;
private String sharedAccessSignature;

/**
* Sets the sharedAccessPolicy of the user.
*
* @param sharedAccessPolicy the sharedAccessPolicy of the user
* @return the EventHubSharedKeyCredentialBuilder itself
*/
public EventHubSharedKeyCredentialBuilder sharedAccessPolicy(String sharedAccessPolicy) {
this.sharedAccessPolicy = sharedAccessPolicy;
return this;
}

/**
* Sets the shardAccessKey of the user.
*
* @param sharedAccessKey the sharedAccessKey of the user
* @return the ServiceBusSharedKeyCredentialBuilder itself
*/
public EventHubSharedKeyCredentialBuilder sharedAccessKey(String sharedAccessKey) {
this.secretKeySpec = new SecretKeySpec(sharedAccessKey.getBytes(UTF_8), HASH_ALGORITHM);
return this;
}

/**
* Sets the sharedAccessSignature of the user.
*
* @param sharedAccessSignature the sharedAccessSignature of the user
* @return the ServiceBusSharedKeyCredentialBuilder itself
*/
public EventHubSharedKeyCredentialBuilder sharedAccessSignature(String sharedAccessSignature) {
this.sharedAccessSignature = sharedAccessSignature;
return this;
}

/**
* Creates a new {@link EventHubSharedKeyCredential} with the current configurations.
*
* @return a {@link EventHubSharedKeyCredential} with the current configurations.
*/
public EventHubSharedKeyCredential build() {
Map<String, Object> validationMap = new HashMap<String, Object>();
validationMap.put("sharedAccessPolicy", sharedAccessPolicy);
validationMap.put("sharedAccessKey", secretKeySpec);
validationMap.put("sharedAccessSignature", sharedAccessSignature);
ValidationUtil.validateForSharedAccessKey(getClass().getSimpleName(), validationMap);
return new EventHubSharedKeyCredential(sharedAccessPolicy, secretKeySpec, sharedAccessSignature);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@
public final class ValidationUtil {
private static Pattern tenantIdentifierCharPattern = Pattern.compile("^(?:[A-Z]|[0-9]|[a-z]|-|.)+$");

public static void validateForSharedAccessKey(String className, Map<String, Object> parameters) {
ClientLogger logger = new ClientLogger(className);
List<String> missing = new ArrayList<>();
if (parameters.get("sharedAccessSignature") == null) {
parameters.remove("sharedAccessSignature");
for (Map.Entry<String, Object> entry : parameters.entrySet()) {
if (entry.getValue() == null) {
missing.add(entry.getKey());
}
}
if (missing.size() > 0) {
throw logger.logExceptionAsWarning(new IllegalArgumentException("Must provide non-null values for "
+ String.join(", ", missing) + " properties in " + className));
}
}
}

public static void validate(String className, Map<String, Object> parameters) {
ClientLogger logger = new ClientLogger(className);
List<String> missing = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package com.azure.identity;

import com.azure.core.credential.AccessToken;
import com.azure.core.credential.TokenRequestContext;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.powermock.api.mockito.PowerMockito;
import org.powermock.core.classloader.annotations.PowerMockIgnore;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.time.OffsetDateTime;
import java.time.ZoneOffset;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;

@RunWith(PowerMockRunner.class)
@PrepareForTest(fullyQualifiedNames = "com.azure.identity.*")
@PowerMockIgnore({"com.sun.org.apache.xerces.*", "javax.xml.*", "org.xml.*"})
public class EventHubSharedKeyCredentialTest {

private static final String SHARED_ACCESS_POLICY = "<YOUR-EVENT-HUB-SHARED-ACCESS-POLICY>";

private static final String SHARED_ACCESS_KEY = "<YOUR-EVENT-HUB-SHARED-ACCESS-KEY>";

private static final String SHARED_ACCESS_SIGNATURE = "<YOUR-EVENT-HUB-ACCESS-SIGNATURE>";

private static final TokenRequestContext REQUEST = new TokenRequestContext().addScopes("<YOUR-ACCESS-SCOPE>");

@Test
public void testValidShardAccessKey() throws Exception {

// mock
EventHubSharedKeyCredential eventHubSharedKeyCredential = PowerMockito.mock(EventHubSharedKeyCredential.class);
when(eventHubSharedKeyCredential.getToken(any(TokenRequestContext.class)))
.thenReturn(getMockAccessToken(SHARED_ACCESS_SIGNATURE, OffsetDateTime.now(ZoneOffset.UTC)));
PowerMockito.whenNew(EventHubSharedKeyCredential.class).withAnyArguments().thenReturn(eventHubSharedKeyCredential);

EventHubSharedKeyCredential credential
= new EventHubSharedKeyCredentialBuilder()
.sharedAccessKey(SHARED_ACCESS_KEY)
.sharedAccessPolicy(SHARED_ACCESS_POLICY)
.build();
StepVerifier.create(credential.getToken(REQUEST))
.expectNextMatches(accessToken -> SHARED_ACCESS_SIGNATURE.equals(accessToken.getToken()))
.verifyComplete();
}

@Test
public void testValidSharedAccessSignature() {
EventHubSharedKeyCredential credential
= new EventHubSharedKeyCredentialBuilder()
.sharedAccessSignature(SHARED_ACCESS_SIGNATURE)
.build();
StepVerifier.create(credential.getToken(REQUEST))
.expectNextMatches(accessToken -> SHARED_ACCESS_SIGNATURE.equals(accessToken.getToken()))
.verifyComplete();
}

/**
* Creates a mock {@link AccessToken} instance.
* @param accessToken the access token to return
* @param expiresOn the expiration time
* @return a Mono publisher of the result
*/
public static Mono<AccessToken> getMockAccessToken(String accessToken, OffsetDateTime expiresOn) {
return Mono.just(new AccessToken(accessToken, expiresOn.plusMinutes(20)));
}

}