From 904321e70090af80d79292719b9f2e5638e20797 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 21 Apr 2020 09:43:44 -0500 Subject: [PATCH] BotFrameworkAdapter auth flow/CallerId --- etc/botframework-java-formatter.xml | 2 +- .../com/microsoft/bot/builder/BotAdapter.java | 70 +++- .../bot/builder/BotFrameworkAdapter.java | 364 ++++++++++++------ .../bot/builder/BotFrameworkAdapterTests.java | 195 +++++++++- .../bot/builder/MemoryConnectorClient.java | 76 ++++ .../bot/builder/MemoryConversations.java | 145 +++++++ .../authentication/JwtTokenValidation.java | 33 ++ .../bot/schema/TokenExchangeState.java | 38 +- 8 files changed, 787 insertions(+), 136 deletions(-) create mode 100644 libraries/bot-builder/src/test/java/com/microsoft/bot/builder/MemoryConnectorClient.java create mode 100644 libraries/bot-builder/src/test/java/com/microsoft/bot/builder/MemoryConversations.java diff --git a/etc/botframework-java-formatter.xml b/etc/botframework-java-formatter.xml index 577eb8d5e..11cd7eff7 100644 --- a/etc/botframework-java-formatter.xml +++ b/etc/botframework-java-formatter.xml @@ -131,7 +131,7 @@ - + diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/BotAdapter.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/BotAdapter.java index 9678a0d20..7bd258210 100644 --- a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/BotAdapter.java +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/BotAdapter.java @@ -3,6 +3,7 @@ package com.microsoft.bot.builder; +import com.microsoft.bot.connector.authentication.ClaimsIdentity; import com.microsoft.bot.schema.Activity; import com.microsoft.bot.schema.ConversationReference; import com.microsoft.bot.schema.ResourceResponse; @@ -10,6 +11,7 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import org.apache.commons.lang3.NotImplementedException; /** * Represents a bot adapter that can connect a bot to a service endpoint. This @@ -32,6 +34,16 @@ * {@link TurnContext} {@link Activity} {@link Bot} {@link Middleware} */ public abstract class BotAdapter { + /** + * Key to store bot claims identity. + */ + public static final String BOT_IDENTITY_KEY = "BotIdentity"; + + /** + * Key to store bot oauth scope. + */ + public static final String OAUTH_SCOPE_KEY = "Microsoft.Bot.Builder.BotAdapter.OAuthScope"; + /** * The collection of middleware in the adapter's pipeline. */ @@ -213,13 +225,10 @@ public CompletableFuture continueConversation( ConversationReference reference, BotCallbackHandler callback ) { - CompletableFuture pipelineResult = new CompletableFuture<>(); - try (TurnContextImpl context = new TurnContextImpl( - this, - reference.getContinuationActivity() - )) { + try (TurnContextImpl context = + new TurnContextImpl(this, reference.getContinuationActivity())) { pipelineResult = runPipeline(context, callback); } catch (Exception e) { pipelineResult.completeExceptionally(e); @@ -227,4 +236,55 @@ public CompletableFuture continueConversation( return pipelineResult; } + + /** + * Sends a proactive message to a conversation. + * + *

+ * Call this method to proactively send a message to a conversation. Most + * channels require a user to initiate a conversation with a bot before the bot + * can send activities to the user. + *

+ * + * @param claimsIdentity A ClaimsIdentity reference for the conversation. + * @param reference A reference to the conversation to continue. + * @param callback The method to call for the result bot turn. + * @return A task that represents the work queued to execute. + */ + public CompletableFuture continueConversation( + ClaimsIdentity claimsIdentity, + ConversationReference reference, + BotCallbackHandler callback + ) { + CompletableFuture result = new CompletableFuture<>(); + result.completeExceptionally(new NotImplementedException("continueConversation")); + return result; + } + + /** + * Sends a proactive message to a conversation. + * + *

+ * Call this method to proactively send a message to a conversation. Most + * channels require a user to initiate a conversation with a bot before the bot + * can send activities to the user. + *

+ * + * @param claimsIdentity A ClaimsIdentity reference for the conversation. + * @param reference A reference to the conversation to continue. + * @param audience A value signifying the recipient of the proactive + * message. + * @param callback The method to call for the result bot turn. + * @return A task that represents the work queued to execute. + */ + public CompletableFuture continueConversation( + ClaimsIdentity claimsIdentity, + ConversationReference reference, + String audience, + BotCallbackHandler callback + ) { + CompletableFuture result = new CompletableFuture<>(); + result.completeExceptionally(new NotImplementedException("continueConversation")); + return result; + } } diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/BotFrameworkAdapter.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/BotFrameworkAdapter.java index 5e3d27812..da560d2e9 100644 --- a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/BotFrameworkAdapter.java +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/BotFrameworkAdapter.java @@ -21,6 +21,7 @@ import com.microsoft.bot.connector.authentication.ChannelProvider; import com.microsoft.bot.connector.authentication.ClaimsIdentity; import com.microsoft.bot.connector.authentication.CredentialProvider; +import com.microsoft.bot.connector.authentication.GovernmentAuthenticationConstants; import com.microsoft.bot.connector.authentication.JwtTokenValidation; import com.microsoft.bot.connector.authentication.MicrosoftAppCredentials; import com.microsoft.bot.connector.authentication.MicrosoftGovernmentAppCredentials; @@ -37,11 +38,11 @@ import com.microsoft.bot.schema.ConversationReference; import com.microsoft.bot.schema.ConversationsResult; import com.microsoft.bot.schema.ResourceResponse; -import com.microsoft.bot.schema.RoleTypes; import com.microsoft.bot.schema.TokenExchangeState; import com.microsoft.bot.schema.TokenResponse; import com.microsoft.bot.schema.TokenStatus; import com.microsoft.bot.rest.retry.RetryStrategy; +import java.util.Collections; import org.apache.commons.lang3.StringUtils; import java.net.HttpURLConnection; @@ -80,11 +81,6 @@ public class BotFrameworkAdapter extends BotAdapter */ public static final String INVOKE_RESPONSE_KEY = "BotFrameworkAdapter.InvokeResponse"; - /** - * Key to store bot claims identity. - */ - private static final String BOT_IDENTITY_KEY = "BotIdentity"; - /** * Key to store ConnectorClient. */ @@ -296,26 +292,101 @@ public CompletableFuture continueConversation( throw new IllegalArgumentException("callback"); } + // Hand craft Claims Identity. + HashMap claims = new HashMap() { + { + // Adding claims for both Emulator and Channel. + put(AuthenticationConstants.AUDIENCE_CLAIM, botAppId); + put(AuthenticationConstants.APPID_CLAIM, botAppId); + } + }; + ClaimsIdentity claimsIdentity = new ClaimsIdentity("ExternalBearer", claims); + + String audience = getBotFrameworkOAuthScope(); + + return continueConversation(claimsIdentity, reference, audience, callback); + } + + /** + * Sends a proactive message to a conversation. + * + *

+ * Call this method to proactively send a message to a conversation. Most + * channels require a user to initiate a conversation with a bot before the bot + * can send activities to the user. + *

+ * + * @param claimsIdentity A ClaimsIdentity reference for the conversation. + * @param reference A reference to the conversation to continue. + * @param callback The method to call for the result bot turn. + * @return A task that represents the work queued to execute. + */ + public CompletableFuture continueConversation( + ClaimsIdentity claimsIdentity, + ConversationReference reference, + BotCallbackHandler callback + ) { + return continueConversation( + claimsIdentity, + reference, + getBotFrameworkOAuthScope(), + callback + ); + } + + /** + * Sends a proactive message to a conversation. + * + *

+ * Call this method to proactively send a message to a conversation. Most + * channels require a user to initiate a conversation with a bot before the bot + * can send activities to the user. + *

+ * + * @param claimsIdentity A ClaimsIdentity reference for the conversation. + * @param reference A reference to the conversation to continue. + * @param audience A value signifying the recipient of the proactive + * message. + * @param callback The method to call for the result bot turn. + * @return A task that represents the work queued to execute. + */ + public CompletableFuture continueConversation( + ClaimsIdentity claimsIdentity, + ConversationReference reference, + String audience, + BotCallbackHandler callback + ) { + if (claimsIdentity == null) { + throw new IllegalArgumentException("claimsIdentity"); + } + + if (reference == null) { + throw new IllegalArgumentException("reference"); + } + + if (callback == null) { + throw new IllegalArgumentException("callback"); + } + + if (StringUtils.isEmpty(audience)) { + throw new IllegalArgumentException("audience cannot be null or empty"); + } + CompletableFuture pipelineResult = new CompletableFuture<>(); try (TurnContextImpl context = new TurnContextImpl(this, reference.getContinuationActivity())) { - // Hand craft Claims Identity. - HashMap claims = new HashMap() { - { - put(AuthenticationConstants.AUDIENCE_CLAIM, botAppId); - put(AuthenticationConstants.APPID_CLAIM, botAppId); - } - }; - ClaimsIdentity claimsIdentity = new ClaimsIdentity("ExternalBearer", claims); - context.getTurnState().add(BOT_IDENTITY_KEY, claimsIdentity); - - pipelineResult = createConnectorClient(reference.getServiceUrl(), claimsIdentity) - .thenCompose(connectorClient -> { - context.getTurnState().add(CONNECTOR_CLIENT_KEY, connectorClient); - return runPipeline(context, callback); - }); + context.getTurnState().add(OAUTH_SCOPE_KEY, audience); + + pipelineResult = createConnectorClient( + reference.getServiceUrl(), + claimsIdentity, + audience + ).thenCompose(connectorClient -> { + context.getTurnState().add(CONNECTOR_CLIENT_KEY, connectorClient); + return runPipeline(context, callback); + }); } catch (Exception e) { pipelineResult.completeExceptionally(e); } @@ -359,7 +430,11 @@ public CompletableFuture processActivity( BotAssert.activityNotNull(activity); return JwtTokenValidation.authenticateRequest( - activity, authHeader, credentialProvider, channelProvider, authConfiguration + activity, + authHeader, + credentialProvider, + channelProvider, + authConfiguration ).thenCompose(claimsIdentity -> processActivity(claimsIdentity, activity, callback)); } @@ -390,7 +465,12 @@ public CompletableFuture processActivity( activity.setCallerId(generateCallerId(identity).join()); context.getTurnState().add(BOT_IDENTITY_KEY, identity); - pipelineResult = createConnectorClient(activity.getServiceUrl(), identity) + // The OAuthScope is also stored on the TurnState to get the correct + // AppCredentials if fetching a token is required. + String scope = getBotFrameworkOAuthScope(); + context.getTurnState().add(OAUTH_SCOPE_KEY, scope); + + pipelineResult = createConnectorClient(activity.getServiceUrl(), identity, scope) // run pipeline .thenCompose(connectorClient -> { @@ -399,8 +479,7 @@ public CompletableFuture processActivity( }) // Handle Invoke scenarios, which deviate from the request/response model in - // that - // the Bot will return a specific body and return code. + // that the Bot will return a specific body and return code. .thenCompose(result -> { if (activity.isType(ActivityTypes.INVOKE)) { Activity invokeResponse = context.getTurnState().get(INVOKE_RESPONSE_KEY); @@ -429,9 +508,9 @@ public CompletableFuture processActivity( private CompletableFuture generateCallerId(ClaimsIdentity claimsIdentity) { return credentialProvider.isAuthenticationDisabled() .thenApply( - is_auth_enabled -> { + is_auth_disabled -> { // Is the bot accepting all incoming messages? - if (!is_auth_enabled) { + if (is_auth_disabled) { return null; } @@ -463,7 +542,7 @@ private CompletableFuture generateCallerId(ClaimsIdentity claimsIdentity * * {@link TurnContext#onSendActivities(SendActivitiesHandler)} */ - @SuppressWarnings("checkstyle:EmptyBlock") + @SuppressWarnings("checkstyle:EmptyBlock, checkstyle:linelength") @Override public CompletableFuture sendActivities( TurnContext context, @@ -494,8 +573,11 @@ public CompletableFuture sendActivities( */ for (int index = 0; index < activities.size(); index++) { Activity activity = activities.get(index); - ResourceResponse response; + // Clients and bots SHOULD NOT include an id field in activities they generate. + activity.setId(null); + + ResourceResponse response; if (activity.isType(ActivityTypes.DELAY)) { // The Activity Schema doesn't have a delay type build in, so it's simulated // here in the Bot. This matches the behavior in the Node connector. @@ -529,17 +611,14 @@ public CompletableFuture sendActivities( } // If No response is set, then default to a "simple" response. This can't really - // be done - // above, as there are cases where the ReplyTo/SendTo methods will also return - // null - // (See below) so the check has to happen here. - + // be done above, as there are cases where the ReplyTo/SendTo methods will also + // return null (See below) so the check has to happen here. + // // Note: In addition to the Invoke / Delay / Activity cases, this code also - // applies - // with Skype and Teams with regards to typing events. When sending a typing - // event in - // these channels they do not return a RequestResponse which causes the bot to - // blow up. + // applies with Skype and Teams with regards to typing events. When sending a + // typing event in these channels they do not return a RequestResponse which + // causes the bot to blow up. + // // https://github.com/Microsoft/botbuilder-dotnet/issues/460 // bug report : https://github.com/Microsoft/botbuilder-dotnet/issues/465 if (response == null) { @@ -826,11 +905,13 @@ public CompletableFuture getUserToken( throw new IllegalArgumentException("connectionName"); } - return createOAuthClient(context).thenCompose(oAuthClient -> { + return createOAuthClient(context, null).thenCompose(oAuthClient -> { return oAuthClient.getUserToken() .getToken( - context.getActivity().getFrom().getId(), connectionName, - context.getActivity().getChannelId(), magicCode + context.getActivity().getFrom().getId(), + connectionName, + context.getActivity().getChannelId(), + magicCode ); }); } @@ -854,9 +935,10 @@ public CompletableFuture getOauthSignInLink( throw new IllegalArgumentException("connectionName"); } - return createOAuthClient(context).thenCompose(oAuthClient -> { + return createOAuthClient(context, null).thenCompose(oAuthClient -> { try { Activity activity = context.getActivity(); + String appId = getBotAppId(context); TokenExchangeState tokenExchangeState = new TokenExchangeState() { { @@ -871,6 +953,8 @@ public CompletableFuture getOauthSignInLink( setUser(activity.getFrom()); } }); + setMsAppId(appId); + setRelatesTo(activity.getRelatesTo()); } }; @@ -904,7 +988,6 @@ public CompletableFuture getOauthSignInLink( String userId, String finalRedirect ) { - BotAssert.contextNotNull(context); if (StringUtils.isEmpty(connectionName)) { throw new IllegalArgumentException("connectionName"); @@ -913,30 +996,26 @@ public CompletableFuture getOauthSignInLink( throw new IllegalArgumentException("userId"); } - return createOAuthClient(context).thenCompose(oAuthClient -> { + return createOAuthClient(context, null).thenCompose(oAuthClient -> { try { + Activity activity = context.getActivity(); + String appId = getBotAppId(context); + TokenExchangeState tokenExchangeState = new TokenExchangeState() { { setConnectionName(connectionName); setConversation(new ConversationReference() { { - setActivityId(null); - setBot(new ChannelAccount() { - { - setRole(RoleTypes.BOT); - } - }); - setChannelId(Channels.DIRECTLINE); - setConversation(new ConversationAccount()); - setServiceUrl(null); - setUser(new ChannelAccount() { - { - setRole(RoleTypes.USER); - setId(userId); - } - }); + setActivityId(activity.getId()); + setBot(activity.getRecipient()); + setChannelId(activity.getChannelId()); + setConversation(activity.getConversation()); + setServiceUrl(activity.getServiceUrl()); + setUser(activity.getFrom()); } }); + setRelatesTo(activity.getRelatesTo()); + setMsAppId(appId); } }; @@ -971,10 +1050,11 @@ public CompletableFuture signOutUser( throw new IllegalArgumentException("connectionName"); } - return createOAuthClient(context).thenCompose(oAuthClient -> { + return createOAuthClient(context, null).thenCompose(oAuthClient -> { return oAuthClient.getUserToken() .signOut( - context.getActivity().getFrom().getId(), connectionName, + context.getActivity().getFrom().getId(), + connectionName, context.getActivity().getChannelId() ); }).thenApply(signOutResult -> null); @@ -1002,7 +1082,7 @@ public CompletableFuture> getTokenStatus( throw new IllegalArgumentException("userId"); } - return createOAuthClient(context).thenCompose(oAuthClient -> { + return createOAuthClient(context, null).thenCompose(oAuthClient -> { return oAuthClient.getUserToken() .getTokenStatus(userId, context.getActivity().getChannelId(), includeFilter); }); @@ -1038,13 +1118,12 @@ public CompletableFuture> getAadTokens( throw new IllegalArgumentException("resourceUrls"); } - return createOAuthClient(context).thenCompose(oAuthClient -> { + return createOAuthClient(context, null).thenCompose(oAuthClient -> { String effectiveUserId = userId; if ( StringUtils.isEmpty(effectiveUserId) && context.getActivity() != null && context.getActivity().getFrom() != null ) { - effectiveUserId = context.getActivity().getFrom().getId(); } @@ -1190,7 +1269,11 @@ public CompletableFuture createConversation( } return createConversation( - channelId, serviceUrl, credentials, conversationParameters, callback + channelId, + serviceUrl, + credentials, + conversationParameters, + callback ); } @@ -1202,10 +1285,15 @@ public CompletableFuture createConversation( * a mock OAuthClient. *

* - * @param turnContext The context object for the current turn. + * @param turnContext The context object for the current turn. + * @param oAuthAppCredentials The credentials to use when creating the client. + * If null, the default credentials will be used. * @return An OAuth client for the bot. */ - protected CompletableFuture createOAuthClient(TurnContext turnContext) { + protected CompletableFuture createOAuthClient( + TurnContext turnContext, + AppCredentials oAuthAppCredentials + ) { if ( !OAuthClientConfig.emulateOAuthCards && StringUtils @@ -1216,34 +1304,23 @@ protected CompletableFuture createOAuthClient(TurnContext turnConte OAuthClientConfig.emulateOAuthCards = true; } - ConnectorClient connectorClient = turnContext.getTurnState().get(CONNECTOR_CLIENT_KEY); - if (connectorClient == null) { - CompletableFuture result = new CompletableFuture<>(); - result.completeExceptionally( - new RuntimeException( - "An ConnectorClient is required in TurnState for this operation." - ) - ); - return result; - } + String appId = getBotAppId(turnContext); + String oAuthScope = getBotFrameworkOAuthScope(); + AppCredentials credentials = oAuthAppCredentials != null + ? oAuthAppCredentials + : getAppCredentials(appId, oAuthScope).join(); if (OAuthClientConfig.emulateOAuthCards) { // do not join task - we want this to run in the background. - OAuthClient oAuthClient = new RestOAuthClient( - turnContext.getActivity().getServiceUrl(), - connectorClient.getRestClient().credentials() - ); + OAuthClient oAuthClient = + new RestOAuthClient(turnContext.getActivity().getServiceUrl(), credentials); return OAuthClientConfig .sendEmulateOAuthCards(oAuthClient, OAuthClientConfig.emulateOAuthCards) .thenApply(result -> oAuthClient); } - return CompletableFuture.completedFuture( - new RestOAuthClient( - OAuthClientConfig.OAUTHENDPOINT, - connectorClient.getRestClient().credentials() - ) - ); + return CompletableFuture + .completedFuture(new RestOAuthClient(OAuthClientConfig.OAUTHENDPOINT, credentials)); } /** @@ -1256,9 +1333,11 @@ protected CompletableFuture createOAuthClient(TurnContext turnConte * Anonymous ClaimsIdentity if * authentication is turned off. */ + @SuppressWarnings(value = "PMD") private CompletableFuture createConnectorClient( String serviceUrl, - ClaimsIdentity claimsIdentity + ClaimsIdentity claimsIdentity, + String audience ) { if (claimsIdentity == null) { throw new UnsupportedOperationException( @@ -1277,32 +1356,19 @@ private CompletableFuture createConnectorClient( // For Activities coming from Emulator AppId claim contains the Bot's AAD AppId. // For anonymous requests (requests with no header) appId is not set in claims. - Map.Entry botAppIdClaim = claimsIdentity.claims() - .entrySet() - .stream() - .filter( - claim -> StringUtils.equals(claim.getKey(), AuthenticationConstants.AUDIENCE_CLAIM) - ) - .findFirst() - .orElse(null); + String botAppIdClaim = claimsIdentity.claims().get(AuthenticationConstants.AUDIENCE_CLAIM); if (botAppIdClaim == null) { - botAppIdClaim = claimsIdentity.claims() - .entrySet() - .stream() - .filter( - claim -> StringUtils.equals(claim.getKey(), AuthenticationConstants.APPID_CLAIM) - ) - .findFirst() - .orElse(null); + botAppIdClaim = claimsIdentity.claims().get(AuthenticationConstants.APPID_CLAIM); } - if (botAppIdClaim == null) { - return getOrCreateConnectorClient(serviceUrl); + if (botAppIdClaim != null) { + String scope = getBotFrameworkOAuthScope(); + + return getAppCredentials(botAppIdClaim, scope) + .thenCompose(credentials -> getOrCreateConnectorClient(serviceUrl, credentials)); } - String botId = botAppIdClaim.getValue(); - return getAppCredentials(botId) - .thenCompose(credentials -> getOrCreateConnectorClient(serviceUrl, credentials)); + return getOrCreateConnectorClient(serviceUrl); } private CompletableFuture getOrCreateConnectorClient(String serviceUrl) { @@ -1327,7 +1393,11 @@ protected CompletableFuture getOrCreateConnectorClient( ) { CompletableFuture result = new CompletableFuture<>(); - String clientKey = serviceUrl + (appCredentials != null ? appCredentials.getAppId() : ""); + String clientKey = keyForConnectorClient( + serviceUrl, + usingAppCredentials != null ? usingAppCredentials.getAppId() : null, + usingAppCredentials != null ? usingAppCredentials.oAuthScope() : null + ); result.complete(connectorClients.computeIfAbsent(clientKey, key -> { try { @@ -1375,30 +1445,76 @@ protected CompletableFuture getOrCreateConnectorClient( * @param appId The application identifier (AAD Id for the bot). * @return App credentials. */ - private CompletableFuture getAppCredentials(String appId) { + private CompletableFuture getAppCredentials(String appId, String scope) { if (appId == null) { return CompletableFuture.completedFuture(MicrosoftAppCredentials.empty()); } - if (appCredentialMap.containsKey(appId)) { - return CompletableFuture.completedFuture(appCredentialMap.get(appId)); + String cacheKey = keyForAppCredentials(appId, scope); + if (appCredentialMap.containsKey(cacheKey)) { + return CompletableFuture.completedFuture(appCredentialMap.get(cacheKey)); } // If app credentials were provided, use them as they are the preferred choice // moving forward if (appCredentials != null) { - appCredentialMap.put(appId, appCredentials); + appCredentialMap.put(cacheKey, appCredentials); } return credentialProvider.getAppPassword(appId).thenApply(appPassword -> { AppCredentials credentials = channelProvider != null && channelProvider.isGovernment() ? new MicrosoftGovernmentAppCredentials(appId, appPassword) : new MicrosoftAppCredentials(appId, appPassword); - appCredentialMap.put(appId, credentials); + appCredentialMap.put(cacheKey, credentials); return credentials; }); } + private String getBotAppId(TurnContext turnContext) { + ClaimsIdentity botIdentity = turnContext.getTurnState().get(BOT_IDENTITY_KEY); + if (botIdentity == null) { + throw new IllegalStateException( + "An IIdentity is required in TurnState for this operation." + ); + } + + String appId = botIdentity.claims().get(AuthenticationConstants.AUDIENCE_CLAIM); + if (StringUtils.isEmpty(appId)) { + throw new IllegalStateException("Unable to get the bot AppId from the audience claim."); + } + + return appId; + } + + private String getBotFrameworkOAuthScope() { + return channelProvider != null && channelProvider.isGovernment() + ? GovernmentAuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + : AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE; + } + + /** + * Generates the key for accessing the app credentials cache. + * + * @param appId The appId + * @param scope The scope. + * @return The cache key + */ + protected static String keyForAppCredentials(String appId, String scope) { + return appId + (StringUtils.isEmpty(scope) ? "" : scope); + } + + /** + * Generates the key for accessing the connector client cache. + * + * @param serviceUrl The service url + * @param appId The app did + * @param scope The scope + * @return The cache key. + */ + protected static String keyForConnectorClient(String serviceUrl, String appId, String scope) { + return serviceUrl + (appId == null ? "" : appId) + (scope == null ? "" : scope); + } + /** * Middleware to assign tenantId from channelData to Conversation.TenantId. * @@ -1435,4 +1551,20 @@ public CompletableFuture onTurn(TurnContext turnContext, NextDelegate next return next.next(); } } + + /** + * Get the AppCredentials cache. For unit testing. + * @return The AppCredentials cache. + */ + protected Map getCredentialsCache() { + return Collections.unmodifiableMap(appCredentialMap); + } + + /** + * Get the ConnectorClient cache. For unit testing. + * @return The ConnectorClient cache. + */ + protected Map getConnectorClientCache() { + return Collections.unmodifiableMap(connectorClients); + } } diff --git a/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/BotFrameworkAdapterTests.java b/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/BotFrameworkAdapterTests.java index 1de3e55e0..af3ec53d8 100644 --- a/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/BotFrameworkAdapterTests.java +++ b/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/BotFrameworkAdapterTests.java @@ -8,15 +8,21 @@ import com.microsoft.bot.connector.ConnectorClient; import com.microsoft.bot.connector.Conversations; import com.microsoft.bot.connector.authentication.AppCredentials; +import com.microsoft.bot.connector.authentication.AuthenticationConstants; import com.microsoft.bot.connector.authentication.ClaimsIdentity; import com.microsoft.bot.connector.authentication.CredentialProvider; +import com.microsoft.bot.connector.authentication.GovernmentAuthenticationConstants; import com.microsoft.bot.connector.authentication.MicrosoftAppCredentials; +import com.microsoft.bot.connector.authentication.SimpleChannelProvider; import com.microsoft.bot.connector.authentication.SimpleCredentialProvider; import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.CallerIdConstants; import com.microsoft.bot.schema.ConversationAccount; import com.microsoft.bot.schema.ConversationParameters; import com.microsoft.bot.schema.ConversationReference; import com.microsoft.bot.schema.ConversationResourceResponse; +import java.util.HashMap; +import java.util.Map; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; @@ -87,10 +93,8 @@ protected CompletableFuture getOrCreateConnectorClient( ObjectNode channelData = JsonNodeFactory.instance.objectNode(); channelData.set( "tenant", - JsonNodeFactory.instance.objectNode().set( - "id", - JsonNodeFactory.instance.textNode(TenantIdValue) - ) + JsonNodeFactory.instance.objectNode() + .set("id", JsonNodeFactory.instance.textNode(TenantIdValue)) ); Activity activity = new Activity("Test") { @@ -119,7 +123,7 @@ protected CompletableFuture getOrCreateConnectorClient( ConversationReference reference = activity.getConversationReference(); MicrosoftAppCredentials credentials = new MicrosoftAppCredentials(null, null); - Activity[] newActivity = new Activity[] { null }; + Activity[] newActivity = new Activity[] {null}; BotCallbackHandler updateParameters = (turnContext -> { newActivity[0] = turnContext.getActivity(); return CompletableFuture.completedFuture(null); @@ -159,7 +163,7 @@ private Activity processActivity( tenantId.put("id", channelDataTenantId); channelData.set("tenant", tenantId); - Activity[] activity = new Activity[] { null }; + Activity[] activity = new Activity[] {null}; sut.processActivity(mockClaims, new Activity("test") { { setChannelId(channelId); @@ -178,4 +182,183 @@ private Activity processActivity( return activity[0]; } + + @Test + public void OutgoingActivityIdNotSent() { + CredentialProvider mockCredentials = mock(CredentialProvider.class); + BotFrameworkAdapter adapter = new BotFrameworkAdapter(mockCredentials); + + Activity incoming_activity = new Activity("test") { + { + setId("testid"); + setChannelId(Channels.DIRECTLINE); + setServiceUrl("https://fake.service.url"); + setConversation(new ConversationAccount("cid")); + } + }; + + Activity reply = MessageFactory.text("test"); + reply.setId("TestReplyId"); + + MemoryConnectorClient mockConnector = new MemoryConnectorClient(); + TurnContext turnContext = new TurnContextImpl(adapter, incoming_activity); + turnContext.getTurnState().add(BotFrameworkAdapter.CONNECTOR_CLIENT_KEY, mockConnector); + turnContext.sendActivity(reply).join(); + + Assert.assertEquals( + 1, + ((MemoryConversations) mockConnector.getConversations()).getSentActivities().size() + ); + Assert.assertNull( + ((MemoryConversations) mockConnector.getConversations()).getSentActivities().get(0).getId() + ); + } + + @Test + public void processActivityCreatesCorrectCredsAndClient_anon() { + processActivityCreatesCorrectCredsAndClient( + null, + null, + null, + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + 0, + 1 + ); + } + + @Test + public void processActivityCreatesCorrectCredsAndClient_public() { + processActivityCreatesCorrectCredsAndClient( + "00000000-0000-0000-0000-000000000001", + CallerIdConstants.PUBLIC_AZURE_CHANNEL, + null, + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + 1, + 1 + ); + } + + @Test + public void processActivityCreatesCorrectCredsAndClient_gov() { + processActivityCreatesCorrectCredsAndClient( + "00000000-0000-0000-0000-000000000001", + CallerIdConstants.US_GOV_CHANNEL, + GovernmentAuthenticationConstants.CHANNELSERVICE, + GovernmentAuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + 1, + 1 + ); + } + + private void processActivityCreatesCorrectCredsAndClient( + String botAppId, + String expectedCallerId, + String channelService, + String expectedScope, + int expectedAppCredentialsCount, + int expectedClientCredentialsCount + ) { + HashMap claims = new HashMap() { + { + put(AuthenticationConstants.AUDIENCE_CLAIM, botAppId); + put(AuthenticationConstants.APPID_CLAIM, botAppId); + put(AuthenticationConstants.VERSION_CLAIM, "1.0"); + } + }; + ClaimsIdentity identity = new ClaimsIdentity("anonymous", claims); + + CredentialProvider credentialProvider = new SimpleCredentialProvider() { + { + setAppId(botAppId); + } + }; + String serviceUrl = "https://smba.trafficmanager.net/amer/"; + + BotFrameworkAdapter sut = new BotFrameworkAdapter( + credentialProvider, + new SimpleChannelProvider(channelService), + null, + null + ); + + BotCallbackHandler callback = turnContext -> { + getAppCredentialsAndAssertValues( + turnContext, + botAppId, + expectedScope, + expectedAppCredentialsCount + ); + + getConnectorClientAndAssertValues( + turnContext, + botAppId, + expectedScope, + serviceUrl, + expectedClientCredentialsCount + ); + + Assert.assertEquals(expectedCallerId, turnContext.getActivity().getCallerId()); + + return CompletableFuture.completedFuture(null); + }; + + sut.processActivity(identity, new Activity("test") { + { + setChannelId(Channels.EMULATOR); + setServiceUrl(serviceUrl); + } + }, callback).join(); + } + + private static void getAppCredentialsAndAssertValues( + TurnContext turnContext, + String expectedAppId, + String expectedScope, + int credsCount + ) { + if (credsCount > 0) { + Map credsCache = + ((BotFrameworkAdapter) turnContext.getAdapter()).getCredentialsCache(); + AppCredentials creds = credsCache + .get(BotFrameworkAdapter.keyForAppCredentials(expectedAppId, expectedScope)); + + Assert + .assertEquals("Unexpected credentials cache count", credsCount, credsCache.size()); + + Assert.assertNotNull("Credentials not found", creds); + Assert.assertEquals("Unexpected app id", expectedAppId, creds.getAppId()); + Assert.assertEquals("Unexpected scope", expectedScope, creds.oAuthScope()); + } + } + + private static void getConnectorClientAndAssertValues( + TurnContext turnContext, + String expectedAppId, + String expectedScope, + String expectedUrl, + int clientCount + ) { + Map clientCache = + ((BotFrameworkAdapter) turnContext.getAdapter()).getConnectorClientCache(); + + String cacheKey = expectedAppId == null + ? BotFrameworkAdapter.keyForConnectorClient(expectedUrl, null, null) + : BotFrameworkAdapter.keyForConnectorClient(expectedUrl, expectedAppId, expectedScope); + ConnectorClient client = clientCache.get(cacheKey); + + Assert.assertNotNull("ConnectorClient not in cache", client); + Assert.assertEquals("Unexpected credentials cache count", clientCount, clientCache.size()); + AppCredentials creds = (AppCredentials) client.credentials(); + Assert.assertEquals( + "Unexpected app id", + expectedAppId, + creds == null ? null : creds.getAppId() + ); + Assert.assertEquals( + "Unexpected scope", + expectedScope, + creds == null ? null : creds.oAuthScope() + ); + Assert.assertEquals("Unexpected base url", expectedUrl, client.baseUrl()); + } } diff --git a/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/MemoryConnectorClient.java b/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/MemoryConnectorClient.java new file mode 100644 index 000000000..6e0320648 --- /dev/null +++ b/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/MemoryConnectorClient.java @@ -0,0 +1,76 @@ +package com.microsoft.bot.builder; + +import com.microsoft.bot.connector.Attachments; +import com.microsoft.bot.connector.ConnectorClient; +import com.microsoft.bot.connector.Conversations; +import com.microsoft.bot.rest.RestClient; +import com.microsoft.bot.rest.credentials.ServiceClientCredentials; + +public class MemoryConnectorClient implements ConnectorClient { + private MemoryConversations conversations = new MemoryConversations(); + + @Override + public RestClient getRestClient() { + return null; + } + + @Override + public String baseUrl() { + return null; + } + + @Override + public ServiceClientCredentials credentials() { + return null; + } + + @Override + public String getUserAgent() { + return null; + } + + @Override + public String getAcceptLanguage() { + return null; + } + + @Override + public void setAcceptLanguage(String acceptLanguage) { + + } + + @Override + public int getLongRunningOperationRetryTimeout() { + return 0; + } + + @Override + public void setLongRunningOperationRetryTimeout(int timeout) { + + } + + @Override + public boolean getGenerateClientRequestId() { + return false; + } + + @Override + public void setGenerateClientRequestId(boolean generateClientRequestId) { + + } + + @Override + public Attachments getAttachments() { + return null; + } + + @Override + public Conversations getConversations() { + return conversations; + } + + @Override + public void close() throws Exception { + + } +} diff --git a/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/MemoryConversations.java b/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/MemoryConversations.java new file mode 100644 index 000000000..32da8e319 --- /dev/null +++ b/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/MemoryConversations.java @@ -0,0 +1,145 @@ +package com.microsoft.bot.builder; + +import com.microsoft.bot.connector.Conversations; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.AttachmentData; +import com.microsoft.bot.schema.ChannelAccount; +import com.microsoft.bot.schema.ConversationParameters; +import com.microsoft.bot.schema.ConversationResourceResponse; +import com.microsoft.bot.schema.ConversationsResult; +import com.microsoft.bot.schema.PagedMembersResult; +import com.microsoft.bot.schema.ResourceResponse; +import com.microsoft.bot.schema.Transcript; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.apache.commons.lang3.NotImplementedException; + +public class MemoryConversations implements Conversations { + private List sentActivities = new ArrayList<>(); + + public List getSentActivities() { + return sentActivities; + } + + @Override + public CompletableFuture getConversations() { + return notImplemented("getConversations"); + } + + @Override + public CompletableFuture getConversations(String continuationToken) { + return notImplemented("getConversations"); + } + + @Override + public CompletableFuture createConversation( + ConversationParameters parameters + ) { + return notImplemented("createConversation"); + } + + @Override + public CompletableFuture sendToConversation( + String conversationId, + Activity activity + ) { + sentActivities.add(activity); + return CompletableFuture.completedFuture(new ResourceResponse() { + { + setId(activity.getId()); + } + }); + } + + @Override + public CompletableFuture updateActivity( + String conversationId, String activityId, + Activity activity + ) { + return notImplemented("updateActivity"); + } + + @Override + public CompletableFuture replyToActivity( + String conversationId, String activityId, + Activity activity + ) { + sentActivities.add(activity); + return CompletableFuture.completedFuture(new ResourceResponse() { + { + setId(activity.getId()); + } + }); + } + + @Override + public CompletableFuture deleteActivity(String conversationId, String activityId) { + return notImplemented("deleteActivity"); + } + + @Override + public CompletableFuture> getConversationMembers( + String conversationId + ) { + return notImplemented("getConversationMembers"); + } + + @Override + public CompletableFuture getConversationMember( + String userId, String conversationId + ) { + return notImplemented("getConversationMember"); + } + + @Override + public CompletableFuture deleteConversationMember( + String conversationId, String memberId + ) { + return notImplemented("deleteConversationMember"); + } + + @Override + public CompletableFuture> getActivityMembers( + String conversationId, String activityId + ) { + return notImplemented("getActivityMembers"); + } + + @Override + public CompletableFuture uploadAttachment( + String conversationId, AttachmentData attachmentUpload + ) { + return notImplemented("uploadAttachment"); + } + + @Override + public CompletableFuture sendConversationHistory( + String conversationId, Transcript history + ) { + return notImplemented("sendConversationHistory"); + } + + @Override + public CompletableFuture getConversationPagedMembers( + String conversationId + ) { + return notImplemented("getConversationPagedMembers"); + } + + @Override + public CompletableFuture getConversationPagedMembers( + String conversationId, + String continuationToken + ) { + return notImplemented("getConversationPagedMembers"); + } + + protected CompletableFuture notImplemented(String message) { + CompletableFuture result = new CompletableFuture<>(); + result.completeExceptionally( + new NotImplementedException(message) + ); + return result; + } +} diff --git a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/JwtTokenValidation.java b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/JwtTokenValidation.java index 59f4c1a89..3b6e5303e 100644 --- a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/JwtTokenValidation.java +++ b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/JwtTokenValidation.java @@ -4,6 +4,7 @@ package com.microsoft.bot.connector.authentication; import com.microsoft.bot.schema.Activity; +import java.util.Map; import org.apache.commons.lang3.StringUtils; import java.util.concurrent.CompletableFuture; @@ -170,4 +171,36 @@ public static CompletableFuture validateAuthHeader( ); } } + + /** + * Gets the AppId from claims. + * + *

+ * In v1 tokens the AppId is in the the AppIdClaim claim. In v2 tokens the AppId + * is in the AuthorizedParty claim. + *

+ * + * @param claims The map of claims. + * @return The value of the appId claim if found (null if it can't find a + * suitable claim). + */ + public static String getAppIdFromClaims(Map claims) { + if (claims == null) { + throw new IllegalArgumentException("claims"); + } + + String appId = null; + + String tokenVersion = claims.get(AuthenticationConstants.VERSION_CLAIM); + if (StringUtils.isEmpty(tokenVersion) || tokenVersion.equalsIgnoreCase("1.0")) { + // either no Version or a version of "1.0" means we should look for the claim in + // the "appid" claim. + appId = claims.get(AuthenticationConstants.APPID_CLAIM); + } else { + // "2.0" puts the AppId in the "azp" claim. + appId = claims.get(AuthenticationConstants.AUTHORIZED_PARTY); + } + + return appId; + } } diff --git a/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenExchangeState.java b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenExchangeState.java index fe781a265..3546a9db8 100644 --- a/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenExchangeState.java +++ b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenExchangeState.java @@ -26,9 +26,13 @@ public class TokenExchangeState { @JsonInclude(JsonInclude.Include.NON_EMPTY) private String botUrl; + @JsonProperty("relatesTo") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private ConversationReference relatesTo; + /** * The connection name that was used. - * + * * @return The connection name. */ public String getConnectionName() { @@ -37,7 +41,7 @@ public String getConnectionName() { /** * The connection name that was used. - * + * * @param withConnectionName The connection name. */ public void setConnectionName(String withConnectionName) { @@ -46,7 +50,7 @@ public void setConnectionName(String withConnectionName) { /** * A reference to the conversation. - * + * * @return The conversation reference. */ public ConversationReference getConversation() { @@ -55,7 +59,7 @@ public ConversationReference getConversation() { /** * A reference to the conversation. - * + * * @param withConversation The conversation reference. */ public void setConversation(ConversationReference withConversation) { @@ -64,7 +68,7 @@ public void setConversation(ConversationReference withConversation) { /** * The URL of the bot messaging endpoint. - * + * * @return The messaging endpoint. */ public String getBotUrl() { @@ -73,7 +77,7 @@ public String getBotUrl() { /** * The URL of the bot messaging endpoint. - * + * * @param withBotUrl The messaging endpoint. */ public void setBotUrl(String withBotUrl) { @@ -82,7 +86,7 @@ public void setBotUrl(String withBotUrl) { /** * The bot's registered application ID. - * + * * @return The app id. */ public String getMsAppId() { @@ -91,10 +95,28 @@ public String getMsAppId() { /** * The bot's registered application ID. - * + * * @param withMsAppId The app id. */ public void setMsAppId(String withMsAppId) { this.msAppId = withMsAppId; } + + /** + * Gets the reference to a related parent conversation for this token exchange. + * + * @return A reference to a related parent conversation. + */ + public ConversationReference getRelatesTo() { + return relatesTo; + } + + /** + * Sets the reference to a related parent conversation for this token exchange. + * + * @param withRelatesTo A reference to a related parent conversation. + */ + public void setRelatesTo(ConversationReference withRelatesTo) { + relatesTo = withRelatesTo; + } }