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 a587516c5..54ba59213 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 @@ -41,6 +41,8 @@ import com.microsoft.bot.schema.ExpectedReplies; import com.microsoft.bot.schema.ResourceResponse; import com.microsoft.bot.schema.Serialization; +import com.microsoft.bot.schema.SignInResource; +import com.microsoft.bot.schema.TokenExchangeRequest; import com.microsoft.bot.schema.TokenExchangeState; import com.microsoft.bot.schema.TokenResponse; import com.microsoft.bot.schema.TokenStatus; @@ -78,8 +80,10 @@ *

* {@link TurnContext} {@link Activity} {@link Bot} {@link Middleware} */ -public class BotFrameworkAdapter extends BotAdapter - implements AdapterIntegration, UserTokenProvider { +public class BotFrameworkAdapter extends BotAdapter implements + AdapterIntegration, + UserTokenProvider, + ConnectorClientBuilder { /** * Key to store InvokeResponse. */ @@ -151,19 +155,10 @@ public BotFrameworkAdapter(CredentialProvider withCredentialProvider) { * @param withRetryStrategy Retry policy for retrying HTTP operations. * @param withMiddleware The middleware to initially add to the adapter. */ - public BotFrameworkAdapter( - CredentialProvider withCredentialProvider, - ChannelProvider withChannelProvider, - RetryStrategy withRetryStrategy, - Middleware withMiddleware - ) { - this( - withCredentialProvider, - new AuthenticationConfiguration(), - withChannelProvider, - withRetryStrategy, - withMiddleware - ); + public BotFrameworkAdapter(CredentialProvider withCredentialProvider, ChannelProvider withChannelProvider, + RetryStrategy withRetryStrategy, Middleware withMiddleware) { + this(withCredentialProvider, new AuthenticationConfiguration(), withChannelProvider, withRetryStrategy, + withMiddleware); } /** @@ -176,13 +171,8 @@ public BotFrameworkAdapter( * @param withRetryStrategy Retry policy for retrying HTTP operations. * @param withMiddleware The middleware to initially add to the adapter. */ - public BotFrameworkAdapter( - CredentialProvider withCredentialProvider, - AuthenticationConfiguration withAuthConfig, - ChannelProvider withChannelProvider, - RetryStrategy withRetryStrategy, - Middleware withMiddleware - ) { + public BotFrameworkAdapter(CredentialProvider withCredentialProvider, AuthenticationConfiguration withAuthConfig, + ChannelProvider withChannelProvider, RetryStrategy withRetryStrategy, Middleware withMiddleware) { if (withCredentialProvider == null) { throw new IllegalArgumentException("CredentialProvider cannot be null"); } @@ -220,13 +210,8 @@ public BotFrameworkAdapter( * @param withRetryStrategy Retry policy for retrying HTTP operations. * @param withMiddleware The middleware to initially add to the adapter. */ - public BotFrameworkAdapter( - AppCredentials withCredentials, - AuthenticationConfiguration withAuthConfig, - ChannelProvider withChannelProvider, - RetryStrategy withRetryStrategy, - Middleware withMiddleware - ) { + public BotFrameworkAdapter(AppCredentials withCredentials, AuthenticationConfiguration withAuthConfig, + ChannelProvider withChannelProvider, RetryStrategy withRetryStrategy, Middleware withMiddleware) { if (withCredentials == null) { throw new IllegalArgumentException("credentials"); } @@ -281,11 +266,8 @@ public BotFrameworkAdapter( * @throws IllegalArgumentException botAppId, reference, or callback is null. */ @Override - public CompletableFuture continueConversation( - String botAppId, - ConversationReference reference, - BotCallbackHandler callback - ) { + public CompletableFuture continueConversation(String botAppId, ConversationReference reference, + BotCallbackHandler callback) { if (reference == null) { return Async.completeExceptionally(new IllegalArgumentException("reference")); } @@ -322,14 +304,9 @@ public CompletableFuture continueConversation( * @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 - ); + public CompletableFuture continueConversation(ClaimsIdentity claimsIdentity, ConversationReference reference, + BotCallbackHandler callback) { + return continueConversation(claimsIdentity, reference, getBotFrameworkOAuthScope(), callback); } /** @@ -348,12 +325,8 @@ claimsIdentity, reference, getBotFrameworkOAuthScope(), callback * @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 - ) { + public CompletableFuture continueConversation(ClaimsIdentity claimsIdentity, ConversationReference reference, + String audience, BotCallbackHandler callback) { if (claimsIdentity == null) { return Async.completeExceptionally(new IllegalArgumentException("claimsIdentity")); } @@ -367,34 +340,30 @@ public CompletableFuture continueConversation( } if (StringUtils.isEmpty(audience)) { - return Async.completeExceptionally(new IllegalArgumentException( - "audience cannot be null or empty" - )); + return Async.completeExceptionally(new IllegalArgumentException("audience cannot be null or empty")); } CompletableFuture pipelineResult = new CompletableFuture<>(); - try (TurnContextImpl context = - new TurnContextImpl(this, reference.getContinuationActivity())) { + try (TurnContextImpl context = new TurnContextImpl(this, reference.getContinuationActivity())) { context.getTurnState().add(BOT_IDENTITY_KEY, claimsIdentity); context.getTurnState().add(OAUTH_SCOPE_KEY, audience); String appIdFromClaims = JwtTokenValidation.getAppIdFromClaims(claimsIdentity.claims()); - return credentialProvider.isValidAppId(appIdFromClaims) - .thenCompose(isValidAppId -> { - // If we receive a valid app id in the incoming token claims, add the - // channel service URL to the trusted services list so we can send messages back. - if (!StringUtils.isEmpty(appIdFromClaims) && isValidAppId) { - AppCredentials.trustServiceUrl(reference.getServiceUrl()); - } + return credentialProvider.isValidAppId(appIdFromClaims).thenCompose(isValidAppId -> { + // If we receive a valid app id in the incoming token claims, add the + // channel service URL to the trusted services list so we can send messages + // back. + if (!StringUtils.isEmpty(appIdFromClaims) && isValidAppId) { + AppCredentials.trustServiceUrl(reference.getServiceUrl()); + } - return createConnectorClient( - reference.getServiceUrl(), claimsIdentity, audience - ).thenCompose(connectorClient -> { - context.getTurnState().add(CONNECTOR_CLIENT_KEY, connectorClient); - return runPipeline(context, callback); - }); - }); + return createConnectorClient(reference.getServiceUrl(), claimsIdentity, audience) + .thenCompose(connectorClient -> { + context.getTurnState().add(CONNECTOR_CLIENT_KEY, connectorClient); + return runPipeline(context, callback); + }); + }); } catch (Exception e) { pipelineResult.completeExceptionally(e); } @@ -430,18 +399,15 @@ public BotFrameworkAdapter use(Middleware middleware) { * returned. * @throws IllegalArgumentException Activity is null. */ - public CompletableFuture processActivity( - String authHeader, - Activity activity, - BotCallbackHandler callback - ) { + public CompletableFuture processActivity(String authHeader, Activity activity, + BotCallbackHandler callback) { if (activity == null) { return Async.completeExceptionally(new IllegalArgumentException("Activity")); } - return JwtTokenValidation.authenticateRequest( - activity, authHeader, credentialProvider, channelProvider, authConfiguration - ).thenCompose(claimsIdentity -> processActivity(claimsIdentity, activity, callback)); + return JwtTokenValidation + .authenticateRequest(activity, authHeader, credentialProvider, channelProvider, authConfiguration) + .thenCompose(claimsIdentity -> processActivity(claimsIdentity, activity, callback)); } /** @@ -458,11 +424,8 @@ public CompletableFuture processActivity( * returned. * @throws IllegalArgumentException Activity is null. */ - public CompletableFuture processActivity( - ClaimsIdentity identity, - Activity activity, - BotCallbackHandler callback - ) { + public CompletableFuture processActivity(ClaimsIdentity identity, Activity activity, + BotCallbackHandler callback) { if (activity == null) { return Async.completeExceptionally(new IllegalArgumentException("Activity")); } @@ -522,30 +485,27 @@ public CompletableFuture processActivity( return pipelineResult; } - @SuppressWarnings({"PMD"}) + @SuppressWarnings("PMD") private CompletableFuture generateCallerId(ClaimsIdentity claimsIdentity) { - return credentialProvider.isAuthenticationDisabled() - .thenApply( - is_auth_disabled -> { - // Is the bot accepting all incoming messages? - if (is_auth_disabled) { - return null; - } + return credentialProvider.isAuthenticationDisabled().thenApply(is_auth_disabled -> { + // Is the bot accepting all incoming messages? + if (is_auth_disabled) { + return null; + } - // Is the activity from Public Azure? - if (channelProvider == null || channelProvider.isPublicAzure()) { - return CallerIdConstants.PUBLIC_AZURE_CHANNEL; - } + // Is the activity from Public Azure? + if (channelProvider == null || channelProvider.isPublicAzure()) { + return CallerIdConstants.PUBLIC_AZURE_CHANNEL; + } - // Is the activity from Azure Gov? - if (channelProvider != null && channelProvider.isGovernment()) { - return CallerIdConstants.US_GOV_CHANNEL; - } + // Is the activity from Azure Gov? + if (channelProvider != null && channelProvider.isGovernment()) { + return CallerIdConstants.US_GOV_CHANNEL; + } - // Return null so that the callerId is cleared. - return null; - } - ); + // Return null so that the callerId is cleared. + return null; + }); } /** @@ -562,10 +522,7 @@ private CompletableFuture generateCallerId(ClaimsIdentity claimsIdentity */ @SuppressWarnings("checkstyle:EmptyBlock, checkstyle:linelength") @Override - public CompletableFuture sendActivities( - TurnContext context, - List activities - ) { + public CompletableFuture sendActivities(TurnContext context, List activities) { if (context == null) { return Async.completeExceptionally(new IllegalArgumentException("context")); } @@ -575,9 +532,8 @@ public CompletableFuture sendActivities( } if (activities.size() == 0) { - return Async.completeExceptionally(new IllegalArgumentException( - "Expecting one or more activities, but the array was empty." - )); + return Async.completeExceptionally( + new IllegalArgumentException("Expecting one or more activities, but the array was empty.")); } return CompletableFuture.supplyAsync(() -> { @@ -611,21 +567,16 @@ public CompletableFuture sendActivities( context.getTurnState().add(INVOKE_RESPONSE_KEY, activity); // No need to create a response. One will be created below. response = null; - } else if ( - activity.isType(ActivityTypes.TRACE) - && !StringUtils.equals(activity.getChannelId(), Channels.EMULATOR) - ) { + } else if (activity.isType(ActivityTypes.TRACE) + && !StringUtils.equals(activity.getChannelId(), Channels.EMULATOR)) { // if it is a Trace activity we only send to the channel if it's the emulator. response = null; } else if (!StringUtils.isEmpty(activity.getReplyToId())) { - ConnectorClient connectorClient = - context.getTurnState().get(CONNECTOR_CLIENT_KEY); + ConnectorClient connectorClient = context.getTurnState().get(CONNECTOR_CLIENT_KEY); response = connectorClient.getConversations().replyToActivity(activity).join(); } else { - ConnectorClient connectorClient = - context.getTurnState().get(CONNECTOR_CLIENT_KEY); - response = - connectorClient.getConversations().sendToConversation(activity).join(); + ConnectorClient connectorClient = context.getTurnState().get(CONNECTOR_CLIENT_KEY); + response = connectorClient.getConversations().sendToConversation(activity).join(); } // If No response is set, then default to a "simple" response. This can't really @@ -640,8 +591,7 @@ public CompletableFuture sendActivities( // https://github.com/Microsoft/botbuilder-dotnet/issues/460 // bug report : https://github.com/Microsoft/botbuilder-dotnet/issues/465 if (response == null) { - response = - new ResourceResponse((activity.getId() == null) ? "" : activity.getId()); + response = new ResourceResponse((activity.getId() == null) ? "" : activity.getId()); } responses[index] = response; @@ -667,10 +617,7 @@ public CompletableFuture sendActivities( * {@link TurnContext#onUpdateActivity(UpdateActivityHandler)} */ @Override - public CompletableFuture updateActivity( - TurnContext context, - Activity activity - ) { + public CompletableFuture updateActivity(TurnContext context, Activity activity) { ConnectorClient connectorClient = context.getTurnState().get(CONNECTOR_CLIENT_KEY); return connectorClient.getConversations().updateActivity(activity); } @@ -684,13 +631,10 @@ public CompletableFuture updateActivity( * {@link TurnContext#onDeleteActivity(DeleteActivityHandler)} */ @Override - public CompletableFuture deleteActivity( - TurnContext context, - ConversationReference reference - ) { + public CompletableFuture deleteActivity(TurnContext context, ConversationReference reference) { ConnectorClient connectorClient = context.getTurnState().get(CONNECTOR_CLIENT_KEY); - return connectorClient.getConversations() - .deleteActivity(reference.getConversation().getId(), reference.getActivityId()); + return connectorClient.getConversations().deleteActivity(reference.getConversation().getId(), + reference.getActivityId()); } /** @@ -700,26 +644,20 @@ public CompletableFuture deleteActivity( * @param memberId ID of the member to delete from the conversation * @return A task that represents the work queued to execute. */ - public CompletableFuture deleteConversationMember( - TurnContextImpl context, - String memberId - ) { + public CompletableFuture deleteConversationMember(TurnContextImpl context, String memberId) { if (context.getActivity().getConversation() == null) { return Async.completeExceptionally(new IllegalArgumentException( - "BotFrameworkAdapter.deleteConversationMember(): missing conversation" - )); + "BotFrameworkAdapter.deleteConversationMember(): missing conversation")); } if (StringUtils.isEmpty(context.getActivity().getConversation().getId())) { return Async.completeExceptionally(new IllegalArgumentException( - "BotFrameworkAdapter.deleteConversationMember(): missing conversation.id" - )); + "BotFrameworkAdapter.deleteConversationMember(): missing conversation.id")); } ConnectorClient connectorClient = context.getTurnState().get(CONNECTOR_CLIENT_KEY); String conversationId = context.getActivity().getConversation().getId(); - return connectorClient.getConversations() - .deleteConversationMember(conversationId, memberId); + return connectorClient.getConversations().deleteConversationMember(conversationId, memberId); } /** @@ -740,25 +678,20 @@ public CompletableFuture> getActivityMembers(TurnContextImp * current activities ID will be used. * @return List of Members of the activity */ - public CompletableFuture> getActivityMembers( - TurnContextImpl context, - String activityId - ) { + public CompletableFuture> getActivityMembers(TurnContextImpl context, String activityId) { // If no activity was passed in, use the current activity. if (activityId == null) { activityId = context.getActivity().getId(); } if (context.getActivity().getConversation() == null) { - return Async.completeExceptionally(new IllegalArgumentException( - "BotFrameworkAdapter.GetActivityMembers(): missing conversation" - )); + return Async.completeExceptionally( + new IllegalArgumentException("BotFrameworkAdapter.GetActivityMembers(): missing conversation")); } if (StringUtils.isEmpty(context.getActivity().getConversation().getId())) { - return Async.completeExceptionally(new IllegalArgumentException( - "BotFrameworkAdapter.GetActivityMembers(): missing conversation.id" - )); + return Async.completeExceptionally( + new IllegalArgumentException("BotFrameworkAdapter.GetActivityMembers(): missing conversation.id")); } ConnectorClient connectorClient = context.getTurnState().get(CONNECTOR_CLIENT_KEY); @@ -775,15 +708,13 @@ public CompletableFuture> getActivityMembers( */ public CompletableFuture> getConversationMembers(TurnContextImpl context) { if (context.getActivity().getConversation() == null) { - return Async.completeExceptionally(new IllegalArgumentException( - "BotFrameworkAdapter.GetActivityMembers(): missing conversation" - )); + return Async.completeExceptionally( + new IllegalArgumentException("BotFrameworkAdapter.GetActivityMembers(): missing conversation")); } if (StringUtils.isEmpty(context.getActivity().getConversation().getId())) { - return Async.completeExceptionally(new IllegalArgumentException( - "BotFrameworkAdapter.GetActivityMembers(): missing conversation.id" - )); + return Async.completeExceptionally( + new IllegalArgumentException("BotFrameworkAdapter.GetActivityMembers(): missing conversation.id")); } ConnectorClient connectorClient = context.getTurnState().get(CONNECTOR_CLIENT_KEY); @@ -808,10 +739,8 @@ public CompletableFuture> getConversationMembers(TurnContex * conversation, as only the Bot's ServiceUrl and credentials are * required. */ - public CompletableFuture getConversations( - String serviceUrl, - MicrosoftAppCredentials credentials - ) { + public CompletableFuture getConversations(String serviceUrl, + MicrosoftAppCredentials credentials) { return getConversations(serviceUrl, credentials, null); } @@ -832,11 +761,8 @@ public CompletableFuture getConversations( * results. * @return List of Members of the current conversation */ - public CompletableFuture getConversations( - String serviceUrl, - MicrosoftAppCredentials credentials, - String continuationToken - ) { + public CompletableFuture getConversations(String serviceUrl, + MicrosoftAppCredentials credentials, String continuationToken) { if (StringUtils.isEmpty(serviceUrl)) { return Async.completeExceptionally(new IllegalArgumentException("serviceUrl")); } @@ -846,10 +772,7 @@ public CompletableFuture getConversations( } return getOrCreateConnectorClient(serviceUrl, credentials) - .thenCompose( - connectorClient -> connectorClient.getConversations() - .getConversations(continuationToken) - ); + .thenCompose(connectorClient -> connectorClient.getConversations().getConversations(continuationToken)); } /** @@ -885,10 +808,7 @@ public CompletableFuture getConversations(TurnContextImpl c * results. * @return List of Members of the current conversation */ - public CompletableFuture getConversations( - TurnContextImpl context, - String continuationToken - ) { + public CompletableFuture getConversations(TurnContextImpl context, String continuationToken) { ConnectorClient connectorClient = context.getTurnState().get(CONNECTOR_CLIENT_KEY); return connectorClient.getConversations().getConversations(continuationToken); } @@ -903,32 +823,22 @@ public CompletableFuture getConversations( * @return Token Response */ @Override - public CompletableFuture getUserToken( - TurnContext context, - String connectionName, - String magicCode - ) { + public CompletableFuture getUserToken(TurnContext context, String connectionName, String magicCode) { if (context == null) { return Async.completeExceptionally(new IllegalArgumentException("TurnContext")); } - if ( - context.getActivity().getFrom() == null - || StringUtils.isEmpty(context.getActivity().getFrom().getId()) - ) { - return Async.completeExceptionally(new IllegalArgumentException( - "BotFrameworkAdapter.getUserToken(): missing from or from.id" - )); + if (context.getActivity().getFrom() == null || StringUtils.isEmpty(context.getActivity().getFrom().getId())) { + return Async.completeExceptionally( + new IllegalArgumentException("BotFrameworkAdapter.getUserToken(): missing from or from.id")); } if (StringUtils.isEmpty(connectionName)) { return Async.completeExceptionally(new IllegalArgumentException("connectionName")); } - return createOAuthClient(context, null).thenCompose(oAuthClient -> oAuthClient.getUserToken() - .getToken( - context.getActivity().getFrom().getId(), connectionName, - context.getActivity().getChannelId(), magicCode - )); + return createOAuthAPIClient(context, null) + .thenCompose(oAuthClient -> oAuthClient.getUserToken().getToken(context.getActivity().getFrom().getId(), + connectionName, context.getActivity().getChannelId(), magicCode)); } /** @@ -941,50 +851,8 @@ public CompletableFuture getUserToken( * @return A task that represents the work queued to execute. */ @Override - public CompletableFuture getOauthSignInLink( - TurnContext context, - String connectionName - ) { - if (context == null) { - return Async.completeExceptionally(new IllegalArgumentException("TurnContext")); - } - - if (StringUtils.isEmpty(connectionName)) { - return Async.completeExceptionally(new IllegalArgumentException("connectionName")); - } - - 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(activity.getId()); - setBot(activity.getRecipient()); - setChannelId(activity.getChannelId()); - setConversation(activity.getConversation()); - setServiceUrl(activity.getServiceUrl()); - setUser(activity.getFrom()); - } - }); - setMsAppId(appId); - setRelatesTo(activity.getRelatesTo()); - } - }; - - String serializedState = Serialization.toString(tokenExchangeState); - String state = Base64.getEncoder() - .encodeToString(serializedState.getBytes(StandardCharsets.UTF_8)); - - return oAuthClient.getBotSignIn().getSignInUrl(state); - } catch (Throwable t) { - throw new CompletionException(t); - } - }); + public CompletableFuture getOAuthSignInLink(TurnContext context, String connectionName) { + return getOAuthSignInLink(context, null, connectionName); } /** @@ -999,54 +867,9 @@ public CompletableFuture getOauthSignInLink( * @return A task that represents the work queued to execute. */ @Override - public CompletableFuture getOauthSignInLink( - TurnContext context, - String connectionName, - String userId, - String finalRedirect - ) { - if (context == null) { - return Async.completeExceptionally(new IllegalArgumentException("TurnContext")); - } - if (StringUtils.isEmpty(connectionName)) { - return Async.completeExceptionally(new IllegalArgumentException("connectionName")); - } - if (StringUtils.isEmpty(userId)) { - return Async.completeExceptionally(new IllegalArgumentException("userId")); - } - - 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(activity.getId()); - setBot(activity.getRecipient()); - setChannelId(activity.getChannelId()); - setConversation(activity.getConversation()); - setServiceUrl(activity.getServiceUrl()); - setUser(activity.getFrom()); - } - }); - setRelatesTo(activity.getRelatesTo()); - setMsAppId(appId); - } - }; - - String serializedState = Serialization.toString(tokenExchangeState); - String state = Base64.getEncoder() - .encodeToString(serializedState.getBytes(StandardCharsets.UTF_8)); - - return oAuthClient.getBotSignIn().getSignInUrl(state); - } catch (Throwable t) { - throw new CompletionException(t); - } - }); + public CompletableFuture getOAuthSignInLink(TurnContext context, String connectionName, String userId, + String finalRedirect) { + return getOAuthSignInLink(context, null, connectionName, userId, finalRedirect); } /** @@ -1058,25 +881,8 @@ public CompletableFuture getOauthSignInLink( * @return A task that represents the work queued to execute. */ @Override - public CompletableFuture signOutUser( - TurnContext context, - String connectionName, - String userId - ) { - if (context == null) { - return Async.completeExceptionally(new IllegalArgumentException("TurnContext")); - } - if (StringUtils.isEmpty(connectionName)) { - return Async.completeExceptionally(new IllegalArgumentException("connectionName")); - } - - return createOAuthClient(context, null).thenCompose(oAuthClient -> { - return oAuthClient.getUserToken() - .signOut( - context.getActivity().getFrom().getId(), connectionName, - context.getActivity().getChannelId() - ); - }).thenApply(signOutResult -> null); + public CompletableFuture signOutUser(TurnContext context, String connectionName, String userId) { + return signOutUser(context, null, connectionName, userId); } /** @@ -1091,22 +897,9 @@ public CompletableFuture signOutUser( * @return Array of {@link TokenStatus}. */ @Override - public CompletableFuture> getTokenStatus( - TurnContext context, - String userId, - String includeFilter - ) { - if (context == null) { - return Async.completeExceptionally(new IllegalArgumentException("TurnContext")); - } - if (StringUtils.isEmpty(userId)) { - return Async.completeExceptionally(new IllegalArgumentException("userId")); - } - - return createOAuthClient(context, null).thenCompose(oAuthClient -> { - return oAuthClient.getUserToken() - .getTokenStatus(userId, context.getActivity().getChannelId(), includeFilter); - }); + public CompletableFuture> getTokenStatus(TurnContext context, String userId, + String includeFilter) { + return getTokenStatus(context, null, userId, includeFilter); } /** @@ -1124,34 +917,9 @@ public CompletableFuture> getTokenStatus( * @return Map of resourceUrl to the corresponding {@link TokenResponse}. */ @Override - public CompletableFuture> getAadTokens( - TurnContext context, - String connectionName, - String[] resourceUrls, - String userId - ) { - if (context == null) { - return Async.completeExceptionally(new IllegalArgumentException("TurnContext")); - } - if (StringUtils.isEmpty(connectionName)) { - return Async.completeExceptionally(new IllegalArgumentException("connectionName")); - } - if (resourceUrls == null) { - return Async.completeExceptionally(new IllegalArgumentException("resourceUrls")); - } - - 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(); - } - - return oAuthClient.getUserToken() - .getAadTokens(effectiveUserId, connectionName, new AadResourceUrls(resourceUrls)); - }); + public CompletableFuture> getAadTokens(TurnContext context, String connectionName, + String[] resourceUrls, String userId) { + return getAadTokens(context, null, connectionName, resourceUrls, userId); } /** @@ -1179,58 +947,50 @@ public CompletableFuture> getAadTokens( * @param callback The method to call for the resulting bot turn. * @return A task that represents the work queued to execute. */ - public CompletableFuture createConversation( - String channelId, - String serviceUrl, - MicrosoftAppCredentials credentials, - ConversationParameters conversationParameters, - BotCallbackHandler callback - ) { + public CompletableFuture createConversation(String channelId, String serviceUrl, + MicrosoftAppCredentials credentials, ConversationParameters conversationParameters, + BotCallbackHandler callback) { return getOrCreateConnectorClient(serviceUrl, credentials).thenCompose(connectorClient -> { Conversations conversations = connectorClient.getConversations(); return conversations.createConversation(conversationParameters) - .thenCompose(conversationResourceResponse -> { - // Create a event activity to represent the result. - Activity eventActivity = Activity.createEventActivity(); - eventActivity.setName("CreateConversation"); - eventActivity.setChannelId(channelId); - eventActivity.setServiceUrl(serviceUrl); - eventActivity.setId( - (conversationResourceResponse.getActivityId() != null) - ? conversationResourceResponse.getActivityId() - : UUID.randomUUID().toString() - ); - eventActivity.setConversation( - new ConversationAccount(conversationResourceResponse.getId()) { + .thenCompose(conversationResourceResponse -> { + // Create a event activity to represent the result. + Activity eventActivity = Activity.createEventActivity(); + eventActivity.setName("CreateConversation"); + eventActivity.setChannelId(channelId); + eventActivity.setServiceUrl(serviceUrl); + eventActivity.setId((conversationResourceResponse.getActivityId() != null) + ? conversationResourceResponse.getActivityId() + : UUID.randomUUID().toString()); + eventActivity.setConversation(new ConversationAccount(conversationResourceResponse.getId()) { { setTenantId(conversationParameters.getTenantId()); } + }); + eventActivity.setChannelData(conversationParameters.getChannelData()); + eventActivity.setRecipient(conversationParameters.getBot()); + + // run pipeline + CompletableFuture result = new CompletableFuture<>(); + try (TurnContextImpl context = new TurnContextImpl(this, eventActivity)) { + HashMap claims = new HashMap() { + { + put(AuthenticationConstants.AUDIENCE_CLAIM, credentials.getAppId()); + put(AuthenticationConstants.APPID_CLAIM, credentials.getAppId()); + put(AuthenticationConstants.SERVICE_URL_CLAIM, serviceUrl); + } + }; + ClaimsIdentity claimsIdentity = new ClaimsIdentity("anonymous", claims); + + context.getTurnState().add(BOT_IDENTITY_KEY, claimsIdentity); + context.getTurnState().add(CONNECTOR_CLIENT_KEY, connectorClient); + + result = runPipeline(context, callback); + } catch (Exception e) { + result.completeExceptionally(e); } - ); - eventActivity.setChannelData(conversationParameters.getChannelData()); - eventActivity.setRecipient(conversationParameters.getBot()); - - // run pipeline - CompletableFuture result = new CompletableFuture<>(); - try (TurnContextImpl context = new TurnContextImpl(this, eventActivity)) { - HashMap claims = new HashMap() { - { - put(AuthenticationConstants.AUDIENCE_CLAIM, credentials.getAppId()); - put(AuthenticationConstants.APPID_CLAIM, credentials.getAppId()); - put(AuthenticationConstants.SERVICE_URL_CLAIM, serviceUrl); - } - }; - ClaimsIdentity claimsIdentity = new ClaimsIdentity("anonymous", claims); - - context.getTurnState().add(BOT_IDENTITY_KEY, claimsIdentity); - context.getTurnState().add(CONNECTOR_CLIENT_KEY, connectorClient); - - result = runPipeline(context, callback); - } catch (Exception e) { - result.completeExceptionally(e); - } - return result; - }); + return result; + }); }); } @@ -1262,14 +1022,9 @@ public CompletableFuture createConversation( * @return A task that represents the work queued to execute. */ @SuppressWarnings("checkstyle:InnerAssignment") - public CompletableFuture createConversation( - String channelId, - String serviceUrl, - MicrosoftAppCredentials credentials, - ConversationParameters conversationParameters, - BotCallbackHandler callback, - ConversationReference reference - ) { + public CompletableFuture createConversation(String channelId, String serviceUrl, + MicrosoftAppCredentials credentials, ConversationParameters conversationParameters, + BotCallbackHandler callback, ConversationReference reference) { if (reference.getConversation() == null) { return CompletableFuture.completedFuture(null); } @@ -1279,20 +1034,15 @@ public CompletableFuture createConversation( // Putting tenantId in channelData is a temporary solution while we wait for the // Teams API to be updated ObjectNode channelData = JsonNodeFactory.instance.objectNode(); - channelData.set( - "tenant", - JsonNodeFactory.instance.objectNode() - .set("tenantId", JsonNodeFactory.instance.textNode(tenantId)) - ); + channelData.set("tenant", + JsonNodeFactory.instance.objectNode().set("tenantId", JsonNodeFactory.instance.textNode(tenantId))); conversationParameters.setChannelData(channelData); conversationParameters.setTenantId(tenantId); } - return createConversation( - channelId, serviceUrl, credentials, conversationParameters, callback - ); + return createConversation(channelId, serviceUrl, credentials, conversationParameters, callback); } /** @@ -1308,42 +1058,33 @@ public CompletableFuture createConversation( * If null, the default credentials will be used. * @return An OAuth client for the bot. */ - protected CompletableFuture createOAuthClient( - TurnContext turnContext, - AppCredentials oAuthAppCredentials - ) { - if ( - !OAuthClientConfig.emulateOAuthCards - && StringUtils - .equalsIgnoreCase(turnContext.getActivity().getChannelId(), Channels.EMULATOR) - && credentialProvider.isAuthenticationDisabled().join() - ) { + protected CompletableFuture createOAuthAPIClient(TurnContext turnContext, + AppCredentials oAuthAppCredentials) { + if (!OAuthClientConfig.emulateOAuthCards + && StringUtils.equalsIgnoreCase(turnContext.getActivity().getChannelId(), Channels.EMULATOR) + && credentialProvider.isAuthenticationDisabled().join()) { OAuthClientConfig.emulateOAuthCards = true; } String appId = getBotAppId(turnContext); String cacheKey = appId + (oAuthAppCredentials != null ? oAuthAppCredentials.getAppId() : ""); String oAuthScope = getBotFrameworkOAuthScope(); - AppCredentials credentials = oAuthAppCredentials != null - ? oAuthAppCredentials - : getAppCredentials(appId, oAuthScope).join(); + AppCredentials credentials = oAuthAppCredentials != null ? oAuthAppCredentials + : getAppCredentials(appId, oAuthScope).join(); OAuthClient client = oAuthClients.computeIfAbsent(cacheKey, key -> { - OAuthClient oAuthClient = new RestOAuthClient( - OAuthClientConfig.emulateOAuthCards - ? turnContext.getActivity().getServiceUrl() - : OAuthClientConfig.OAUTHENDPOINT, - credentials - ); - - if (OAuthClientConfig.emulateOAuthCards) { - // do not join task - we want this to run in the background. - OAuthClientConfig - .sendEmulateOAuthCards(oAuthClient, OAuthClientConfig.emulateOAuthCards); - } + OAuthClient oAuthClient = new RestOAuthClient( + OAuthClientConfig.emulateOAuthCards ? turnContext.getActivity().getServiceUrl() + : OAuthClientConfig.OAUTHENDPOINT, + credentials); + + if (OAuthClientConfig.emulateOAuthCards) { + // do not join task - we want this to run in the background. + OAuthClientConfig.sendEmulateOAuthCards(oAuthClient, OAuthClientConfig.emulateOAuthCards); + } - return oAuthClient; - }); + return oAuthClient; + }); // adding the oAuthClient into the TurnState // TokenResolver.cs will use it get the correct credentials to poll for @@ -1360,21 +1101,19 @@ protected CompletableFuture createOAuthClient( * * @param serviceUrl The service URL. * @param claimsIdentity The claims identity. + * @param audience The target audience for the connector. * @return ConnectorClient instance. * @throws UnsupportedOperationException ClaimsIdentity cannot be null. Pass * Anonymous ClaimsIdentity if * authentication is turned off. */ @SuppressWarnings(value = "PMD") - private CompletableFuture createConnectorClient( - String serviceUrl, - ClaimsIdentity claimsIdentity, - String audience - ) { + public CompletableFuture createConnectorClient(String serviceUrl, + ClaimsIdentity claimsIdentity, + String audience) { if (claimsIdentity == null) { return Async.completeExceptionally(new UnsupportedOperationException( - "ClaimsIdentity cannot be null. Pass Anonymous ClaimsIdentity if authentication is turned off." - )); + "ClaimsIdentity cannot be null. Pass Anonymous ClaimsIdentity if authentication is turned off.")); } // For requests from channel App Id is in Audience claim of JWT token. For @@ -1397,7 +1136,7 @@ private CompletableFuture createConnectorClient( String scope = getBotFrameworkOAuthScope(); return getAppCredentials(botAppIdClaim, scope) - .thenCompose(credentials -> getOrCreateConnectorClient(serviceUrl, credentials)); + .thenCompose(credentials -> getOrCreateConnectorClient(serviceUrl, credentials)); } return getOrCreateConnectorClient(serviceUrl); @@ -1419,34 +1158,25 @@ private CompletableFuture getOrCreateConnectorClient(String ser * @param usingAppCredentials (Optional) The AppCredentials to use. * @return A task that will return the ConnectorClient. */ - protected CompletableFuture getOrCreateConnectorClient( - String serviceUrl, - AppCredentials usingAppCredentials - ) { + protected CompletableFuture getOrCreateConnectorClient(String serviceUrl, + AppCredentials usingAppCredentials) { CompletableFuture result = new CompletableFuture<>(); - String clientKey = keyForConnectorClient( - serviceUrl, usingAppCredentials != null ? usingAppCredentials.getAppId() : null, - usingAppCredentials != null ? usingAppCredentials.oAuthScope() : null - ); + String clientKey = keyForConnectorClient(serviceUrl, + usingAppCredentials != null ? usingAppCredentials.getAppId() : null, + usingAppCredentials != null ? usingAppCredentials.oAuthScope() : null); result.complete(connectorClients.computeIfAbsent(clientKey, key -> { try { RestConnectorClient connectorClient; if (usingAppCredentials != null) { - connectorClient = new RestConnectorClient( - new URI(serviceUrl).toURL().toString(), - usingAppCredentials - ); + connectorClient = new RestConnectorClient(new URI(serviceUrl).toURL().toString(), + usingAppCredentials); } else { - AppCredentials emptyCredentials = - channelProvider != null && channelProvider.isGovernment() + AppCredentials emptyCredentials = channelProvider != null && channelProvider.isGovernment() ? MicrosoftGovernmentAppCredentials.empty() : MicrosoftAppCredentials.empty(); - connectorClient = new RestConnectorClient( - new URI(serviceUrl).toURL().toString(), - emptyCredentials - ); + connectorClient = new RestConnectorClient(new URI(serviceUrl).toURL().toString(), emptyCredentials); } if (connectorClientRetryStrategy != null) { @@ -1455,13 +1185,8 @@ protected CompletableFuture getOrCreateConnectorClient( return connectorClient; } catch (Throwable t) { - result - .completeExceptionally( - new IllegalArgumentException( - String.format("Invalid Service URL: %s", serviceUrl), - t - ) - ); + result.completeExceptionally( + new IllegalArgumentException(String.format("Invalid Service URL: %s", serviceUrl), t)); return null; } })); @@ -1504,9 +1229,7 @@ private CompletableFuture getAppCredentials(String appId, String private String getBotAppId(TurnContext turnContext) throws IllegalStateException { ClaimsIdentity botIdentity = turnContext.getTurnState().get(BOT_IDENTITY_KEY); if (botIdentity == null) { - throw new IllegalStateException( - "An IIdentity is required in TurnState for this operation." - ); + throw new IllegalStateException("An IIdentity is required in TurnState for this operation."); } String appId = botIdentity.claims().get(AuthenticationConstants.AUDIENCE_CLAIM); @@ -1519,8 +1242,8 @@ private String getBotAppId(TurnContext turnContext) throws IllegalStateException private String getBotFrameworkOAuthScope() { return channelProvider != null && channelProvider.isGovernment() - ? GovernmentAuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE - : AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE; + ? GovernmentAuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + : AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE; } /** @@ -1559,23 +1282,16 @@ protected static String keyForConnectorClient(String serviceUrl, String appId, S private static class TenantIdWorkaroundForTeamsMiddleware implements Middleware { @Override public CompletableFuture onTurn(TurnContext turnContext, NextDelegate next) { - if ( - StringUtils - .equalsIgnoreCase(turnContext.getActivity().getChannelId(), Channels.MSTEAMS) + if (StringUtils.equalsIgnoreCase(turnContext.getActivity().getChannelId(), Channels.MSTEAMS) && turnContext.getActivity().getConversation() != null - && StringUtils.isEmpty(turnContext.getActivity().getConversation().getTenantId()) - ) { - - JsonNode teamsChannelData = - new ObjectMapper().valueToTree(turnContext.getActivity().getChannelData()); - if ( - teamsChannelData != null && teamsChannelData.has("tenant") - && teamsChannelData.get("tenant").has("id") - ) { - - turnContext.getActivity() - .getConversation() - .setTenantId(teamsChannelData.get("tenant").get("id").asText()); + && StringUtils.isEmpty(turnContext.getActivity().getConversation().getTenantId())) { + + JsonNode teamsChannelData = new ObjectMapper().valueToTree(turnContext.getActivity().getChannelData()); + if (teamsChannelData != null && teamsChannelData.has("tenant") + && teamsChannelData.get("tenant").has("id")) { + + turnContext.getActivity().getConversation() + .setTenantId(teamsChannelData.get("tenant").get("id").asText()); } } @@ -1601,6 +1317,453 @@ protected Map getConnectorClientCache() { return Collections.unmodifiableMap(connectorClients); } + /** + * Attempts to retrieve the token for a user that's in a login flow, using + * customized AppCredentials. + * + * @param context Context for the current turn of + * conversation with the user. + * @param oAuthAppCredentials AppCredentials for OAuth. + * @param connectionName Name of the auth connection to use. + * @param magicCode (Optional) Optional user entered code + * to validate. + * + * @return Token Response. + */ + @Override + public CompletableFuture getUserToken(TurnContext context, AppCredentials oAuthAppCredentials, + String connectionName, String magicCode) { + + if (context == null) { + return Async.completeExceptionally(new IllegalArgumentException("TurnContext")); + } + + if (context.getActivity().getFrom() == null + || StringUtils.isEmpty(context.getActivity().getFrom().getId())) { + return Async.completeExceptionally(new IllegalArgumentException( + "BotFrameworkAdapter.GetUserTokenAsync(): missing from or from.id" + )); + } + + if (StringUtils.isEmpty(connectionName)) { + return Async.completeExceptionally(new IllegalArgumentException( + "connectionName cannot be null." + )); + } + + OAuthClient client = createOAuthAPIClient(context, oAuthAppCredentials).join(); + return client.getUserToken().getToken( + context.getActivity().getFrom().getId(), + connectionName, + context.getActivity().getChannelId(), + magicCode); + } + + /** + * Get the raw signin link to be sent to the user for signin for a + * connection name, using customized AppCredentials. + * + * @param context Context for the current turn of + * conversation with the user. + * @param oAuthAppCredentials AppCredentials for OAuth. + * @param connectionName Name of the auth connection to use. + * + * @return A task that represents the work queued to execute. + * + * If the task completes successfully, the result contains the raw signin + * link. + */ + @Override + public CompletableFuture getOAuthSignInLink(TurnContext context, AppCredentials oAuthAppCredentials, + String connectionName) { + + if (context == null) { + return Async.completeExceptionally(new IllegalArgumentException("TurnContext")); + } + + if (StringUtils.isEmpty(connectionName)) { + Async.completeExceptionally(new IllegalArgumentException( + "connectionName cannot be null." + )); + } + + return createOAuthAPIClient(context, oAuthAppCredentials).thenCompose(oAuthClient -> { + try { + Activity activity = context.getActivity(); + String appId = getBotAppId(context); + + TokenExchangeState tokenExchangeState = new TokenExchangeState() { + { + setConnectionName(connectionName); + setConversation(new ConversationReference() { + { + setActivityId(activity.getId()); + setBot(activity.getRecipient()); + setChannelId(activity.getChannelId()); + setConversation(activity.getConversation()); + setServiceUrl(activity.getServiceUrl()); + setUser(activity.getFrom()); + } + }); + setRelatesTo(activity.getRelatesTo()); + setMsAppId(appId); + } + }; + + String serializedState = Serialization.toString(tokenExchangeState); + String state = Base64.getEncoder().encodeToString(serializedState.getBytes(StandardCharsets.UTF_8)); + + return oAuthClient.getBotSignIn().getSignInUrl(state); + } catch (Throwable t) { + throw new CompletionException(t); + } + }); + } + + /** + * Get the raw signin link to be sent to the user for signin for a + * connection name, using the bot's AppCredentials. + * + * @param context Context for the current turn of + * conversation with the user. + * @param connectionName Name of the auth connection to use. + * @param userId The user id that will be associated with + * the token. + * @param finalRedirect The final URL that the OAuth flow will + * redirect to. + * + * @return A task that represents the work queued to execute. + * + * If the task completes successfully, the result contains the raw signin + * link. + */ + @Override + public CompletableFuture getOAuthSignInLink(TurnContext context, AppCredentials oAuthAppCredentials, + String connectionName, String userId, String finalRedirect) { + if (context == null) { + return Async.completeExceptionally(new IllegalArgumentException("TurnContext")); + } + if (StringUtils.isEmpty(connectionName)) { + return Async.completeExceptionally(new IllegalArgumentException("connectionName")); + } + if (StringUtils.isEmpty(userId)) { + return Async.completeExceptionally(new IllegalArgumentException("userId")); + } + + return createOAuthAPIClient(context, oAuthAppCredentials).thenCompose(oAuthClient -> { + try { + Activity activity = context.getActivity(); + String appId = getBotAppId(context); + + TokenExchangeState tokenExchangeState = new TokenExchangeState() { + { + setConnectionName(connectionName); + setConversation(new ConversationReference() { + { + setActivityId(activity.getId()); + setBot(activity.getRecipient()); + setChannelId(activity.getChannelId()); + setConversation(activity.getConversation()); + setServiceUrl(activity.getServiceUrl()); + setUser(activity.getFrom()); + } + }); + setRelatesTo(activity.getRelatesTo()); + setMsAppId(appId); + } + }; + + String serializedState = Serialization.toString(tokenExchangeState); + String state = Base64.getEncoder().encodeToString(serializedState.getBytes(StandardCharsets.UTF_8)); + + return oAuthClient.getBotSignIn().getSignInUrl(state, null, null, finalRedirect); + } catch (Throwable t) { + throw new CompletionException(t); + } + }); + } + + /** + * Signs the user out with the token server, using customized + * AppCredentials. + * + * @param context Context for the current turn of + * conversation with the user. + * @param oAuthAppCredentials AppCredentials for OAuth. + * @param connectionName Name of the auth connection to use. + * @param userId User id of user to sign out. + * + * @return A task that represents the work queued to execute. + */ + @Override + public CompletableFuture signOutUser(TurnContext context, AppCredentials oAuthAppCredentials, + String connectionName, String userId) { + if (context == null) { + return Async.completeExceptionally(new IllegalArgumentException("TurnContext")); + } + if (StringUtils.isEmpty(connectionName)) { + return Async.completeExceptionally(new IllegalArgumentException("connectionName")); + } + + return createOAuthAPIClient(context, oAuthAppCredentials).thenCompose(oAuthClient -> { + return oAuthClient.getUserToken().signOut(context.getActivity().getFrom().getId(), connectionName, + context.getActivity().getChannelId()); + }).thenApply(signOutResult -> null); + } + + /** + * Retrieves the token status for each configured connection for the given + * user, using customized AppCredentials. + * + * @param context Context for the current turn of + * conversation with the user. + * @param oAuthAppCredentials AppCredentials for OAuth. + * @param userId The user Id for which token status is + * retrieved. + * @param includeFilter Optional comma separated list of + * connection's to include. Blank will return token status for all + * configured connections. + * + * @return List of TokenStatus. + */ + @Override + public CompletableFuture> getTokenStatus(TurnContext context, AppCredentials oAuthAppCredentials, + String userId, String includeFilter) { + if (context == null) { + return Async.completeExceptionally(new IllegalArgumentException("TurnContext")); + } + if (StringUtils.isEmpty(userId)) { + return Async.completeExceptionally(new IllegalArgumentException("userId")); + } + + return createOAuthAPIClient(context, oAuthAppCredentials).thenCompose(oAuthClient -> { + return oAuthClient.getUserToken().getTokenStatus(userId, context.getActivity().getChannelId(), + includeFilter); + }); + } + + /** + * Retrieves Azure Active Directory tokens for particular resources on a + * configured connection, using customized AppCredentials. + * + * @param context Context for the current turn of + * conversation with the user. + * @param oAuthAppCredentials AppCredentials for OAuth. + * @param connectionName The name of the Azure Active + * Directory connection configured with this bot. + * @param resourceUrls The list of resource URLs to retrieve + * tokens for. + * @param userId The user Id for which tokens are + * retrieved. If passing in null the userId is taken from the Activity in + * the TurnContext. + * + * @return Dictionary of resourceUrl to the corresponding + * TokenResponse. + */ + @Override + public CompletableFuture> getAadTokens(TurnContext context, + AppCredentials oAuthAppCredentials, String connectionName, String[] resourceUrls, String userId) { + if (context == null) { + return Async.completeExceptionally(new IllegalArgumentException("TurnContext")); + } + if (StringUtils.isEmpty(connectionName)) { + return Async.completeExceptionally(new IllegalArgumentException("connectionName")); + } + if (resourceUrls == null) { + return Async.completeExceptionally(new IllegalArgumentException("resourceUrls")); + } + + return createOAuthAPIClient(context, oAuthAppCredentials).thenCompose(oAuthClient -> { + String effectiveUserId = userId; + if (StringUtils.isEmpty(effectiveUserId) && context.getActivity() != null + && context.getActivity().getFrom() != null) { + effectiveUserId = context.getActivity().getFrom().getId(); + } + + return oAuthClient.getUserToken().getAadTokens(effectiveUserId, connectionName, + new AadResourceUrls(resourceUrls)); + }); + + } + + /** + * Get the raw signin link to be sent to the user for signin for a + * connection name. + * + * @param turnContext Context for the current turn of + * conversation with the user. + * @param connectionName Name of the auth connection to use. + * + * @return A task that represents the work queued to execute. + * + * If the task completes successfully, the result contains the raw signin + * link. + */ + @Override + public CompletableFuture getSignInResource(TurnContext turnContext, String connectionName) { + return getSignInResource(turnContext, connectionName, turnContext.getActivity().getFrom().getId(), null); + } + + /** + * Get the raw signin link to be sent to the user for signin for a + * connection name. + * + * @param turnContext Context for the current turn of + * conversation with the user. + * @param connectionName Name of the auth connection to use. + * @param userId The user id that will be associated with + * the token. + * @param finalRedirect The final URL that the OAuth flow will + * redirect to. + * + * @return A task that represents the work queued to execute. + * + * If the task completes successfully, the result contains the raw signin + * link. + */ + @Override + public CompletableFuture getSignInResource(TurnContext turnContext, String connectionName, + String userId, String finalRedirect) { + return getSignInResource(turnContext, null, connectionName, userId, finalRedirect); + } + + /** + * Get the raw signin link to be sent to the user for signin for a + * connection name. + * + * @param context Context for the current turn of + * conversation with the user. + * @param oAuthAppCredentials AppCredentials for OAuth. + * @param connectionName Name of the auth connection to use. + * @param userId The user id that will be associated + * with the token. + * @param finalRedirect The final URL that the OAuth flow + * will redirect to. + * + * @return A task that represents the work queued to execute. + * + * If the task completes successfully, the result contains the raw signin + * link. + */ + @Override + public CompletableFuture getSignInResource(TurnContext context, + AppCredentials oAuthAppCredentials, String connectionName, String userId, String finalRedirect) { + if (context == null) { + return Async.completeExceptionally(new IllegalArgumentException("TurnContext")); + } + + if (StringUtils.isEmpty(connectionName)) { + throw new IllegalArgumentException("connectionName cannot be null."); + } + + if (StringUtils.isEmpty(userId)) { + throw new IllegalArgumentException("userId cannot be null."); + } + + return createOAuthAPIClient(context, oAuthAppCredentials).thenCompose(oAuthClient -> { + try { + Activity activity = context.getActivity(); + String appId = getBotAppId(context); + + TokenExchangeState tokenExchangeState = new TokenExchangeState() { + { + setConnectionName(connectionName); + setConversation(new ConversationReference() { + { + setActivityId(activity.getId()); + setBot(activity.getRecipient()); + setChannelId(activity.getChannelId()); + setConversation(activity.getConversation()); + setLocale(activity.getLocale()); + setServiceUrl(activity.getServiceUrl()); + setUser(activity.getFrom()); + } + }); + setRelatesTo(activity.getRelatesTo()); + setMsAppId(appId); + } + }; + + String serializedState = Serialization.toString(tokenExchangeState); + String state = Base64.getEncoder().encodeToString(serializedState.getBytes(StandardCharsets.UTF_8)); + + return oAuthClient.getBotSignIn().getSignInResource(state, null, null, finalRedirect); + } catch (Throwable t) { + throw new CompletionException(t); + } + }); + + } + + /** + * Performs a token exchange operation such as for single sign-on. + * + * @param turnContext Context for the current turn of + * conversation with the user. + * @param connectionName Name of the auth connection to use. + * @param userId The user id associated with the token.. + * @param exchangeRequest The exchange request details, either a + * token to exchange or a uri to exchange. + * + * @return If the task completes, the exchanged token is returned. + */ + @Override + public CompletableFuture exchangeToken(TurnContext turnContext, String connectionName, String userId, + TokenExchangeRequest exchangeRequest) { + return exchangeToken(turnContext, null, connectionName, userId, exchangeRequest); + } + + /** + * Performs a token exchange operation such as for single sign-on. + * + * @param turnContext Context for the current turn of + * conversation with the user. + * @param oAuthAppCredentials AppCredentials for OAuth. + * @param connectionName Name of the auth connection to use. + * @param userId The user id associated with the + * token.. + * @param exchangeRequest The exchange request details, either + * a token to exchange or a uri to exchange. + * + * @return If the task completes, the exchanged token is returned. + */ + @Override + public CompletableFuture exchangeToken(TurnContext turnContext, AppCredentials oAuthAppCredentials, + String connectionName, String userId, TokenExchangeRequest exchangeRequest) { + + if (StringUtils.isEmpty(connectionName)) { + return Async.completeExceptionally(new IllegalArgumentException( + "connectionName is null or empty" + )); + } + + if (StringUtils.isEmpty(userId)) { + return Async.completeExceptionally(new IllegalArgumentException( + "userId is null or empty" + )); + } + + if (exchangeRequest == null) { + return Async.completeExceptionally(new IllegalArgumentException( + "exchangeRequest is null" + )); + } + + if (StringUtils.isEmpty(exchangeRequest.getToken()) && StringUtils.isEmpty(exchangeRequest.getUri())) { + return Async.completeExceptionally(new IllegalArgumentException( + "Either a Token or Uri property is required on the TokenExchangeRequest" + )); + } + + return createOAuthAPIClient(turnContext, oAuthAppCredentials).thenCompose(oAuthClient -> { + return oAuthClient.getUserToken().exchangeToken(userId, + connectionName, + turnContext.getActivity().getChannelId(), + exchangeRequest); + + }); + } + /** * Inserts a ConnectorClient into the cache. FOR UNIT TESTING ONLY. * @param serviceUrl The service url diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/ConnectorClientBuilder.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/ConnectorClientBuilder.java new file mode 100644 index 000000000..b63e19699 --- /dev/null +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/ConnectorClientBuilder.java @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.builder; + +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.connector.ConnectorClient; +import com.microsoft.bot.connector.authentication.ClaimsIdentity; + +/** + * Abstraction to build connector clients. + */ + public interface ConnectorClientBuilder { + + /** + * Creates the connector client asynchronous. + * @param serviceUrl The service URL. + * @param claimsIdentity The claims claimsIdentity. + * @param audience The target audience for the connector. + * @return ConnectorClient instance. + */ + CompletableFuture createConnectorClient(String serviceUrl, + ClaimsIdentity claimsIdentity, + String audience); +} diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/TurnStateConstants.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/TurnStateConstants.java new file mode 100644 index 000000000..fab1f8a14 --- /dev/null +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/TurnStateConstants.java @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.builder; + +import java.time.Duration; + +/** + * Constants used in TurnState. + */ +public final class TurnStateConstants { + + private TurnStateConstants() { + + } + + /** + * TurnState key for the OAuth login timeout. + */ + public static final String OAUTH_LOGIN_TIMEOUT_KEY = "loginTimeout"; + + /** + * Name of the token polling settings key. + */ + public static final String TOKEN_POLLING_SETTINGS_KEY = "tokenPollingSettings"; + + /** + * Default amount of time an OAuthCard will remain active (clickable and + * actively waiting for a token). After this time: (1) the OAuthCard will not + * allow the user to click on it. (2) any polling triggered by the OAuthCard + * will stop. + */ + public static final Duration OAUTH_LOGIN_TIMEOUT_VALUE = Duration.ofMinutes(15); +} diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/UserTokenProvider.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/UserTokenProvider.java index 47b2b8370..995b97a8d 100644 --- a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/UserTokenProvider.java +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/UserTokenProvider.java @@ -3,6 +3,9 @@ package com.microsoft.bot.builder; +import com.microsoft.bot.connector.authentication.AppCredentials; +import com.microsoft.bot.schema.SignInResource; +import com.microsoft.bot.schema.TokenExchangeRequest; import com.microsoft.bot.schema.TokenResponse; import com.microsoft.bot.schema.TokenStatus; @@ -39,7 +42,7 @@ CompletableFuture getUserToken( * @return A task that represents the work queued to execute. If the task * completes successfully, the result contains the raw signin link. */ - CompletableFuture getOauthSignInLink(TurnContext turnContext, String connectionName); + CompletableFuture getOAuthSignInLink(TurnContext turnContext, String connectionName); /** * Get the raw signin link to be sent to the user for signin for a connection @@ -53,7 +56,7 @@ CompletableFuture getUserToken( * @return A task that represents the work queued to execute. If the task * completes successfully, the result contains the raw signin link. */ - CompletableFuture getOauthSignInLink( + CompletableFuture getOAuthSignInLink( TurnContext turnContext, String connectionName, String userId, @@ -156,4 +159,237 @@ CompletableFuture> getAadTokens( String[] resourceUrls, String userId ); + + + /** + * Attempts to retrieve the token for a user that's in a login flow, using + * customized AppCredentials. + * + * @param turnContext Context for the current turn of + * conversation with the user. + * @param oAuthAppCredentials AppCredentials for OAuth. + * @param connectionName Name of the auth connection to use. + * @param magicCode (Optional) Optional user entered code + * to validate. + * + * @return Token Response. + */ + CompletableFuture getUserToken( + TurnContext turnContext, + AppCredentials oAuthAppCredentials, + String connectionName, + String magicCode); + + /** + * Get the raw signin link to be sent to the user for signin for a + * connection name, using customized AppCredentials. + * + * @param turnContext Context for the current turn of + * conversation with the user. + * @param oAuthAppCredentials AppCredentials for OAuth. + * @param connectionName Name of the auth connection to use. + * + * @return A CompletableFuture that represents the work queued to execute. + * + * If the CompletableFuture completes successfully, the result contains the raw signin + * link. + */ + CompletableFuture getOAuthSignInLink( + TurnContext turnContext, + AppCredentials oAuthAppCredentials, + String connectionName); + + /** + * Get the raw signin link to be sent to the user for signin for a + * connection name, using customized AppCredentials. + * + * @param turnContext Context for the current turn of + * conversation with the user. + * @param oAuthAppCredentials AppCredentials for OAuth. + * @param connectionName Name of the auth connection to use. + * @param userId The user id that will be associated + * with the token. + * @param finalRedirect The final URL that the OAuth flow + * will redirect to. + * + * @return A CompletableFuture that represents the work queued to execute. + * + * If the CompletableFuture completes successfully, the result contains the raw signin + * link. + */ + CompletableFuture getOAuthSignInLink( + TurnContext turnContext, + AppCredentials oAuthAppCredentials, + String connectionName, + String userId, + String finalRedirect); + + /** + * Signs the user out with the token server, using customized + * AppCredentials. + * + * @param turnContext Context for the current turn of + * conversation with the user. + * @param oAuthAppCredentials AppCredentials for OAuth. + * @param connectionName Name of the auth connection to use. + * @param userId User id of user to sign out. + * + * @return A CompletableFuture that represents the work queued to execute. + */ + CompletableFuture signOutUser( + TurnContext turnContext, + AppCredentials oAuthAppCredentials, + String connectionName, + String userId); + + /** + * Retrieves the token status for each configured connection for the given + * user, using customized AppCredentials. + * + * @param context Context for the current turn of + * conversation with the user. + * @param oAuthAppCredentials AppCredentials for OAuth. + * @param userId The user Id for which token status is + * retrieved. + * @param includeFilter Optional comma separated list of + * connection's to include. Blank will return token status for all + * configured connections. + * + * @return Array of TokenStatus. + */ + CompletableFuture> getTokenStatus( + TurnContext context, + AppCredentials oAuthAppCredentials, + String userId, + String includeFilter); + + /** + * Retrieves Azure Active Directory tokens for particular resources on a + * configured connection, using customized AppCredentials. + * + * @param context Context for the current turn of + * conversation with the user. + * @param oAuthAppCredentials AppCredentials for OAuth. + * @param connectionName The name of the Azure Active + * Directory connection configured with this bot. + * @param resourceUrls The list of resource URLs to retrieve + * tokens for. + * @param userId The user Id for which tokens are + * retrieved. If passing in null the userId is taken from the Activity in + * the TurnContext. + * + * @return Dictionary of resourceUrl to the corresponding + * TokenResponse. + */ + CompletableFuture> getAadTokens( + TurnContext context, + AppCredentials oAuthAppCredentials, + String connectionName, + String[] resourceUrls, + String userId); + + /** + * Get the raw signin link to be sent to the user for signin for a + * connection name. + * + * @param turnContext Context for the current turn of + * conversation with the user. + * @param connectionName Name of the auth connection to use. + * + * @return A CompletableFuture that represents the work queued to execute. + * + * If the CompletableFuture completes successfully, the result contains the raw signin + * link. + */ + CompletableFuture getSignInResource( + TurnContext turnContext, + String connectionName); + + /** + * Get the raw signin link to be sent to the user for signin for a + * connection name. + * + * @param turnContext Context for the current turn of + * conversation with the user. + * @param connectionName Name of the auth connection to use. + * @param userId The user id that will be associated with + * the token. + * @param finalRedirect The final URL that the OAuth flow will + * redirect to. + * + * @return A CompletableFuture that represents the work queued to execute. + * + * If the CompletableFuture completes successfully, the result contains the raw signin + * link. + */ + CompletableFuture getSignInResource( + TurnContext turnContext, + String connectionName, + String userId, + String finalRedirect); + + /** + * Get the raw signin link to be sent to the user for signin for a + * connection name. + * + * @param turnContext Context for the current turn of + * conversation with the user. + * @param oAuthAppCredentials Credentials for OAuth. + * @param connectionName Name of the auth connection to use. + * @param userId The user id that will be associated + * with the token. + * @param finalRedirect The final URL that the OAuth flow + * will redirect to. + * + * @return A CompletableFuture that represents the work queued to execute. + * + * If the CompletableFuture completes successfully, the result contains the raw signin + * link. + */ + CompletableFuture getSignInResource( + TurnContext turnContext, + AppCredentials oAuthAppCredentials, + String connectionName, + String userId, + String finalRedirect); + + /** + * Performs a token exchange operation such as for single sign-on. + * + * @param turnContext Context for the current turn of + * conversation with the user. + * @param connectionName Name of the auth connection to use. + * @param userId The user id associated with the token.. + * @param exchangeRequest The exchange request details, either a + * token to exchange or a uri to exchange. + * + * @return If the CompletableFuture completes, the exchanged token is returned. + */ + CompletableFuture exchangeToken( + TurnContext turnContext, + String connectionName, + String userId, + TokenExchangeRequest exchangeRequest); + + /** + * Performs a token exchange operation such as for single sign-on. + * + * @param turnContext Context for the current turn of + * conversation with the user. + * @param oAuthAppCredentials AppCredentials for OAuth. + * @param connectionName Name of the auth connection to use. + * @param userId The user id associated with the + * token.. + * @param exchangeRequest The exchange request details, either + * a token to exchange or a uri to exchange. + * + * @return If the CompletableFuture completes, the exchanged token is returned. + */ + CompletableFuture exchangeToken( + TurnContext turnContext, + AppCredentials oAuthAppCredentials, + String connectionName, + String userId, + TokenExchangeRequest exchangeRequest); + } diff --git a/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/TestAdapterTests.java b/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/TestAdapterTests.java index b6be7ecc3..efd49f498 100644 --- a/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/TestAdapterTests.java +++ b/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/TestAdapterTests.java @@ -14,6 +14,7 @@ import org.junit.Assert; import org.junit.Test; +import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; @@ -451,9 +452,9 @@ public void TestAdapter_GetTokenStatus() { adapter.addUserToken("ABC", channelId, userId, token, null); adapter.addUserToken("DEF", channelId, userId, token, null); - TokenStatus[] status = adapter.getTokenStatus(turnContext, userId, null).join(); + List status = adapter.getTokenStatus(turnContext, userId, null).join(); Assert.assertNotNull(status); - Assert.assertEquals(2, status.length); + Assert.assertEquals(2, status.size()); } @Test @@ -478,8 +479,8 @@ public void TestAdapter_GetTokenStatusWithFilter() { adapter.addUserToken("ABC", channelId, userId, token, null); adapter.addUserToken("DEF", channelId, userId, token, null); - TokenStatus[] status = adapter.getTokenStatus(turnContext, userId, "DEF").join(); + List status = adapter.getTokenStatus(turnContext, userId, "DEF").join(); Assert.assertNotNull(status); - Assert.assertEquals(1, status.length); + Assert.assertEquals(1, status.size()); } } diff --git a/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/adapters/TestAdapter.java b/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/adapters/TestAdapter.java index d4425747b..84a46a6f4 100644 --- a/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/adapters/TestAdapter.java +++ b/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/adapters/TestAdapter.java @@ -4,35 +4,67 @@ package com.microsoft.bot.builder.adapters; import com.microsoft.bot.builder.*; +import com.microsoft.bot.connector.Async; import com.microsoft.bot.connector.Channels; -import com.microsoft.bot.connector.UserToken; +import com.microsoft.bot.connector.authentication.AppCredentials; import com.microsoft.bot.schema.*; import org.apache.commons.lang3.StringUtils; + import java.time.OffsetDateTime; import java.time.ZoneId; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; -public class TestAdapter extends BotAdapter { +public class TestAdapter extends BotAdapter implements UserTokenProvider { + + private final String exceptionExpected = "ExceptionExpected"; private final Queue botReplies = new LinkedList<>(); private int nextId = 0; private ConversationReference conversationReference; private String locale; private boolean sendTraceActivity = false; + private Map exchangableToken = new HashMap(); + private static class UserTokenKey { - public String connectionName; - public String userId; - public String channelId; + private String connectionName; + private String userId; + private String channelId; + + public String getConnectionName() { + return connectionName; + } + + public void setConnectionName(String withConnectionName) { + connectionName = withConnectionName; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String withUserId) { + userId = withUserId; + } + + public String getChannelId() { + return channelId; + } + + public void setChannelId(String withChannelId) { + channelId = withChannelId; + } + + @Override public boolean equals(Object rhs) { if (!(rhs instanceof UserTokenKey)) return false; return StringUtils.equals(connectionName, ((UserTokenKey) rhs).connectionName) - && StringUtils.equals(userId, ((UserTokenKey) rhs).userId) + && StringUtils.equals(userId, ((UserTokenKey) rhs).userId) && StringUtils.equals(channelId, ((UserTokenKey) rhs).channelId); } @@ -132,9 +164,11 @@ public TestAdapter use(Middleware middleware) { return this; } - /** - * Adds middleware to the adapter to register an Storage object on the turn context. - * The middleware registers the state objects on the turn context at the start of each turn. + /** + * Adds middleware to the adapter to register an Storage object on the turn + * context. The middleware registers the state objects on the turn context at + * the start of each turn. + * * @param storage The storage object to register. * @return The updated adapter. */ @@ -146,8 +180,10 @@ public TestAdapter useStorage(Storage storage) { } /** - * Adds middleware to the adapter to register one or more BotState objects on the turn context. - * The middleware registers the state objects on the turn context at the start of each turn. + * Adds middleware to the adapter to register one or more BotState objects on + * the turn context. The middleware registers the state objects on the turn + * context at the start of each turn. + * * @param botstates The state objects to register. * @return The updated adapter. */ @@ -169,10 +205,8 @@ public CompletableFuture processActivity(Activity activity, BotCallbackHan activity.setType(ActivityTypes.MESSAGE); activity.setChannelId(conversationReference().getChannelId()); - if (activity.getFrom() == null - || StringUtils.equalsIgnoreCase(activity.getFrom().getId(), "unknown") - || activity.getFrom().getRole() == RoleTypes.BOT - ) { + if (activity.getFrom() == null || StringUtils.equalsIgnoreCase(activity.getFrom().getId(), "unknown") + || activity.getFrom().getRole() == RoleTypes.BOT) { activity.setFrom(conversationReference().getUser()); } @@ -206,10 +240,7 @@ public void setConversationReference(ConversationReference conversationReference } @Override - public CompletableFuture sendActivities( - TurnContext context, - List activities - ) { + public CompletableFuture sendActivities(TurnContext context, List activities) { List responses = new LinkedList(); for (Activity activity : activities) { @@ -221,23 +252,12 @@ public CompletableFuture sendActivities( responses.add(new ResourceResponse(activity.getId())); - System.out.println( - String.format( - "TestAdapter:SendActivities, Count:%s (tid:%s)", - activities.size(), - Thread.currentThread().getId() - ) - ); + System.out.println(String.format("TestAdapter:SendActivities, Count:%s (tid:%s)", activities.size(), + Thread.currentThread().getId())); for (Activity act : activities) { System.out.printf(" :--------\n : To:%s\n", act.getRecipient().getName()); - System.out.printf( - " : From:%s\n", - (act.getFrom() == null) ? "No from set" : act.getFrom().getName() - ); - System.out.printf( - " : Text:%s\n :---------\n", - (act.getText() == null) ? "No text set" : act.getText() - ); + System.out.printf(" : From:%s\n", (act.getFrom() == null) ? "No from set" : act.getFrom().getName()); + System.out.printf(" : Text:%s\n :---------\n", (act.getText() == null) ? "No text set" : act.getText()); } // This is simulating DELAY @@ -263,16 +283,11 @@ public CompletableFuture sendActivities( } } } - return CompletableFuture.completedFuture( - responses.toArray(new ResourceResponse[responses.size()]) - ); + return CompletableFuture.completedFuture(responses.toArray(new ResourceResponse[responses.size()])); } @Override - public CompletableFuture updateActivity( - TurnContext context, - Activity activity - ) { + public CompletableFuture updateActivity(TurnContext context, Activity activity) { synchronized (botReplies) { List replies = new ArrayList<>(botReplies); for (int i = 0; i < botReplies.size(); i++) { @@ -283,9 +298,7 @@ public CompletableFuture updateActivity( for (Activity item : replies) { botReplies.add(item); } - return CompletableFuture.completedFuture( - new ResourceResponse(activity.getId()) - ); + return CompletableFuture.completedFuture(new ResourceResponse(activity.getId())); } } } @@ -293,10 +306,7 @@ public CompletableFuture updateActivity( } @Override - public CompletableFuture deleteActivity( - TurnContext context, - ConversationReference reference - ) { + public CompletableFuture deleteActivity(TurnContext context, ConversationReference reference) { synchronized (botReplies) { ArrayList replies = new ArrayList<>(botReplies); for (int i = 0; i < botReplies.size(); i++) { @@ -363,13 +373,8 @@ public CompletableFuture sendTextToBot(String userSays, BotCallbackHandler return processActivity(this.makeActivity(userSays), callback); } - public void addUserToken( - String connectionName, - String channelId, - String userId, - String token, - String withMagicCode - ) { + public void addUserToken(String connectionName, String channelId, String userId, String token, + String withMagicCode) { UserTokenKey userKey = new UserTokenKey(); userKey.connectionName = connectionName; userKey.channelId = channelId; @@ -388,116 +393,23 @@ public void addUserToken( } } - public CompletableFuture getUserToken( - TurnContext turnContext, - String connectionName, - String magicCode - ) { - UserTokenKey key = new UserTokenKey(); - key.connectionName = connectionName; - key.channelId = turnContext.getActivity().getChannelId(); - key.userId = turnContext.getActivity().getFrom().getId(); - - if (magicCode != null) { - TokenMagicCode magicCodeRecord = magicCodes.stream().filter( - tokenMagicCode -> key.equals(tokenMagicCode.key) - ).findFirst().orElse(null); - if ( - magicCodeRecord != null && StringUtils.equals(magicCodeRecord.magicCode, magicCode) - ) { - addUserToken( - connectionName, - key.channelId, - key.userId, - magicCodeRecord.userToken, - null - ); - } - } + public CompletableFuture getUserToken(TurnContext turnContext, String connectionName, + String magicCode) { + return getUserToken(turnContext, null, connectionName, magicCode); - if (userTokens.containsKey(key)) { - return CompletableFuture.completedFuture(new TokenResponse() { - { - setConnectionName(connectionName); - setToken(userTokens.get(key)); - } - }); - } - - return CompletableFuture.completedFuture(null); } - - public CompletableFuture getOAuthSignInLink( - TurnContext turnContext, - String connectionName - ) { - return getOAuthSignInLink( - turnContext, - connectionName, - turnContext.getActivity().getFrom().getId(), - null - ); - } - - public CompletableFuture getOAuthSignInLink( - TurnContext turnContext, - String connectionName, - String userId, - String finalRedirect - ) { - String link = String.format( - "https://fake.com/oauthsignin/%s/{turnContext.Activity.ChannelId}/%s", - connectionName, - userId == null ? "" : userId - ); - return CompletableFuture.completedFuture(link); - } - - public CompletableFuture signOutUser( - TurnContext turnContext, - String connectionName, - String userId - ) { - String channelId = turnContext.getActivity().getChannelId(); - final String effectiveUserId = userId == null - ? turnContext.getActivity().getFrom().getId() - : userId; - - userTokens.keySet().stream().filter( - t -> StringUtils.equals(t.channelId, channelId) && StringUtils.equals(t.userId, effectiveUserId) && connectionName == null || StringUtils.equals(t.connectionName, connectionName) - ).collect(Collectors.toList()).forEach(key -> userTokens.remove(key)); - - return CompletableFuture.completedFuture(null); + public CompletableFuture signOutUser(TurnContext turnContext, String connectionName, String userId) { + return signOutUser(turnContext, null, connectionName, userId); } - public CompletableFuture getTokenStatus( - TurnContext turnContext, - String userId, - String includeFilter - ) { - String[] filter = includeFilter == null ? null : includeFilter.split(","); - List records = userTokens.keySet().stream().filter( - x -> StringUtils.equals(x.channelId, turnContext.getActivity().getChannelId()) && StringUtils.equals(x.userId, turnContext.getActivity().getFrom().getId()) && (includeFilter == null || Arrays.binarySearch(filter, x.connectionName) != -1) - ).map(r -> new TokenStatus() { - { - setConnectionName(r.connectionName); - setHasToken(true); - setServiceProviderDisplayName(r.connectionName); - } - }).collect(Collectors.toList()); - - if (records.size() > 0) - return CompletableFuture.completedFuture(records.toArray(new TokenStatus[0])); - return CompletableFuture.completedFuture(null); + public CompletableFuture> getTokenStatus(TurnContext turnContext, String userId, + String includeFilter) { + return getTokenStatus(turnContext, null, userId, includeFilter); } - public CompletableFuture> getAadTokens( - TurnContext turnContext, - String connectionName, - String[] resourceUrls, - String userId - ) { - return CompletableFuture.completedFuture(new HashMap<>()); + public CompletableFuture> getAadTokens(TurnContext turnContext, String connectionName, + String[] resourceUrls, String userId) { + return getAadTokens(turnContext, null, connectionName, resourceUrls, userId); } public static ConversationReference createConversationReference(String name, String user, String bot) { @@ -526,4 +438,249 @@ public void setSendTraceActivity(boolean sendTraceActivity) { public boolean getSendTraceActivity() { return sendTraceActivity; } + + public CompletableFuture getOAuthSignInLink(TurnContext turnContext, String connectionName) { + return getOAuthSignInLink(turnContext, null, connectionName); + } + + public CompletableFuture getOAuthSignInLink(TurnContext turnContext, String connectionName, String userId, + String finalRedirect) { + return getOAuthSignInLink(turnContext, null, connectionName, userId, finalRedirect); + } + + @Override + public CompletableFuture getOAuthSignInLink(TurnContext turnContext, AppCredentials oAuthAppCredentials, + String connectionName) { + return CompletableFuture.completedFuture( + String.format("https://fake.com/oauthsignin/%s/%s", + connectionName, + turnContext.getActivity().getChannelId())); + } + + public CompletableFuture getOAuthSignInLink(TurnContext turnContext, AppCredentials oAuthAppCredentials, + String connectionName, String userId, String finalRedirect) { + return CompletableFuture.completedFuture( + String.format("https://fake.com/oauthsignin/%s/%s/%s", + connectionName, + turnContext.getActivity().getChannelId(), + userId)); + } + + @Override + public CompletableFuture getUserToken(TurnContext turnContext, AppCredentials oAuthAppCredentials, + String connectionName, String magicCode) { + UserTokenKey key = new UserTokenKey(); + key.connectionName = connectionName; + key.channelId = turnContext.getActivity().getChannelId(); + key.userId = turnContext.getActivity().getFrom().getId(); + + if (magicCode != null) { + TokenMagicCode magicCodeRecord = magicCodes.stream() + .filter(tokenMagicCode -> key.equals(tokenMagicCode.key)).findFirst().orElse(null); + if (magicCodeRecord != null && StringUtils.equals(magicCodeRecord.magicCode, magicCode)) { + addUserToken(connectionName, key.channelId, key.userId, magicCodeRecord.userToken, null); + } + } + + if (userTokens.containsKey(key)) { + return CompletableFuture.completedFuture(new TokenResponse() { + { + setConnectionName(connectionName); + setToken(userTokens.get(key)); + } + }); + } + + return CompletableFuture.completedFuture(null); + } + + /** + * Adds a fake exchangeable token so it can be exchanged later. + * @param connectionName he connection name. + * @param channelId The channel ID. + * @param userId The user ID. + * @param exchangableItem The exchangeable token or resource URI. + * @param token The token to store. + */ + public void addExchangeableToken(String connectionName, + String channelId, + String userId, + String exchangableItem, + String token) + { + ExchangableTokenKey key = new ExchangableTokenKey(); + key.setConnectionName(connectionName); + key.setChannelId(channelId); + key.setUserId(userId); + key.setExchangableItem(exchangableItem); + + if (exchangableToken.containsKey(key)) { + exchangableToken.replace(key, token); + } else { + exchangableToken.put(key, token); + } + } + + @Override + public CompletableFuture signOutUser(TurnContext turnContext, AppCredentials oAuthAppCredentials, + String connectionName, String userId) { + String channelId = turnContext.getActivity().getChannelId(); + final String effectiveUserId = userId == null ? turnContext.getActivity().getFrom().getId() : userId; + + userTokens.keySet().stream() + .filter(t -> StringUtils.equals(t.channelId, channelId) + && StringUtils.equals(t.userId, effectiveUserId) + && connectionName == null || StringUtils.equals(t.connectionName, connectionName)) + .collect(Collectors.toList()).forEach(key -> userTokens.remove(key)); + + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture> getTokenStatus(TurnContext turnContext, + AppCredentials oAuthAppCredentials, + String userId, + String includeFilter) { + String[] filter = includeFilter == null ? null : includeFilter.split(","); + List records = userTokens.keySet().stream() + .filter(x -> StringUtils.equals(x.channelId, turnContext.getActivity().getChannelId()) + && StringUtils.equals(x.userId, turnContext.getActivity().getFrom().getId()) + && (includeFilter == null || Arrays.binarySearch(filter, x.connectionName) != -1)) + .map(r -> new TokenStatus() { + { + setConnectionName(r.connectionName); + setHasToken(true); + setServiceProviderDisplayName(r.connectionName); + } + }).collect(Collectors.toList()); + + if (records.size() > 0) { + return CompletableFuture.completedFuture(records); + } + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture> getAadTokens(TurnContext context, + AppCredentials oAuthAppCredentials, String connectionName, String[] resourceUrls, String userId) { + return CompletableFuture.completedFuture(new HashMap<>()); + } + + @Override + public CompletableFuture getSignInResource(TurnContext turnContext, String connectionName) { + String id = null; + if (turnContext != null + && turnContext.getActivity() != null + && turnContext.getActivity().getRecipient() != null + && turnContext.getActivity().getRecipient().getId() != null) { + id = turnContext.getActivity().getRecipient().getId(); + } + + return getSignInResource(turnContext, connectionName, id, null); + } + + @Override + public CompletableFuture getSignInResource(TurnContext turnContext, String connectionName, + String userId, String finalRedirect) { + return getSignInResource(turnContext, null, connectionName, userId, finalRedirect); + } + + @Override + public CompletableFuture getSignInResource(TurnContext turnContext, + AppCredentials oAuthAppCredentials, String connectionName, String userId, String finalRedirect) { + + SignInResource signInResource = new SignInResource(); + signInResource.setSignInLink( + String.format("https://fake.com/oauthsignin/%s/%s/%s", + connectionName, + turnContext.getActivity().getChannelId(), + userId)); + TokenExchangeResource tokenExchangeResource = new TokenExchangeResource(); + tokenExchangeResource.setId(UUID.randomUUID().toString()); + tokenExchangeResource.setProviderId(null); + tokenExchangeResource.setUri(String.format("api://%s/resource", connectionName)); + signInResource.setTokenExchangeResource(tokenExchangeResource); + return CompletableFuture.completedFuture(signInResource); + } + + @Override + public CompletableFuture exchangeToken(TurnContext turnContext, String connectionName, String userId, + TokenExchangeRequest exchangeRequest) { + return exchangeToken(turnContext, null, connectionName, userId, exchangeRequest); + } + + @Override + public CompletableFuture exchangeToken(TurnContext turnContext, AppCredentials oAuthAppCredentials, + String connectionName, String userId, TokenExchangeRequest exchangeRequest) { + + String exchangableValue = null; + if (exchangeRequest.getToken() != null) { + if (StringUtils.isNotBlank(exchangeRequest.getToken())) { + exchangableValue = exchangeRequest.getToken(); + } + } else { + if (exchangeRequest.getUri() != null) { + exchangableValue = exchangeRequest.getUri(); + } + } + + ExchangableTokenKey key = new ExchangableTokenKey(); + if (turnContext != null + && turnContext.getActivity() != null + && turnContext.getActivity().getChannelId() != null) { + key.setChannelId(turnContext.getActivity().getChannelId()); + } + key.setConnectionName(connectionName); + key.setExchangableItem(exchangableValue); + key.setUserId(userId); + + String token = exchangableToken.get(key); + if (token != null) { + if (token.equals(exceptionExpected)) { + return Async.completeExceptionally( + new RuntimeException("Exception occurred during exchanging tokens") + ); + } + return CompletableFuture.completedFuture(new TokenResponse() { + { + setChannelId(key.getChannelId()); + setConnectionName(key.getConnectionName()); + setToken(token); + } + }); + } else { + return CompletableFuture.completedFuture(null); + } + + } + + class ExchangableTokenKey extends UserTokenKey { + + private String exchangableItem = ""; + + public String getExchangableItem() { + return exchangableItem; + } + + public void setExchangableItem(String withExchangableItem) { + exchangableItem = withExchangableItem; + } + + @Override + public boolean equals(Object rhs) { + if (!(rhs instanceof ExchangableTokenKey)) { + return false; + } + return StringUtils.equals(exchangableItem, ((ExchangableTokenKey) rhs).exchangableItem) + && super.equals(rhs); + } + + @Override + public int hashCode() { + return Objects.hash(exchangableItem != null ? exchangableItem : "") + super.hashCode(); + } + + + } + } diff --git a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/BotSignIn.java b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/BotSignIn.java index 83efb4bad..18b7689cd 100644 --- a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/BotSignIn.java +++ b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/BotSignIn.java @@ -8,6 +8,8 @@ import java.util.concurrent.CompletableFuture; +import com.microsoft.bot.schema.SignInResource; + /** * An instance of this class provides access to all the operations defined in * BotSignIns. @@ -36,4 +38,27 @@ CompletableFuture getSignInUrl( String emulatorUrl, String finalRedirect ); + + /** + * + * @param state the String value + * @throws IllegalArgumentException thrown if parameters fail the validation + * @return the observable to the String object + */ + CompletableFuture getSignInResource(String state); + /** + * + * @param state the String value + * @param codeChallenge the String value + * @param emulatorUrl the String value + * @param finalRedirect the String value + * @throws IllegalArgumentException thrown if parameters fail the validation + * @return the observable to the String object + */ + CompletableFuture getSignInResource( + String state, + String codeChallenge, + String emulatorUrl, + String finalRedirect + ); } diff --git a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/UserToken.java b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/UserToken.java index 6ec7b3cf6..6422a030e 100644 --- a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/UserToken.java +++ b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/UserToken.java @@ -7,6 +7,7 @@ package com.microsoft.bot.connector; import com.microsoft.bot.schema.AadResourceUrls; +import com.microsoft.bot.schema.TokenExchangeRequest; import com.microsoft.bot.schema.TokenResponse; import com.microsoft.bot.schema.TokenStatus; import java.util.List; @@ -43,6 +44,22 @@ CompletableFuture getToken( String code ); + /** + * + * @param userId the String value + * @param connectionName the String value + * @param channelId the String value + * @param exchangeRequest a TokenExchangeRequest + * @throws IllegalArgumentException thrown if parameters fail the validation + * @return the observable to the TokenResponse object + */ + CompletableFuture exchangeToken( + String userId, + String connectionName, + String channelId, + TokenExchangeRequest exchangeRequest + ); + /** * * @param userId the String value diff --git a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/rest/RestBotSignIn.java b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/rest/RestBotSignIn.java index 742b48623..2f19d4145 100644 --- a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/rest/RestBotSignIn.java +++ b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/rest/RestBotSignIn.java @@ -3,21 +3,23 @@ * Licensed under the MIT License. See License.txt in the project root for * license information. */ - package com.microsoft.bot.connector.rest; +import com.google.common.reflect.TypeToken; import com.microsoft.bot.connector.Async; import retrofit2.Retrofit; import com.microsoft.bot.connector.BotSignIn; import com.microsoft.bot.restclient.ServiceResponse; -import java.util.concurrent.CompletableFuture; +import com.microsoft.bot.schema.SignInResource; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.util.concurrent.CompletableFuture; import okhttp3.ResponseBody; import retrofit2.http.GET; import retrofit2.http.Headers; import retrofit2.http.Query; import retrofit2.Response; - /** * An instance of this class provides access to all the operations defined in * BotSignIns. @@ -28,7 +30,6 @@ public class RestBotSignIn implements BotSignIn { private BotSignInsService service; /** The service client containing this operation class. */ private RestOAuthClient client; - /** * Initializes an instance of BotSignInsImpl. * @@ -40,7 +41,6 @@ public RestBotSignIn(Retrofit withRetrofit, RestOAuthClient withClient) { this.service = withRetrofit.create(BotSignInsService.class); this.client = withClient; } - /** * The interface defining all the services for BotSignIns to be used by Retrofit * to perform actually REST calls. @@ -56,8 +56,16 @@ CompletableFuture> getSignInUrl( @Query("emulatorUrl") String emulatorUrl, @Query("finalRedirect") String finalRedirect ); + @Headers({"Content-Type: application/json; charset=utf-8", + "x-ms-logging-context: com.microsoft.bot.schema.BotSignIns GetSignInResource"}) + @GET("api/botsignin/GetSignInResource") + CompletableFuture> getSignInResource( + @Query("state") String state, + @Query("code_challenge") String codeChallenge, + @Query("emulatorUrl") String emulatorUrl, + @Query("finalRedirect") String finalRedirect + ); } - /** * * @param state the String value @@ -70,7 +78,6 @@ public CompletableFuture getSignInUrl(String state) { "Parameter state is required and cannot be null." )); } - final String codeChallenge = null; final String emulatorUrl = null; final String finalRedirect = null; @@ -85,7 +92,6 @@ public CompletableFuture getSignInUrl(String state) { } }); } - /** * * @param state the String value @@ -106,7 +112,6 @@ public CompletableFuture getSignInUrl( "Parameter state is required and cannot be null." )); } - return service.getSignInUrl(state, codeChallenge, emulatorUrl, finalRedirect) .thenApply(responseBodyResponse -> { try { @@ -118,14 +123,87 @@ public CompletableFuture getSignInUrl( } }); } - private ServiceResponse getSignInUrlDelegate( Response response ) throws ErrorResponseException, IllegalArgumentException { if (!response.isSuccessful()) { throw new ErrorResponseException("getSignInUrl", response); } - return new ServiceResponse<>(response.body().source().buffer().readUtf8(), response); } + /** + * + * @param state the String value + * @throws IllegalArgumentException thrown if parameters fail the validation + * @return the observable to the String object + */ + public CompletableFuture getSignInResource(String state) { + if (state == null) { + return Async.completeExceptionally(new IllegalArgumentException( + "Parameter state is required and cannot be null." + )); + } + final String codeChallenge = null; + final String emulatorUrl = null; + final String finalRedirect = null; + return service.getSignInResource(state, codeChallenge, emulatorUrl, finalRedirect) + .thenApply(responseBodyResponse -> { + try { + return getSignInResourceDelegate(responseBodyResponse).body(); + } catch (ErrorResponseException e) { + throw e; + } catch (Throwable t) { + throw new ErrorResponseException("getSignInResource", responseBodyResponse); + } + }); + } + /** + * + * @param state the String value + * @param codeChallenge the String value + * @param emulatorUrl the String value + * @param finalRedirect the String value + * @throws IllegalArgumentException thrown if parameters fail the validation + * @return the observable to the String object + */ + public CompletableFuture getSignInResource( + String state, + String codeChallenge, + String emulatorUrl, + String finalRedirect + ) { + if (state == null) { + return Async.completeExceptionally(new IllegalArgumentException( + "Parameter state is required and cannot be null." + )); + } + return service.getSignInResource(state, codeChallenge, emulatorUrl, finalRedirect) + .thenApply(responseBodyResponse -> { + try { + return getSignInResourceDelegate(responseBodyResponse).body(); + } catch (ErrorResponseException e) { + throw e; + } catch (Throwable t) { + throw new ErrorResponseException("getSignInResource", responseBodyResponse); + } + }); + } + private ServiceResponse getSignInResourceDelegate( + Response response + ) throws ErrorResponseException, IllegalArgumentException, IOException { + if (!response.isSuccessful()) { + throw new ErrorResponseException("getSignInResource", response); + } + return this.client.restClient() + .responseBuilderFactory() + .newInstance(client.serializerAdapter()) + .register(HttpURLConnection.HTTP_OK, new TypeToken() { + }.getType()) + .register(HttpURLConnection.HTTP_MOVED_PERM, new TypeToken() { + }.getType()) + .register(HttpURLConnection.HTTP_MOVED_TEMP, new TypeToken() { + }.getType()) + .registerError(ErrorResponseException.class) + .build(response); + } } diff --git a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/rest/RestUserToken.java b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/rest/RestUserToken.java index 40821b4a0..bb7c7c27c 100644 --- a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/rest/RestUserToken.java +++ b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/rest/RestUserToken.java @@ -11,6 +11,7 @@ import com.microsoft.bot.connector.UserToken; import com.google.common.reflect.TypeToken; import com.microsoft.bot.schema.AadResourceUrls; +import com.microsoft.bot.schema.TokenExchangeRequest; import com.microsoft.bot.schema.TokenResponse; import com.microsoft.bot.schema.TokenStatus; import com.microsoft.bot.restclient.ServiceResponse; @@ -68,6 +69,17 @@ CompletableFuture> getToken( @Query("code") String code ); + @Headers({ "Content-Type: application/json; charset=utf-8", + "x-ms-logging-context: com.microsoft.bot.schema.UserTokens exchangeToken" }) + @GET("api/usertoken/GetToken") + CompletableFuture> exchangeToken( + @Query("userId") String userId, + @Query("connectionName") String connectionName, + @Query("channelId") String channelId, + @Body TokenExchangeRequest exchangeRequest + ); + + @Headers({ "Content-Type: application/json; charset=utf-8", "x-ms-logging-context: com.microsoft.bot.schema.UserTokens getAadTokens" }) @POST("api/usertoken/GetAadTokens") @@ -191,6 +203,71 @@ private ServiceResponse getTokenDelegate( .build(response); } + /** + * + * @param userId the String value + * @param connectionName the String value + * @param channelId the String value + * @param exchangeRequest a TokenExchangeRequest + * @throws IllegalArgumentException thrown if parameters fail the validation + * @return the observable to the TokenResponse object + */ + @Override + public CompletableFuture exchangeToken( + String userId, + String connectionName, + String channelId, + TokenExchangeRequest exchangeRequest + ) { + if (userId == null) { + return Async.completeExceptionally(new IllegalArgumentException( + "Parameter userId is required and cannot be null." + )); + } + if (connectionName == null) { + return Async.completeExceptionally(new IllegalArgumentException( + "Parameter connectionName is required and cannot be null." + )); + } + if (channelId == null) { + return Async.completeExceptionally(new IllegalArgumentException( + "Parameter channelId is required and cannot be null." + )); + } + if (exchangeRequest == null) { + return Async.completeExceptionally(new IllegalArgumentException( + "Parameter exchangeRequest is required and cannot be null." + )); + } + + return service.exchangeToken(userId, connectionName, channelId, exchangeRequest) + .thenApply(responseBodyResponse -> { + try { + return exchangeTokenDelegate(responseBodyResponse).body(); + } catch (ErrorResponseException e) { + throw e; + } catch (Throwable t) { + throw new ErrorResponseException("getToken", responseBodyResponse); + } + }); + } + + private ServiceResponse exchangeTokenDelegate( + Response response + ) throws ErrorResponseException, IOException, IllegalArgumentException { + + return this.client.restClient() + .responseBuilderFactory() + .newInstance(this.client.serializerAdapter()) + + .register(HttpURLConnection.HTTP_OK, new TypeToken() { + }.getType()) + .register(HttpURLConnection.HTTP_NOT_FOUND, new TypeToken() { + }.getType()) + .registerError(ErrorResponseException.class) + .build(response); + } + /** * * @param userId the String value diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/OAuthPrompt.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/OAuthPrompt.java new file mode 100644 index 000000000..d6192c74e --- /dev/null +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/OAuthPrompt.java @@ -0,0 +1,761 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.dialogs.prompts; + +import java.net.HttpURLConnection; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.microsoft.bot.builder.BotAdapter; +import com.microsoft.bot.builder.BotAssert; +import com.microsoft.bot.builder.ConnectorClientBuilder; +import com.microsoft.bot.builder.InvokeResponse; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.builder.TurnStateConstants; +import com.microsoft.bot.builder.UserTokenProvider; +import com.microsoft.bot.connector.Async; +import com.microsoft.bot.connector.Channels; +import com.microsoft.bot.connector.ConnectorClient; +import com.microsoft.bot.connector.authentication.ClaimsIdentity; +import com.microsoft.bot.connector.authentication.JwtTokenValidation; +import com.microsoft.bot.connector.authentication.SkillValidation; +import com.microsoft.bot.dialogs.Dialog; +import com.microsoft.bot.dialogs.DialogContext; +import com.microsoft.bot.dialogs.DialogTurnResult; +import com.microsoft.bot.schema.ActionTypes; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.ActivityTypes; +import com.microsoft.bot.schema.Attachment; +import com.microsoft.bot.schema.CardAction; +import com.microsoft.bot.schema.InputHints; +import com.microsoft.bot.schema.OAuthCard; +import com.microsoft.bot.schema.SignInConstants; +import com.microsoft.bot.schema.SignInResource; +import com.microsoft.bot.schema.SigninCard; +import com.microsoft.bot.schema.TokenExchangeInvokeRequest; +import com.microsoft.bot.schema.TokenExchangeInvokeResponse; +import com.microsoft.bot.schema.TokenExchangeRequest; +import com.microsoft.bot.schema.TokenResponse; + +import org.apache.commons.lang3.StringUtils; + +/** + * Creates a new prompt that asks the user to sign in using the Bot Frameworks + * Single Sign On (SSO)service. + * + * The prompt will attempt to retrieve the users current token and if the user + * isn't signed in, itwill send them an `OAuthCard` containing a button they can + * press to signin. Depending on thechannel, the user will be sent through one + * of two possible signin flows:- The automatic signin flow where once the user + * signs in and the SSO service will forward the botthe users access token using + * either an `event` or `invoke` activity.- The "magic code" flow where once the + * user signs in they will be prompted by the SSOservice to send the bot a six + * digit code confirming their identity. This code will be sent as astandard + * `message` activity. Both flows are automatically supported by the + * `OAuthPrompt` and the only thing you need to becareful of is that you don't + * block the `event` and `invoke` activities that the prompt mightbe waiting on. + * **Note**:You should avoid persisting the access token with your bots other + * state. The Bot FrameworksSSO service will securely store the token on your + * behalf. If you store it in your bots stateit could expire or be revoked in + * between turns. When calling the prompt from within a waterfall step you + * should use the token within the stepfollowing the prompt and then let the + * token go out of scope at the end of your function. + */ +public class OAuthPrompt extends Dialog { + + private static final String PERSISTED_OPTIONS = "options"; + private static final String PERSISTED_STATE = "state"; + private static final String PERSISTED_EXPIRES = "expires"; + private static final String PERSISTED_CALLER = "caller"; + + private final OAuthPromptSettings settings; + private final PromptValidator validator; + + /** + * Initializes a new instance of the {@link OAuthPrompt} class. + * + * @param dialogId The D to assign to this prompt. + * @param settings Additional OAuth settings to use with this instance of the + * prompt. + * + * The value of {@link dialogId} must be unique within the + * {@link DialogSet} or {@link ComponentDialog} to which the + * prompt is added. + */ + public OAuthPrompt(String dialogId, OAuthPromptSettings settings) { + this(dialogId, settings, null); + } + + /** + * Initializes a new instance of the {@link OAuthPrompt} class. + * + * @param dialogId The D to assign to this prompt. + * @param settings Additional OAuth settings to use with this instance of the + * prompt. + * @param validator Optional, a {@link PromptValidator{FoundChoice}} that + * contains additional, custom validation for this prompt. + * + * The value of {@link dialogId} must be unique within the + * {@link DialogSet} or {@link ComponentDialog} to which the + * prompt is added. + */ + public OAuthPrompt(String dialogId, OAuthPromptSettings settings, PromptValidator validator) { + super(dialogId); + + if (StringUtils.isEmpty(dialogId)) { + throw new IllegalArgumentException("dialogId cannot be null."); + } + + if (settings == null) { + throw new IllegalArgumentException("settings cannot be null."); + } + + this.settings = settings; + this.validator = validator; + } + + /** + * Shared implementation of the SendOAuthCard function. This is intended for + * internal use, to consolidate the implementation of the OAuthPrompt and + * OAuthInput. Application logic should use those dialog classes. + * + * @param settings OAuthSettings. + * @param turnContext TurnContext. + * @param prompt MessageActivity. + * + * @return A {@link CompletableFuture} representing the result of the hronous + * operation. + */ + public static CompletableFuture sendOAuthCard(OAuthPromptSettings settings, TurnContext turnContext, + Activity prompt) { + BotAssert.contextNotNull(turnContext); + + BotAdapter adapter = turnContext.getAdapter(); + + if (!(adapter instanceof UserTokenProvider)) { + return Async.completeExceptionally( + new UnsupportedOperationException("OAuthPrompt.Prompt(): not supported by the current adapter")); + } + + UserTokenProvider tokenAdapter = (UserTokenProvider) adapter; + + // Ensure prompt initialized + if (prompt == null) { + prompt = Activity.createMessageActivity(); + } + + if (prompt.getAttachments() == null) { + prompt.setAttachments(new ArrayList<>()); + } + + // Append appropriate card if missing + if (!channelSupportsOAuthCard(turnContext.getActivity().getChannelId())) { + if (!prompt.getAttachments().stream().anyMatch(s -> s.getContent() instanceof SigninCard)) { + SignInResource signInResource = tokenAdapter + .getSignInResource(turnContext, settings.getOAuthAppCredentials(), settings.getConnectionName(), + turnContext.getActivity().getFrom().getId(), null) + .join(); + + CardAction cardAction = new CardAction(); + cardAction.setTitle(settings.getTitle()); + cardAction.setValue(signInResource.getSignInLink()); + cardAction.setType(ActionTypes.SIGNIN); + + ArrayList cardList = new ArrayList(); + cardList.add(cardAction); + + SigninCard signInCard = new SigninCard(); + signInCard.setText(settings.getText()); + signInCard.setButtons(cardList); + + Attachment attachment = new Attachment(); + attachment.setContentType(SigninCard.CONTENTTYPE); + attachment.setContent(signInCard); + + prompt.getAttachments().add(attachment); + + } + } else if (!prompt.getAttachments().stream().anyMatch(s -> s.getContent() instanceof OAuthCard)) { + ActionTypes cardActionType = ActionTypes.SIGNIN; + SignInResource signInResource = tokenAdapter + .getSignInResource(turnContext, settings.getOAuthAppCredentials(), settings.getConnectionName(), + turnContext.getActivity().getFrom().getId(), null) + .join(); + String value = signInResource.getSignInLink(); + + // use the SignInLink when + // in speech channel or + // bot is a skill or + // an extra OAuthAppCredentials is being passed in + ClaimsIdentity botIdentity = turnContext.getTurnState().get(BotAdapter.BOT_IDENTITY_KEY); + if (turnContext.getActivity().isFromStreamingConnection() + || botIdentity != null && SkillValidation.isSkillClaim(botIdentity.claims()) + || settings.getOAuthAppCredentials() != null) { + if (turnContext.getActivity().getChannelId().equals(Channels.EMULATOR)) { + cardActionType = ActionTypes.OPEN_URL; + } + } else if (!channelRequiresSignInLink(turnContext.getActivity().getChannelId())) { + value = null; + } + + CardAction cardAction = new CardAction(); + cardAction.setTitle(settings.getTitle()); + cardAction.setText(settings.getText()); + cardAction.setType(cardActionType); + cardAction.setValue(value); + + ArrayList cardList = new ArrayList(); + cardList.add(cardAction); + + OAuthCard oAuthCard = new OAuthCard(); + oAuthCard.setText(settings.getText()); + oAuthCard.setButtons(cardList); + oAuthCard.setConnectionName(settings.getConnectionName()); + oAuthCard.setTokenExchangeResource(signInResource.getTokenExchangeResource()); + + Attachment attachment = new Attachment(); + attachment.setContentType(OAuthCard.CONTENTTYPE); + attachment.setContent(oAuthCard); + + prompt.getAttachments().add(attachment); + } + + // Add the login timeout specified in OAuthPromptSettings to TurnState so it can + // be referenced if polling is needed + if (!turnContext.getTurnState().containsKey(TurnStateConstants.OAUTH_LOGIN_TIMEOUT_KEY) + && settings.getTimeout() != null) { + turnContext.getTurnState().add(TurnStateConstants.OAUTH_LOGIN_TIMEOUT_KEY, + Duration.ofMillis(settings.getTimeout())); + } + + // Set input hint + if (prompt.getInputHint() == null) { + prompt.setInputHint(InputHints.ACCEPTING_INPUT); + } + + return turnContext.sendActivity(prompt).thenApply(result -> null); + } + + /** + * Shared implementation of the RecognizeToken function. This is intended for internal use, to + * consolidate the implementation of the OAuthPrompt and OAuthInput. Application logic should + * use those dialog classes. + * + * @param settings OAuthPromptSettings. + * @param dc DialogContext. + * + * @return PromptRecognizerResult. + */ + @SuppressWarnings({"checkstyle:MethodLength", "PMD.EmptyCatchBlock"}) + public static CompletableFuture> recognizeToken( + OAuthPromptSettings settings, + DialogContext dc) { + TurnContext turnContext = dc.getContext(); + PromptRecognizerResult result = new PromptRecognizerResult(); + if (isTokenResponseEvent(turnContext)) { + Object tokenResponseObject = turnContext.getActivity().getValue(); + TokenResponse token = null; + if (tokenResponseObject != null) { + token = (TokenResponse) tokenResponseObject; + } + result.setSucceeded(true); + result.setValue(token); + + // fixup the turnContext's state context if this was received from a skill host caller + CallerInfo callerInfo = (CallerInfo) dc.getActiveDialog().getState().get(PERSISTED_CALLER); + if (callerInfo != null) { + // set the ServiceUrl to the skill host's Url + dc.getContext().getActivity().setServiceUrl(callerInfo.getCallerServiceUrl()); + + Object adapter = turnContext.getAdapter(); + // recreate a ConnectorClient and set it in TurnState so replies use the correct one + if (!(adapter instanceof ConnectorClientBuilder)) { + return Async.completeExceptionally( + new UnsupportedOperationException( + "OAuthPrompt: ConnectorClientProvider interface not implemented by the current adapter" + )); + } + + ConnectorClientBuilder connectorClientProvider = (ConnectorClientBuilder) adapter; + ClaimsIdentity claimsIdentity = turnContext.getTurnState().get(BotAdapter.BOT_IDENTITY_KEY); + ConnectorClient connectorClient = connectorClientProvider.createConnectorClient( + dc.getContext().getActivity().getServiceUrl(), + claimsIdentity, + callerInfo.getScope()).join(); + + if (turnContext.getTurnState().get(ConnectorClient.class) != null) { + turnContext.getTurnState().replace(connectorClient); + } else { + turnContext.getTurnState().add(connectorClient); + } + } + } else if (isTeamsVerificationInvoke(turnContext)) { + String magicCode = (String) turnContext.getActivity().getValue(); + //var magicCode = magicCodeObject.GetValue("state", StringComparison.Ordinal)?.toString(); + + Object adapterObject = turnContext.getAdapter(); + if (!(adapterObject instanceof UserTokenProvider)) { + return Async.completeExceptionally( + new UnsupportedOperationException( + "OAuthPrompt.Recognize(): not supported by the current adapter" + )); + } + + UserTokenProvider adapter = (UserTokenProvider) adapterObject; + + // Getting the token follows a different flow in Teams. At the signin completion, Teams + // will send the bot an "invoke" activity that contains a "magic" code. This code MUST + // then be used to try fetching the token from Botframework service within some time + // period. We try here. If it succeeds, we return 200 with an empty body. If it fails + // with a retriable error, we return 500. Teams will re-send another invoke in this case. + // If it fails with a non-retriable error, we return 404. Teams will not (still work in + // progress) retry in that case. + try { + TokenResponse token = adapter.getUserToken( + turnContext, + settings.getOAuthAppCredentials(), + settings.getConnectionName(), + magicCode).join(); + + if (token != null) { + result.setSucceeded(true); + result.setValue(token); + + turnContext.sendActivity(new Activity(ActivityTypes.INVOKE_RESPONSE)); + } else { + sendInvokeResponse(turnContext, HttpURLConnection.HTTP_NOT_FOUND, null); + } + } catch (Exception e) { + sendInvokeResponse(turnContext, HttpURLConnection.HTTP_INTERNAL_ERROR, null); + } + } else if (isTokenExchangeRequestInvoke(turnContext)) { + TokenExchangeInvokeRequest tokenExchangeRequest = + turnContext.getActivity().getValue() instanceof TokenExchangeInvokeRequest + ? (TokenExchangeInvokeRequest) turnContext.getActivity().getValue() : null; + + if (tokenExchangeRequest == null) { + TokenExchangeInvokeResponse response = new TokenExchangeInvokeResponse(); + response.setId(null); + response.setConnectionName(settings.getConnectionName()); + response.setFailureDetail("The bot received an InvokeActivity that is missing a " + + "TokenExchangeInvokeRequest value. This is required to be " + + "sent with the InvokeActivity."); + sendInvokeResponse(turnContext, HttpURLConnection.HTTP_BAD_REQUEST, response).join(); + } else if (tokenExchangeRequest.getConnectionName() != settings.getConnectionName()) { + TokenExchangeInvokeResponse response = new TokenExchangeInvokeResponse(); + response.setId(tokenExchangeRequest.getId()); + response.setConnectionName(settings.getConnectionName()); + response.setFailureDetail("The bot received an InvokeActivity with a " + + "TokenExchangeInvokeRequest containing a ConnectionName that does not match the " + + "ConnectionName expected by the bot's active OAuthPrompt. Ensure these names match " + + "when sending the InvokeActivityInvalid ConnectionName in the " + + "TokenExchangeInvokeRequest"); + sendInvokeResponse(turnContext, HttpURLConnection.HTTP_BAD_REQUEST, response).join(); + } else if (!(turnContext.getAdapter() instanceof UserTokenProvider)) { + TokenExchangeInvokeResponse response = new TokenExchangeInvokeResponse(); + response.setId(tokenExchangeRequest.getId()); + response.setConnectionName(settings.getConnectionName()); + response.setFailureDetail("The bot's BotAdapter does not support token exchange " + + "operations. Ensure the bot's Adapter supports the UserTokenProvider interface."); + + sendInvokeResponse(turnContext, HttpURLConnection.HTTP_BAD_REQUEST, response).join(); + return Async.completeExceptionally( + new UnsupportedOperationException( + "OAuthPrompt.Recognize(): not supported by the current adapter" + )); + } else { + TokenResponse tokenExchangeResponse = null; + try { + UserTokenProvider adapter = (UserTokenProvider) turnContext.getAdapter(); + TokenExchangeRequest tokenExchangeReq = new TokenExchangeRequest(); + tokenExchangeReq.setToken(tokenExchangeRequest.getToken()); + tokenExchangeResponse = adapter.exchangeToken( + turnContext, + settings.getConnectionName(), + turnContext.getActivity().getFrom().getId(), + tokenExchangeReq).join(); + } catch (Exception ex) { + // Ignore Exceptions + // If token exchange failed for any reason, tokenExchangeResponse above stays null, and + // hence we send back a failure invoke response to the caller. + // This ensures that the caller shows + } + + if (tokenExchangeResponse == null || StringUtils.isBlank(tokenExchangeResponse.getToken())) { + TokenExchangeInvokeResponse tokenEIR = new TokenExchangeInvokeResponse(); + tokenEIR.setId(tokenExchangeRequest.getId()); + tokenEIR.setConnectionName(tokenExchangeRequest.getConnectionName()); + tokenEIR.setFailureDetail("The bot is unable to exchange token. Proceed with regular login."); + sendInvokeResponse(turnContext, HttpURLConnection.HTTP_PRECON_FAILED, tokenEIR).join(); + } else { + TokenExchangeInvokeResponse tokenEIR = new TokenExchangeInvokeResponse(); + tokenEIR.setId(tokenExchangeRequest.getId()); + tokenEIR.setConnectionName(settings.getConnectionName()); + sendInvokeResponse(turnContext, HttpURLConnection.HTTP_OK, tokenEIR); + + result.setSucceeded(true); + TokenResponse finalResponse = tokenExchangeResponse; + result.setValue(new TokenResponse() { { + setChannelId(finalResponse.getChannelId()); + setConnectionName(finalResponse.getConnectionName()); + setToken(finalResponse.getToken()); + }}); + } + } + } else if (turnContext.getActivity().getType().equals(ActivityTypes.MESSAGE)) { + // regex to check if code supplied is a 6 digit numerical code (hence, a magic code). + String pattern = "(\\d{6})"; + Pattern r = Pattern.compile(pattern); + Matcher m = r.matcher(turnContext.getActivity().getText()); + + if (m.find()) { + if (!(turnContext.getAdapter() instanceof UserTokenProvider)) { + return Async.completeExceptionally( + new UnsupportedOperationException( + "OAuthPrompt.Recognize(): not supported by the current adapter" + )); + } + UserTokenProvider adapter = (UserTokenProvider) turnContext.getAdapter(); + TokenResponse token = adapter.getUserToken(turnContext, + settings.getOAuthAppCredentials(), + settings.getConnectionName(), + m.group(0)).join(); + if (token != null) { + result.setSucceeded(true); + result.setValue(token); + } + } + } + + return CompletableFuture.completedFuture(result); + } + + /** + * Shared implementation of the SetCallerInfoInDialogState function. This is + * intended for internal use, to consolidate the implementation of the + * OAuthPrompt and OAuthInput. Application logic should use those dialog + * classes. + * + * @param state The dialog state. + * @param context TurnContext. + */ + public static void setCallerInfoInDialogState(Map state, TurnContext context) { + state.put(PERSISTED_CALLER, createCallerInfo(context)); + } + + /** + * Called when a prompt dialog is pushed onto the dialog stack and is being activated. + * + * @param dc The dialog context for the current turn of the conversation. + * @param options Optional, additional information to pass to the prompt being started. + * + * @return A {@link CompletableFuture} representing the hronous operation. + * + * If the task is successful, the result indicates whether the prompt is still active after the + * turn has been processed by the prompt. + */ + @Override + public CompletableFuture beginDialog(DialogContext dc, Object options) { + if (dc == null) { + return Async.completeExceptionally( + new IllegalArgumentException( + "dc cannot be null." + )); + } + + if (options != null && !(options instanceof PromptOptions)) { + return Async.completeExceptionally( + new IllegalArgumentException( + "Parameter options should be an instance of to PromptOptions if provided." + )); + } + + PromptOptions opt = (PromptOptions) options; + if (opt != null) { + // Ensure prompts have input hint set + if (opt.getPrompt() != null && opt.getPrompt().getInputHint() == null) { + opt.getPrompt().setInputHint(InputHints.ACCEPTING_INPUT); + } + + if (opt.getRetryPrompt() != null && opt.getRetryPrompt() == null) { + opt.getRetryPrompt().setInputHint(InputHints.ACCEPTING_INPUT); + } + } + + // Initialize state + int timeout = settings.getTimeout() != null ? settings.getTimeout() + : (int) TurnStateConstants.OAUTH_LOGIN_TIMEOUT_VALUE.toMillis(); + Map state = dc.getActiveDialog().getState(); + state.put(PERSISTED_OPTIONS, opt); + HashMap hMap = new HashMap(); + hMap.put(Prompt.ATTEMPTCOUNTKEY, 0); + state.put(PERSISTED_STATE, hMap); + + state.put(PERSISTED_EXPIRES, OffsetDateTime.now(ZoneId.of("UTC")).plus(timeout, ChronoUnit.MILLIS)); + setCallerInfoInDialogState(state, dc.getContext()); + + // Attempt to get the users token + if (!(dc.getContext().getAdapter() instanceof UserTokenProvider)) { + return Async.completeExceptionally( + new UnsupportedOperationException( + "OAuthPrompt.Recognize(): not supported by the current adapter" + )); + } + + UserTokenProvider adapter = (UserTokenProvider) dc.getContext().getAdapter(); + TokenResponse output = adapter.getUserToken(dc.getContext(), + settings.getOAuthAppCredentials(), + settings.getConnectionName(), + null).join(); + if (output != null) { + // Return token + return dc.endDialog(output); + } + + // Prompt user to login + sendOAuthCard(settings, dc.getContext(), opt != null ? opt.getPrompt() : null).join(); + return CompletableFuture.completedFuture(END_OF_TURN); + } + + /** + * Called when a prompt dialog is the active dialog and the user replied with a new activity. + * + * @param dc The dialog context for the current turn of conversation. + * + * @return A {@link CompletableFuture} representing the hronous operation. + * + * If the task is successful, the result indicates whether the dialog is still active after the + * turn has been processed by the dialog. The prompt generally continues to receive the user's + * replies until it accepts the user's reply as valid input for the prompt. + */ + @Override + public CompletableFuture continueDialog(DialogContext dc) { + if (dc == null) { + return Async.completeExceptionally( + new IllegalArgumentException( + "dc cannot be null." + )); + } + + // Check for timeout + Map state = dc.getActiveDialog().getState(); + OffsetDateTime expires = (OffsetDateTime) state.get(PERSISTED_EXPIRES); + boolean isMessage = dc.getContext().getActivity().getType().equals(ActivityTypes.MESSAGE); + + // If the incoming Activity is a message, or an Activity Type normally handled by OAuthPrompt, + // check to see if this OAuthPrompt Expiration has elapsed, and end the dialog if so. + boolean isTimeoutActivityType = isMessage + || isTokenResponseEvent(dc.getContext()) + || isTeamsVerificationInvoke(dc.getContext()) + || isTokenExchangeRequestInvoke(dc.getContext()); + + + boolean hasTimedOut = isTimeoutActivityType && OffsetDateTime.now(ZoneId.of("UTC")).compareTo(expires) > 0; + + if (hasTimedOut) { + // if the token fetch request times out, complete the prompt with no result. + return dc.endDialog(); + } + + // Recognize token + PromptRecognizerResult recognized = recognizeToken(settings, dc).join(); + + Map promptState = (Map) state.get(PERSISTED_STATE); + PromptOptions promptOptions = (PromptOptions) state.get(PERSISTED_OPTIONS); + + // Increment attempt count + // Convert.ToInt32 For issue https://github.com/Microsoft/botbuilder-dotnet/issues/1859 + promptState.put(Prompt.ATTEMPTCOUNTKEY, (int) promptState.get(Prompt.ATTEMPTCOUNTKEY) + 1); + + // Validate the return value + boolean isValid = false; + if (validator != null) { + PromptValidatorContext promptContext = new PromptValidatorContext( + dc.getContext(), + recognized, + promptState, + promptOptions); + isValid = validator.promptValidator(promptContext).join(); + } else if (recognized.getSucceeded()) { + isValid = true; + } + + // Return recognized value or re-prompt + if (isValid) { + return dc.endDialog(recognized.getValue()); + } else if (isMessage && settings.getEndOnInvalidMessage()) { + // If EndOnInvalidMessage is set, complete the prompt with no result. + return dc.endDialog(); + } + + if (!dc.getContext().getResponded() + && isMessage + && promptOptions != null + && promptOptions.getRetryPrompt() != null) { + dc.getContext().sendActivity(promptOptions.getRetryPrompt()); + } + + return CompletableFuture.completedFuture(END_OF_TURN); + } + + /** + * Attempts to get the user's token. + * + * @param turnContext Context for the current turn of conversation with the + * user. + * + * @return A task that represents the work queued to execute. + * + * If the task is successful and user already has a token or the user + * successfully signs in, the result contains the user's token. + */ + public CompletableFuture getUserToken(TurnContext turnContext) { + if (!(turnContext.getAdapter() instanceof UserTokenProvider)) { + return Async.completeExceptionally( + new UnsupportedOperationException( + "OAuthPrompt.GetUserToken(): not supported by the current adapter" + )); + } + return ((UserTokenProvider) turnContext.getAdapter()).getUserToken(turnContext, + settings.getOAuthAppCredentials(), + settings.getConnectionName(), null); + } + + /** + * Signs out the user. + * + * @param turnContext Context for the current turn of conversation with the user. + * + * @return A task that represents the work queued to execute. + */ + public CompletableFuture signOutUser(TurnContext turnContext) { + if (!(turnContext.getAdapter() instanceof UserTokenProvider)) { + return Async.completeExceptionally( + new UnsupportedOperationException( + "OAuthPrompt.SignOutUser(): not supported by the current adapter" + )); + } + String id = ""; + if (turnContext.getActivity() != null + && turnContext.getActivity() != null + && turnContext.getActivity().getFrom() != null) { + id = turnContext.getActivity().getFrom().getId(); + } + + // Sign out user + return ((UserTokenProvider) turnContext.getAdapter()).signOutUser(turnContext, + settings.getOAuthAppCredentials(), + settings.getConnectionName(), + id); + } + + private static CallerInfo createCallerInfo(TurnContext turnContext) { + ClaimsIdentity botIdentity = + turnContext.getTurnState().get(BotAdapter.BOT_IDENTITY_KEY) instanceof ClaimsIdentity + ? (ClaimsIdentity) turnContext.getTurnState().get(BotAdapter.BOT_IDENTITY_KEY) + : null; + + if (botIdentity != null && SkillValidation.isSkillClaim(botIdentity.claims())) { + CallerInfo callerInfo = new CallerInfo(); + callerInfo.setCallerServiceUrl(turnContext.getActivity().getServiceUrl()); + callerInfo.setScope(JwtTokenValidation.getAppIdFromClaims(botIdentity.claims())); + + return callerInfo; + } + + return null; + } + + private static boolean isTokenResponseEvent(TurnContext turnContext) { + Activity activity = turnContext.getActivity(); + return activity.getType().equals(ActivityTypes.EVENT) + && activity.getName().equals(SignInConstants.TOKEN_RESPONSE_EVENT_NAME); + } + + private static boolean isTeamsVerificationInvoke(TurnContext turnContext) { + Activity activity = turnContext.getActivity(); + return activity.getType().equals(ActivityTypes.INVOKE) + && activity.getName().equals(SignInConstants.VERIFY_STATE_OPERATION_NAME); + } + + private static boolean isTokenExchangeRequestInvoke(TurnContext turnContext) { + Activity activity = turnContext.getActivity(); + return activity.getType().equals(ActivityTypes.INVOKE) + && activity.getName().equals(SignInConstants.TOKEN_EXCHANGE_OPERATION_NAME); + } + + private static boolean channelSupportsOAuthCard(String channelId) { + switch (channelId) { + case Channels.CORTANA: + case Channels.SKYPE: + case Channels.SKYPEFORBUSINESS: + return false; + default: + return true; + } + } + + private static boolean channelRequiresSignInLink(String channelId) { + switch (channelId) { + case Channels.MSTEAMS: + return true; + default: + return false; + } + } + + private static CompletableFuture sendInvokeResponse(TurnContext turnContext, int statusCode, + Object body) { + Activity activity = new Activity(ActivityTypes.INVOKE_RESPONSE); + activity.setValue(new InvokeResponse(statusCode, body)); + return turnContext.sendActivity(activity).thenApply(result -> null); + } + + /** + * Class to contain CallerInfo data including callerServiceUrl and scope. + */ + private static class CallerInfo { + + private String callerServiceUrl; + + private String scope; + + /** + * @return the CallerServiceUrl value as a String. + */ + public String getCallerServiceUrl() { + return this.callerServiceUrl; + } + + /** + * @param withCallerServiceUrl The CallerServiceUrl value. + */ + public void setCallerServiceUrl(String withCallerServiceUrl) { + this.callerServiceUrl = withCallerServiceUrl; + } + /** + * @return the Scope value as a String. + */ + public String getScope() { + return this.scope; + } + + /** + * @param withScope The Scope value. + */ + public void setScope(String withScope) { + this.scope = withScope; + } + + } +} + diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/OAuthPromptSettings.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/OAuthPromptSettings.java new file mode 100644 index 000000000..91e2ff436 --- /dev/null +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/OAuthPromptSettings.java @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.dialogs.prompts; + +import com.microsoft.bot.connector.authentication.AppCredentials; + +/** + * Contains settings for an {@link OAuthPrompt}/>. + */ +public class OAuthPromptSettings { + + private AppCredentials oAuthAppCredentials; + + private String connectionName; + + private String title; + + private String text; + + private Integer timeout; + + private boolean endOnInvalidMessage; + + /** + * Gets the OAuthAppCredentials for OAuthPrompt. + * + * @return the OAuthAppCredentials value as a AppCredentials. + */ + public AppCredentials getOAuthAppCredentials() { + return this.oAuthAppCredentials; + } + + /** + * Sets the OAuthAppCredentials for OAuthPrompt. + * + * @param withOAuthAppCredentials The OAuthAppCredentials value. + */ + public void setOAuthAppCredentials(AppCredentials withOAuthAppCredentials) { + this.oAuthAppCredentials = withOAuthAppCredentials; + } + + /** + * Gets the name of the OAuth connection. + * + * @return the ConnectionName value as a String. + */ + public String getConnectionName() { + return this.connectionName; + } + + /** + * Sets the name of the OAuth connection. + * + * @param withConnectionName The ConnectionName value. + */ + public void setConnectionName(String withConnectionName) { + this.connectionName = withConnectionName; + } + + /** + * Gets the title of the sign-in card. + * + * @return the Title value as a String. + */ + public String getTitle() { + return this.title; + } + + /** + * Sets the title of the sign-in card. + * + * @param withTitle The Title value. + */ + public void setTitle(String withTitle) { + this.title = withTitle; + } + + /** + * Gets any additional text to include in the sign-in card. + * + * @return the Text value as a String. + */ + public String getText() { + return this.text; + } + + /** + * Sets any additional text to include in the sign-in card. + * + * @param withText The Text value. + */ + public void setText(String withText) { + this.text = withText; + } + + /** + * Gets the number of milliseconds the prompt waits for the user to + * authenticate. Default is 900,000 (15 minutes). + * + * @return the Timeout value as a int?. + */ + public Integer getTimeout() { + return this.timeout; + } + + /** + * Sets the number of milliseconds the prompt waits for the user to + * authenticate. Default is 900,000 (15 minutes). + * + * @param withTimeout The Timeout value. + */ + public void setTimeout(Integer withTimeout) { + this.timeout = withTimeout; + } + + /** + * Gets a value indicating whether the {@link OAuthPrompt} should end upon + * receiving an invalid message. Generally the {@link OAuthPrompt} will ignore + * incoming messages from the user during the auth flow, if they are not related + * to the auth flow. This flag enables ending the {@link OAuthPrompt} rather + * than ignoring the user's message. Typically, this flag will be set to 'true', + * but is 'false' by default for backwards compatibility. + * + * @return the EndOnInvalidMessage value as a boolean. + */ + public boolean getEndOnInvalidMessage() { + return this.endOnInvalidMessage; + } + + /** + * Sets a value indicating whether the {@link OAuthPrompt} should end upon + * receiving an invalid message. Generally the {@link OAuthPrompt} will ignore + * incoming messages from the user during the auth flow, if they are not related + * to the auth flow. This flag enables ending the {@link OAuthPrompt} rather + * than ignoring the user's message. Typically, this flag will be set to 'true', + * but is 'false' by default for backwards compatibility. + * + * @param withEndOnInvalidMessage The EndOnInvalidMessage value. + */ + public void setEndOnInvalidMessage(boolean withEndOnInvalidMessage) { + this.endOnInvalidMessage = withEndOnInvalidMessage; + } + +} diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/Prompt.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/Prompt.java index 8188870f8..ee87f2967 100644 --- a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/Prompt.java +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/Prompt.java @@ -103,14 +103,12 @@ public CompletableFuture beginDialog(DialogContext dc, Object // Ensure prompts have input hint set PromptOptions opt = (PromptOptions) options; - if (opt.getPrompt() != null && (opt.getPrompt().getInputHint() == null - || StringUtils.isEmpty(opt.getPrompt().getInputHint().toString()))) { - opt.getPrompt().setInputHint(InputHints.EXPECTING_INPUT); + if (opt.getPrompt() != null && opt.getPrompt().getInputHint() == null) { + opt.getPrompt().setInputHint(InputHints.EXPECTING_INPUT); } - if (opt.getRetryPrompt() != null && (opt.getRetryPrompt().getInputHint() == null - || StringUtils.isEmpty(opt.getRetryPrompt().getInputHint().toString()))) { - opt.getRetryPrompt().setInputHint(InputHints.EXPECTING_INPUT); + if (opt.getRetryPrompt() != null && opt.getRetryPrompt().getInputHint() == null) { + opt.getRetryPrompt().setInputHint(InputHints.EXPECTING_INPUT); } diff --git a/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/prompts/OAuthPromptTests.java b/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/prompts/OAuthPromptTests.java new file mode 100644 index 000000000..8e07128c4 --- /dev/null +++ b/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/prompts/OAuthPromptTests.java @@ -0,0 +1,751 @@ +// Copyright (c) Microsoft Corporation. All rights reserved +// Licensed under the MT License + +package com.microsoft.bot.dialogs.prompts; + +import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import com.fasterxml.jackson.databind.JsonNode; +import com.microsoft.bot.builder.AutoSaveStateMiddleware; +import com.microsoft.bot.builder.BotCallbackHandler; +import com.microsoft.bot.builder.ConversationState; +import com.microsoft.bot.builder.InvokeResponse; +import com.microsoft.bot.builder.MemoryStorage; +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.builder.StatePropertyAccessor; +import com.microsoft.bot.builder.Storage; +import com.microsoft.bot.builder.TurnContextImpl; +import com.microsoft.bot.builder.adapters.TestAdapter; +import com.microsoft.bot.builder.adapters.TestFlow; +import com.microsoft.bot.connector.Channels; +import com.microsoft.bot.dialogs.DialogContext; +import com.microsoft.bot.dialogs.DialogSet; +import com.microsoft.bot.dialogs.DialogState; +import com.microsoft.bot.dialogs.DialogTurnResult; +import com.microsoft.bot.dialogs.DialogTurnStatus; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.ActivityTypes; +import com.microsoft.bot.schema.ChannelAccount; +import com.microsoft.bot.schema.ConversationAccount; +import com.microsoft.bot.schema.InputHints; +import com.microsoft.bot.schema.OAuthCard; +import com.microsoft.bot.schema.SignInConstants; +import com.microsoft.bot.schema.TokenExchangeInvokeRequest; +import com.microsoft.bot.schema.TokenExchangeInvokeResponse; +import com.microsoft.bot.schema.TokenExchangeRequest; +import com.microsoft.bot.schema.TokenResponse; + +import org.junit.Assert; +import org.junit.Test; + +public class OAuthPromptTests { + + @Test + public void OAuthPromptWithEmptySettingsShouldFail() { + Assert.assertThrows(IllegalArgumentException.class, () -> new OAuthPrompt("abc", null, null)); + } + + @Test + public void OAuthPromptWithEmptyIdShouldFail() { + Assert.assertThrows(IllegalArgumentException.class, () -> new OAuthPrompt("", new OAuthPromptSettings(), null)); + } + + @Test + public void OAuthPromptWithDefaultTypeHandlingForStorage() { + OAuthPrompt(new MemoryStorage()); + } + + @Test + public void OAuthPromptBeginDialogWithNoDialogContext() { + Assert.assertThrows(IllegalArgumentException.class, () -> { + try { + OAuthPrompt prompt = new OAuthPrompt("abc", new OAuthPromptSettings(), null); + prompt.beginDialog(null).join(); + } catch (CompletionException ex) { + throw ex.getCause(); + } + }); + } + + @Test + public void OAuthPromptBeginDialogWithWrongOptions() { + Assert.assertThrows(NullPointerException.class, () -> { + OAuthPrompt prompt = new OAuthPrompt("abc", new OAuthPromptSettings(), null); + ConversationState convoState = new ConversationState(new MemoryStorage()); + StatePropertyAccessor dialogState = convoState.createProperty("dialogState"); + + TestAdapter adapter = new TestAdapter().use(new AutoSaveStateMiddleware(convoState)); + + // Create new DialogSet. + DialogSet dialogs = new DialogSet(dialogState); + dialogs.add(prompt); + ConversationAccount conversation = new ConversationAccount(); + conversation.setId("123"); + TurnContextImpl tc = new TurnContextImpl(adapter, new Activity(ActivityTypes.MESSAGE) { + { + setConversation(conversation); + setChannelId("test"); + } + }); + + DialogContext dc = dialogs.createContext(tc).join(); + + prompt.beginDialog(dc).join(); + }); + } + + @Test + public void OAuthPromptWithNoneTypeHandlingForStorage() { + OAuthPrompt(new MemoryStorage(new HashMap())); + } + + @Test + public void OAuthPromptWithMagicCode() { + ConversationState convoState = new ConversationState(new MemoryStorage()); + StatePropertyAccessor dialogState = convoState.createProperty("dialogState"); + + TestAdapter adapter = new TestAdapter() + .use(new AutoSaveStateMiddleware(convoState)); + + String connectionName = "myConnection"; + String token = "abc123"; + String magicCode = "888999"; + + // Create new DialogSet. + DialogSet dialogs = new DialogSet(dialogState); + OAuthPromptSettings settings = new OAuthPromptSettings(); + settings.setText("Please sign in"); + settings.setConnectionName(connectionName); + settings.setTitle("Sign in"); + OAuthPrompt oAuthPrompt = new OAuthPrompt("OAuthPrompt", settings); + dialogs.add(oAuthPrompt); + + BotCallbackHandler botCallbackHandler = (turnContext) -> { + DialogContext dc = dialogs.createContext(turnContext).join(); + + DialogTurnResult results = dc.continueDialog().join(); + if (results.getStatus() == DialogTurnStatus.EMPTY) { + dc.prompt("OAuthPrompt", new PromptOptions()); + } else if (results.getStatus() == DialogTurnStatus.COMPLETE) { + if (results.getResult() instanceof TokenResponse) { + turnContext.sendActivity(MessageFactory.text("Logged in.")); + } else { + turnContext.sendActivity(MessageFactory.text("Failed.")); + } + } + return CompletableFuture.completedFuture(null); + }; + + new TestFlow(adapter, botCallbackHandler) + .send("hello") + .assertReply(activity -> { + Assert.assertTrue(((Activity) activity).getAttachments().size() == 1); + Assert.assertEquals(OAuthCard.CONTENTTYPE, ((Activity) activity).getAttachments().get(0).getContentType()); + Assert.assertEquals(InputHints.ACCEPTING_INPUT, ((Activity) activity).getInputHint()); + + // Add a magic code to the adapter + adapter.addUserToken(connectionName, activity.getChannelId(), + activity.getRecipient().getId(), + token, + magicCode); + }) + .send(magicCode) + .assertReply("Logged in.") + .startTest() + .join(); + } + + @Test + public void OAuthPromptTimesOut_Message() { + PromptTimeoutEndsDialogTest(MessageFactory.text("hi")); + } + + @Test + public void OAuthPromptTimesOut_TokenResponseEvent() { + Activity activity = new Activity(ActivityTypes.EVENT); + activity.setName(SignInConstants.TOKEN_RESPONSE_EVENT_NAME); + activity.setValue(new TokenResponse(Channels.MSTEAMS, "connectionName", "token", null)); + PromptTimeoutEndsDialogTest(activity); + } + + @Test + public void OAuthPromptTimesOut_VerifyStateOperation() { + Activity activity = new Activity(ActivityTypes.INVOKE); + activity.setName(SignInConstants.VERIFY_STATE_OPERATION_NAME); + activity.setValue("888999"); + PromptTimeoutEndsDialogTest(activity); + } + + @Test + public void OAuthPromptTimesOut_TokenExchangeOperation() { + Activity activity = new Activity(ActivityTypes.INVOKE); + activity.setName(SignInConstants.TOKEN_EXCHANGE_OPERATION_NAME); + + String connectionName = "myConnection"; + String exchangeToken = "exch123"; + + TokenExchangeInvokeRequest tokenExchangeRequest = new TokenExchangeInvokeRequest(); + tokenExchangeRequest.setConnectionName(connectionName); + tokenExchangeRequest.setToken(exchangeToken); + + activity.setValue(tokenExchangeRequest); + + PromptTimeoutEndsDialogTest(activity); + } + + @Test + public void OAuthPromptDoesNotDetectCodeInBeginDialog() { + ConversationState convoState = new ConversationState(new MemoryStorage()); + StatePropertyAccessor dialogState = convoState.createProperty("dialogState"); + + TestAdapter adapter = new TestAdapter() + .use(new AutoSaveStateMiddleware(convoState)); + + String connectionName = "myConnection"; + String token = "abc123"; + String magicCode = "888999"; + + // Create new DialogSet + DialogSet dialogs = new DialogSet(dialogState); + + OAuthPromptSettings settings = new OAuthPromptSettings(); + settings.setText("Please sign in"); + settings.setConnectionName(connectionName); + settings.setTitle("Sign in"); + + dialogs.add(new OAuthPrompt("OAuthPrompt", settings)); + + BotCallbackHandler botCallbackHandler = (turnContext) -> { + // Add a magic code to the adapter preemptively so that we can test if the + // message that triggers BeginDialogAsync uses magic code detection + adapter.addUserToken(connectionName, turnContext.getActivity().getChannelId(), + turnContext.getActivity().getFrom().getId(), token, magicCode); + + DialogContext dc = dialogs.createContext(turnContext).join(); + + DialogTurnResult results = dc.continueDialog().join(); + + if (results.getStatus() == DialogTurnStatus.EMPTY) { + // If magicCode is detected when prompting, this will end the dialog and + // return the token in tokenResult + DialogTurnResult tokenResult = dc.prompt("OAuthPrompt", new PromptOptions()).join(); + if (tokenResult.getResult() instanceof TokenResponse) { + throw new RuntimeException(); + } + } + return CompletableFuture.completedFuture(null); + }; + + // Call BeginDialogAsync by sending the magic code as the first message. It + // SHOULD respond with an OAuthPrompt since we haven't authenticated yet + new TestFlow(adapter, botCallbackHandler) + .send(magicCode) + .assertReply(activity -> { + Assert.assertTrue(((Activity) activity).getAttachments().size() == 1); + Assert.assertEquals(OAuthCard.CONTENTTYPE, ((Activity) activity).getAttachments().get(0).getContentType()); + Assert.assertEquals(InputHints.ACCEPTING_INPUT, ((Activity) activity).getInputHint()); + }) + .startTest() + .join(); + } + + @Test + public void OAuthPromptWithTokenExchangeInvoke() { + ConversationState convoState = new ConversationState(new MemoryStorage()); + StatePropertyAccessor dialogState = convoState.createProperty("dialogState"); + + TestAdapter adapter = new TestAdapter() + .use(new AutoSaveStateMiddleware(convoState)); + + String connectionName = "myConnection"; + String exchangeToken = "exch123"; + String token = "abc123"; + + // Create new DialogSet + DialogSet dialogs = new DialogSet(dialogState); + + OAuthPromptSettings settings = new OAuthPromptSettings(); + settings.setText("Please sign in"); + settings.setConnectionName(connectionName); + settings.setTitle("Sign in"); + + dialogs.add(new OAuthPrompt("OAuthPrompt", settings)); + + BotCallbackHandler botCallbackHandler = (turnContext) -> { + DialogContext dc = dialogs.createContext(turnContext).join(); + + DialogTurnResult results = dc.continueDialog().join(); + if (results.getStatus() == DialogTurnStatus.EMPTY) { + dc.prompt("OAuthPrompt", new PromptOptions()).join(); + } else if (results.getStatus() == DialogTurnStatus.COMPLETE) { + if (results.getResult() instanceof TokenResponse) { + turnContext.sendActivity(MessageFactory.text("Logged in.")).join(); + } else { + turnContext.sendActivity(MessageFactory.text("Failed.")).join(); + } + } + return CompletableFuture.completedFuture(null); + }; + + new TestFlow(adapter, botCallbackHandler) + .send("hello") + .assertReply(activity -> { + Assert.assertTrue(((Activity) activity).getAttachments().size() == 1); + Assert.assertEquals(OAuthCard.CONTENTTYPE, ((Activity) activity).getAttachments().get(0).getContentType()); + Assert.assertEquals(InputHints.ACCEPTING_INPUT, ((Activity) activity).getInputHint()); + + // Add an exchangable token to the adapter + adapter.addExchangeableToken(connectionName, activity.getChannelId(), + activity.getRecipient().getId(), exchangeToken, token); + }) + .send(new Activity(ActivityTypes.INVOKE) { + { + setName(SignInConstants.TOKEN_EXCHANGE_OPERATION_NAME); + setValue(new TokenExchangeInvokeRequest() { + { + setConnectionName(connectionName); + setToken(exchangeToken); + } + }); + }}) + .assertReply(a -> { + Assert.assertEquals("invokeResponse", a.getType()); + InvokeResponse response = (InvokeResponse) ((Activity)a).getValue(); + Assert.assertNotNull(response); + Assert.assertEquals(200, response.getStatus()); + TokenExchangeInvokeResponse body = (TokenExchangeInvokeResponse) response.getBody(); + Assert.assertEquals(connectionName, body.getConnectionName()); + Assert.assertNull(body.getFailureDetail()); + }) + .assertReply("Logged in.") + .startTest() + .join(); + } + + @Test + public void OAuthPromptWithTokenExchangeFail() { + ConversationState convoState = new ConversationState(new MemoryStorage()); + StatePropertyAccessor dialogState = convoState.createProperty("dialogState"); + + TestAdapter adapter = new TestAdapter() + .use(new AutoSaveStateMiddleware(convoState)); + + String connectionName = "myConnection"; + String exchangeToken = "exch123"; + + // Create new DialogSet + DialogSet dialogs = new DialogSet(dialogState); + + OAuthPromptSettings settings = new OAuthPromptSettings(); + settings.setText("Please sign in"); + settings.setConnectionName(connectionName); + settings.setTitle("Sign in"); + + dialogs.add(new OAuthPrompt("OAuthPrompt", settings)); + + BotCallbackHandler botCallbackHandler = (turnContext) -> { + DialogContext dc = dialogs.createContext(turnContext).join(); + + DialogTurnResult results = dc.continueDialog().join(); + if (results.getStatus() == DialogTurnStatus.EMPTY) { + dc.prompt("OAuthPrompt", new PromptOptions()).join(); + } else if (results.getStatus() == DialogTurnStatus.COMPLETE) { + if (results.getResult() instanceof TokenResponse) { + turnContext.sendActivity(MessageFactory.text("Logged in.")).join(); + } else { + turnContext.sendActivity(MessageFactory.text("Failed.")).join(); + } + } + return CompletableFuture.completedFuture(null); + }; + + new TestFlow(adapter, botCallbackHandler) + .send("hello") + .assertReply(activity -> { + Assert.assertTrue(((Activity) activity).getAttachments().size() == 1); + Assert.assertEquals(OAuthCard.CONTENTTYPE, ((Activity) activity).getAttachments().get(0).getContentType()); + Assert.assertEquals(InputHints.ACCEPTING_INPUT, ((Activity) activity).getInputHint()); + // No exchangable token is added to the adapter + }) + .send(new Activity(ActivityTypes.INVOKE) { + { + setName(SignInConstants.TOKEN_EXCHANGE_OPERATION_NAME); + setValue(new TokenExchangeInvokeRequest() { + { + setConnectionName(connectionName); + setToken(exchangeToken); + } + }); + }}) + .assertReply(a -> { + Assert.assertEquals("invokeResponse", a.getType()); + InvokeResponse response = (InvokeResponse) ((Activity) a).getValue(); + Assert.assertNotNull(response); + Assert.assertEquals(412, response.getStatus()); + TokenExchangeInvokeResponse body = (TokenExchangeInvokeResponse) response.getBody(); + Assert.assertEquals(connectionName, body.getConnectionName()); + Assert.assertNotNull(body.getFailureDetail()); + }) + .startTest() + .join(); + } + + @Test + public void OAuthPromptWithTokenExchangeNoBodyFails() { + ConversationState convoState = new ConversationState(new MemoryStorage()); + StatePropertyAccessor dialogState = convoState.createProperty("dialogState"); + + TestAdapter adapter = new TestAdapter() + .use(new AutoSaveStateMiddleware(convoState)); + + String connectionName = "myConnection"; + + // Create new DialogSet + DialogSet dialogs = new DialogSet(dialogState); + + OAuthPromptSettings settings = new OAuthPromptSettings(); + settings.setText("Please sign in"); + settings.setConnectionName(connectionName); + settings.setTitle("Sign in"); + + dialogs.add(new OAuthPrompt("OAuthPrompt", settings)); + + BotCallbackHandler botCallbackHandler = (turnContext) -> { + DialogContext dc = dialogs.createContext(turnContext).join(); + + DialogTurnResult results = dc.continueDialog().join(); + if (results.getStatus() == DialogTurnStatus.EMPTY) { + dc.prompt("OAuthPrompt", new PromptOptions()).join(); + } else if (results.getStatus() == DialogTurnStatus.COMPLETE) { + if (results.getResult() instanceof TokenResponse) { + turnContext.sendActivity(MessageFactory.text("Logged in.")).join(); + } else { + turnContext.sendActivity(MessageFactory.text("Failed.")).join(); + } + } + return CompletableFuture.completedFuture(null); + }; + + new TestFlow(adapter, botCallbackHandler) + .send("hello") + .assertReply(activity -> { + Assert.assertTrue(((Activity) activity).getAttachments().size() == 1); + Assert.assertEquals(OAuthCard.CONTENTTYPE, ((Activity) activity).getAttachments().get(0).getContentType()); + Assert.assertEquals(InputHints.ACCEPTING_INPUT, ((Activity) activity).getInputHint()); + // No exchangable token is added to the adapter + }) + .send(new Activity(ActivityTypes.INVOKE) { + { + setName(SignInConstants.TOKEN_EXCHANGE_OPERATION_NAME); + } + }) + .assertReply(a -> { + Assert.assertEquals("invokeResponse", a.getType()); + InvokeResponse response = (InvokeResponse) ((Activity) a).getValue(); + Assert.assertNotNull(response); + Assert.assertEquals(400, response.getStatus()); + TokenExchangeInvokeResponse body = (TokenExchangeInvokeResponse) response.getBody(); + Assert.assertEquals(connectionName, body.getConnectionName()); + Assert.assertNotNull(body.getFailureDetail()); + }) + .startTest() + .join(); + } + + @Test + public void OAuthPromptWithTokenExchangeWrongConnectionNameFail() { + ConversationState convoState = new ConversationState(new MemoryStorage()); + StatePropertyAccessor dialogState = convoState.createProperty("dialogState"); + + TestAdapter adapter = new TestAdapter() + .use(new AutoSaveStateMiddleware(convoState)); + + String connectionName = "myConnection"; + String exchangeToken = "exch123"; + + // Create new DialogSet + DialogSet dialogs = new DialogSet(dialogState); + + OAuthPromptSettings settings = new OAuthPromptSettings(); + settings.setText("Please sign in"); + settings.setConnectionName(connectionName); + settings.setTitle("Sign in"); + + dialogs.add(new OAuthPrompt("OAuthPrompt", settings)); + + BotCallbackHandler botCallbackHandler = (turnContext) -> { + DialogContext dc = dialogs.createContext(turnContext).join(); + + DialogTurnResult results = dc.continueDialog().join(); + if (results.getStatus() == DialogTurnStatus.EMPTY) { + dc.prompt("OAuthPrompt", new PromptOptions()).join(); + } else if (results.getStatus() == DialogTurnStatus.COMPLETE) { + if (results.getResult() instanceof TokenResponse) { + turnContext.sendActivity(MessageFactory.text("Logged in.")).join(); + } else { + turnContext.sendActivity(MessageFactory.text("Failed.")).join(); + } + } + return CompletableFuture.completedFuture(null); + }; + + new TestFlow(adapter, botCallbackHandler) + .send("hello") + .assertReply(activity -> { + Assert.assertTrue(((Activity) activity).getAttachments().size() == 1); + Assert.assertEquals(OAuthCard.CONTENTTYPE, ((Activity) activity).getAttachments().get(0).getContentType()); + Assert.assertEquals(InputHints.ACCEPTING_INPUT, ((Activity) activity).getInputHint()); + // No exchangable token is added to the adapter + }) + .send(new Activity(ActivityTypes.INVOKE) { + { + setName(SignInConstants.TOKEN_EXCHANGE_OPERATION_NAME); + setValue(new TokenExchangeInvokeRequest() { + { + setConnectionName("beepboop"); + setToken(exchangeToken); + } + }); + }}) + .assertReply(a -> { + Assert.assertEquals("invokeResponse", a.getType()); + InvokeResponse response = (InvokeResponse) ((Activity) a).getValue(); + Assert.assertNotNull(response); + Assert.assertEquals(400, response.getStatus()); + TokenExchangeInvokeResponse body = (TokenExchangeInvokeResponse) response.getBody(); + Assert.assertEquals(connectionName, body.getConnectionName()); + Assert.assertNotNull(body.getFailureDetail()); + }) + .startTest() + .join(); + } + + @Test + public void TestAdapterTokenExchange() { + ConversationState convoState = new ConversationState(new MemoryStorage()); + + TestAdapter adapter = new TestAdapter() + .use(new AutoSaveStateMiddleware(convoState)); + + String connectionName = "myConnection"; + String exchangeToken = "exch123"; + String token = "abc123"; + + BotCallbackHandler botCallbackHandler = (turnContext) -> { + String userId = "fred"; + adapter.addExchangeableToken(connectionName, + turnContext.getActivity().getChannelId(), + userId, + exchangeToken, + token); + + // Positive case: Token + TokenResponse result = adapter.exchangeToken(turnContext, connectionName, userId, + new TokenExchangeRequest() {{ setToken(exchangeToken); }}).join(); + Assert.assertNotNull(result); + Assert.assertEquals(token, result.getToken()); + Assert.assertEquals(connectionName, result.getConnectionName()); + + // Positive case: URI + result = adapter.exchangeToken(turnContext, connectionName, userId, + new TokenExchangeRequest() { { setUri(exchangeToken); }}).join(); + Assert.assertNotNull(result); + Assert.assertEquals(token, result.getToken()); + Assert.assertEquals(connectionName, result.getConnectionName()); + + // Negative case: Token + result = adapter.exchangeToken(turnContext, connectionName, userId, + new TokenExchangeRequest() {{ setToken("beeboop"); }}).join(); + Assert.assertNull(result); + + // Negative case: URI + result = adapter.exchangeToken(turnContext, connectionName, userId, + new TokenExchangeRequest() {{ setUri("beeboop"); }}).join(); + Assert.assertNull(result); + + return CompletableFuture.completedFuture(null); + }; + + new TestFlow(adapter, botCallbackHandler) + .send("hello") + .startTest() + .join(); + } + + private void OAuthPrompt(Storage storage) { + ConversationState convoState = new ConversationState(storage); + StatePropertyAccessor dialogState = convoState.createProperty("dialogState"); + TestAdapter adapter = new TestAdapter() + .use(new AutoSaveStateMiddleware(convoState)); + + String connectionName = "myConnection"; + String token = "abc123"; + + // Create new DialogSet. + DialogSet dialogs = new DialogSet(dialogState); + OAuthPromptSettings oauthPromptSettings = new OAuthPromptSettings(); + oauthPromptSettings.setText("Please sign in"); + oauthPromptSettings.setConnectionName(connectionName); + oauthPromptSettings.setTitle("Sign in"); + dialogs.add(new OAuthPrompt("OAuthPrompt", oauthPromptSettings)); + + BotCallbackHandler botCallbackHandler = (turnContext) -> { + DialogContext dc = dialogs.createContext(turnContext).join(); + + DialogTurnResult results = dc.continueDialog().join(); + if (results.getStatus() == DialogTurnStatus.EMPTY) { + dc.prompt("OAuthPrompt", new PromptOptions()); + } else if (results.getStatus() == DialogTurnStatus.COMPLETE) { + if (results.getResult() instanceof TokenResponse) { + turnContext.sendActivity(MessageFactory.text("Logged in.")); + } else { + turnContext.sendActivity(MessageFactory.text("Failed.")); + } + } + return CompletableFuture.completedFuture(null); + }; + + new TestFlow(adapter, botCallbackHandler).send("hello").assertReply(activity -> { + Assert.assertTrue(((Activity) activity).getAttachments().size() == 1); + Assert.assertEquals(OAuthCard.CONTENTTYPE, ((Activity) activity).getAttachments().get(0).getContentType()); + Assert.assertEquals(InputHints.ACCEPTING_INPUT, ((Activity) activity).getInputHint()); + + // Prepare an EventActivity with a TokenResponse and send it to the + // botCallbackHandler + Activity eventActivity = createEventResponse(adapter, activity, connectionName, token); + TurnContextImpl ctx = new TurnContextImpl(adapter, (Activity) eventActivity); + botCallbackHandler.invoke(ctx); + }).assertReply("Logged in.").startTest().join(); + } + + private void PromptTimeoutEndsDialogTest(Activity oauthPromptActivity) { + ConversationState convoState = new ConversationState(new MemoryStorage()); + StatePropertyAccessor dialogState = convoState.createProperty("dialogState"); + TestAdapter adapter = new TestAdapter() + .use(new AutoSaveStateMiddleware(convoState)); + + String connectionName = "myConnection"; + String exchangeToken = "exch123"; + String magicCode = "888999"; + String token = "abc123"; + + // Create new DialogSet. + DialogSet dialogs = new DialogSet(dialogState); + + // Set timeout to zero, so the prompt will end immediately. + OAuthPromptSettings settings = new OAuthPromptSettings(); + settings.setText("Please sign in"); + settings.setConnectionName(connectionName); + settings.setTitle("Sign in"); + settings.setTimeout(0); + dialogs.add(new OAuthPrompt("OAuthPrompt", settings)); + + BotCallbackHandler botCallbackHandler = (turnContext) -> { + DialogContext dc = dialogs.createContext(turnContext).join(); + + DialogTurnResult results = dc.continueDialog().join(); + if (results.getStatus() == DialogTurnStatus.EMPTY) { + dc.prompt("OAuthPrompt", new PromptOptions()).join(); + } else if (results.getStatus() == DialogTurnStatus.COMPLETE) { + // If the TokenResponse comes back, the timeout did not occur. + if (results.getResult() instanceof TokenResponse) { + turnContext.sendActivity("failed").join(); + } else { + turnContext.sendActivity("ended").join(); + } + } + return CompletableFuture.completedFuture(null); + }; + + new TestFlow(adapter, botCallbackHandler) + .send("hello") + .assertReply(activity -> { + Assert.assertTrue(((Activity) activity).getAttachments().size() == 1); + Assert.assertEquals(OAuthCard.CONTENTTYPE, ((Activity) activity).getAttachments().get(0).getContentType()); + + // Add a magic code to the adapter + adapter.addUserToken(connectionName, + activity.getChannelId(), + activity.getRecipient().getId(), + token, + magicCode); + + // Add an exchangable token to the adapter + adapter.addExchangeableToken(connectionName, + activity.getChannelId(), + activity.getRecipient().getId(), + exchangeToken, + token); + }) + .send(oauthPromptActivity) + .assertReply("ended") + .startTest() + .join(); + } + + private Activity createEventResponse(TestAdapter adapter, Activity activity, String connectionName, String token) { + // add the token to the TestAdapter + adapter.addUserToken(connectionName, activity.getChannelId(), activity.getRecipient().getId(), token, null); + + // send an event TokenResponse activity to the botCallback handler + Activity eventActivity = ((Activity) activity).createReply(); + eventActivity.setType(ActivityTypes.EVENT); + ChannelAccount from = eventActivity.getFrom(); + eventActivity.setFrom(eventActivity.getRecipient()); + eventActivity.setRecipient(from); + eventActivity.setName(SignInConstants.TOKEN_RESPONSE_EVENT_NAME); + TokenResponse tokenResponse = new TokenResponse(); + tokenResponse.setConnectionName(connectionName); + tokenResponse.setToken(token); + eventActivity.setValue(tokenResponse); + + return eventActivity; + } + + // private void OAuthPromptEndOnInvalidMessageSetting() { + // var convoState = new ConversationState(new MemoryStorage()); + // var dialogState = convoState.CreateProperty("dialogState"); + + // var adapter = new TestAdapter() + // .Use(new AutoSaveStateMiddleware(convoState)); + + // var connectionName = "myConnection"; + + // // Create new DialogSet. + // var dialogs = new DialogSet(dialogState); + // dialogs.Add(new OAuthPrompt("OAuthPrompt", new OAuthPromptSettings() { Text = + // "Please sign in", ConnectionName = connectionName, Title = "Sign in", + // EndOnInvalidMessage = true })); + + // BotCallbackHandler botCallbackHandler = (turnContext) -> { + // var dc = dialogs.CreateContext(turnContext); + + // var results = dc.ContinueDialog(); + // if (results.Status == DialogTurnStatus.Empty) { + // dc.Prompt("OAuthPrompt", new PromptOptions()); + // } else if (results.Status == DialogTurnStatus.Waiting) { + // throw new InvalidOperationException("Test + // OAuthPromptEndOnInvalidMessageSetting expected DialogTurnStatus.Complete"); + // } else if (results.Status == DialogTurnStatus.Complete) { + // if (results.Result is TokenResponse) { + // turnContext.SendActivity(MessageFactory.Text("Logged in.")); + // } else { + // turnContext.SendActivity(MessageFactory.Text("Ended.")); + // } + // } + // }; + + // new TestFlow(adapter, botCallbackHandler) + // .send("hello") + // .assertReply(activity -> { + // Assert.Single(((Activity)activity).Attachments); + // Assert.Equal(OAuthCard.ContentType, + // ((Activity)activity).Attachments[0].ContentType); + // }) + // .send("blah") + // .assertReply("Ended.") + // .startTest(); + // } +} diff --git a/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/OAuthCard.java b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/OAuthCard.java index 1fa140ddc..7524eafb0 100644 --- a/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/OAuthCard.java +++ b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/OAuthCard.java @@ -38,6 +38,11 @@ public class OAuthCard { @JsonInclude(JsonInclude.Include.NON_EMPTY) private List buttons; + /** + * The resource to try to perform token exchange with. + */ + private TokenExchangeResource tokenExchangeResource; + /** * Get the text value. * @@ -103,7 +108,7 @@ public void setButtons(CardAction... withButtons) { /** * Creates an @{link Attachment} for this card. - * + * * @return An Attachment object containing the card. */ public Attachment toAttachment() { @@ -114,4 +119,20 @@ public Attachment toAttachment() { } }; } + + /** + * Gets the resource to try to perform token exchange with. + * @return The tokenExchangeResource value. + */ + public TokenExchangeResource getTokenExchangeResource() { + return tokenExchangeResource; + } + + /** + * Sets the resource to try to perform token exchange with. + * @param withExchangeResource The tokenExchangeResource value. + */ + public void setTokenExchangeResource(TokenExchangeResource withExchangeResource) { + tokenExchangeResource = withExchangeResource; + } } diff --git a/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/SignInResource.java b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/SignInResource.java new file mode 100644 index 000000000..a662d40d0 --- /dev/null +++ b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/SignInResource.java @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.schema; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A type containing information for single sign-on. + */ +public class SignInResource { + + @JsonProperty(value = "signInLink") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String signInLink; + + @JsonProperty(value = "tokenExchangeResource") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private TokenExchangeResource tokenExchangeResource; + + /** + * Initializes a new instance of the SignInUrlResponse class. + */ + public SignInResource() { + customInit(); + } + + /** + * Initializes a new instance of the SignInUrlResponse class. + * @param signInLink the sign in link to initialize this instance to. + * @param tokenExchangeResource the tokenExchangeResource to initialize this instance to. + */ + public SignInResource(String signInLink, TokenExchangeResource tokenExchangeResource) { + this.signInLink = signInLink; + this.tokenExchangeResource = tokenExchangeResource; + customInit(); + } + + /** + * An initialization method that performs custom operations like setting. + * defaults + */ + void customInit() { + } + + /** + * The sign-in link. + * @return the SignInLink value as a String. + */ + public String getSignInLink() { + return this.signInLink; + } + + /** + * The sign-in link. + * @param withSignInLink The SignInLink value. + */ + public void setSignInLink(String withSignInLink) { + this.signInLink = withSignInLink; + } + /** + * Additional properties that cna be used for token exchange operations. + * @return the TokenExchangeResource value as a TokenExchangeResource. + */ + public TokenExchangeResource getTokenExchangeResource() { + return this.tokenExchangeResource; + } + + /** + * Additional properties that cna be used for token exchange operations. + * @param withTokenExchangeResource The TokenExchangeResource value. + */ + public void setTokenExchangeResource(TokenExchangeResource withTokenExchangeResource) { + this.tokenExchangeResource = withTokenExchangeResource; + } +} diff --git a/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenExchangeInvokeRequest.java b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenExchangeInvokeRequest.java new file mode 100644 index 000000000..2db369daf --- /dev/null +++ b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenExchangeInvokeRequest.java @@ -0,0 +1,70 @@ +package com.microsoft.bot.schema; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A request to exchange a token. + */ +public class TokenExchangeInvokeRequest { + @JsonProperty(value = "id") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String id; + + @JsonProperty(value = "connectionName") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String connectionName; + + @JsonProperty(value = "token") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String token; + + /** + * Gets the id from the TokenExchangeInvokeRequest. + * @return the Id value as a String. + */ + public String getId() { + return this.id; + } + + /** + * Sets the id from the TokenExchangeInvokeRequest. + * @param withId The Id value. + */ + public void setId(String withId) { + this.id = withId; + } + + /** + * Gets the connection name. + * @return the ConnectionName value as a String. + */ + public String getConnectionName() { + return this.connectionName; + } + + /** + * Sets the connection name. + * @param withConnectionName The ConnectionName value. + */ + public void setConnectionName(String withConnectionName) { + this.connectionName = withConnectionName; + } + + /** + * Gets the details of why the token exchange failed. + * @return the Token value as a String. + */ + public String getToken() { + return this.token; + } + + /** + * Sets the details of why the token exchange failed. + * @param withToken The FailureDetail value. + */ + public void setToken(String withToken) { + this.token = withToken; + } + +} diff --git a/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenExchangeInvokeResponse.java b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenExchangeInvokeResponse.java new file mode 100644 index 000000000..71012fcf9 --- /dev/null +++ b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenExchangeInvokeResponse.java @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.schema; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The response Object of a token exchange invoke. + */ +public class TokenExchangeInvokeResponse { + + @JsonProperty(value = "id") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String id; + + @JsonProperty(value = "connectionName") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String connectionName; + + @JsonProperty(value = "failureDetail") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String failureDetail; + + /** + * Gets the id from the TokenExchangeInvokeRequest. + * @return the Id value as a String. + */ + public String getId() { + return this.id; + } + + /** + * Sets the id from the TokenExchangeInvokeRequest. + * @param withId The Id value. + */ + public void setId(String withId) { + this.id = withId; + } + + /** + * Gets the connection name. + * @return the ConnectionName value as a String. + */ + public String getConnectionName() { + return this.connectionName; + } + + /** + * Sets the connection name. + * @param withConnectionName The ConnectionName value. + */ + public void setConnectionName(String withConnectionName) { + this.connectionName = withConnectionName; + } + + /** + * Gets the details of why the token exchange failed. + * @return the FailureDetail value as a String. + */ + public String getFailureDetail() { + return this.failureDetail; + } + + /** + * Sets the details of why the token exchange failed. + * @param withFailureDetail The FailureDetail value. + */ + public void setFailureDetail(String withFailureDetail) { + this.failureDetail = withFailureDetail; + } +} + diff --git a/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenExchangeRequest.java b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenExchangeRequest.java new file mode 100644 index 000000000..07a7c17eb --- /dev/null +++ b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenExchangeRequest.java @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.schema; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Request payload to be sent to the Bot Framework Token Service for Single Sign + * On.If the URI is set to a custom scope, then Token Service will exchange the + * token in its cache for a token targeting the custom scope and return it in + * the response.If a Token is sent in the payload, then Token Service will + * exchange the token for a token targetting the scopes specified in the + * corresponding OAauth connection. + */ +public class TokenExchangeRequest { + + @JsonProperty(value = "uri") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String uri; + + @JsonProperty(value = "token") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String token; + + + /** + * Initializes a new instance of the {@link TokenExchangeRequest} class. + */ + public TokenExchangeRequest() { + customInit(); + } + + /** + * Initializes a new instance of the TokenExchangeRequest class. + * @param uri The uri to intialize this instance with. + * @param token The token to initialize this instance with. + */ + public TokenExchangeRequest(String uri, String token) { + this.uri = uri; + this.token = token; + customInit(); + } + + /** + * An initialization method that performs custom operations like setting. + * defaults + */ + void customInit() { + + } + + /** + * Gets a URI String. + * @return the Uri value as a String. + */ + public String getUri() { + return this.uri; + } + + /** + * Sets a URI String. + * @param withUri The Uri value. + */ + public void setUri(String withUri) { + this.uri = withUri; + } + /** + * Gets a token String. + * @return the Token value as a String. + */ + public String getToken() { + return this.token; + } + + /** + * Sets a token String. + * @param withToken The Token value. + */ + public void setToken(String withToken) { + this.token = withToken; + } +} + diff --git a/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenExchangeResource.java b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenExchangeResource.java new file mode 100644 index 000000000..df4c55462 --- /dev/null +++ b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenExchangeResource.java @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.schema; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Response schema sent back from Bot Framework Token Service required to + * initiate a user single sign on. + */ + public class TokenExchangeResource { + + @JsonProperty(value = "id") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String id; + + @JsonProperty(value = "uri") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String uri; + + @JsonProperty(value = "providerId") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String providerId; + + /** + * Initializes a new instance of the TokenExchangeResource class. + */ + public TokenExchangeResource() { + customInit(); + } + + /** + * Initializes a new instance of the TokenExchangeResource class. + * @param id The id to initialize this instance to. + * @param uri The uri to initialize this instance to. + * @param providerId the providerId to initialize this instance to. + */ + public TokenExchangeResource(String id, String uri, String providerId) { + this.id = id; + this.uri = uri; + this.providerId = providerId; + customInit(); + } + + /** + * An initialization method that performs custom operations like setting + * defaults. + */ + void customInit() { + } + + /** + * A unique identifier for this token exchange instance. + * @return the Id value as a String. + */ + public String getId() { + return this.id; + } + + /** + * A unique identifier for this token exchange instance. + * @param withId The Id value. + */ + public void setId(String withId) { + this.id = withId; + } + /** + * The application D / resource identifier with which to exchange a token. + * on behalf of + * @return the Uri value as a String. + */ + public String getUri() { + return this.uri; + } + + /** + * The application D / resource identifier with which to exchange a token. + * on behalf of + * @param withUri The Uri value. + */ + public void setUri(String withUri) { + this.uri = withUri; + } + /** + * The identifier of the provider with which to attempt a token exchange A + * value of null or empty will default to Azure Active Directory. + * @return the ProviderId value as a String. + */ + public String getProviderId() { + return this.providerId; + } + + /** + * The identifier of the provider with which to attempt a token exchange A + * value of null or empty will default to Azure Active Directory. + * @param withProviderId The ProviderId value. + */ + public void setProviderId(String withProviderId) { + this.providerId = withProviderId; + } + } 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 3546a9db8..3c78f7361 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 @@ -10,7 +10,7 @@ * State object passed to the bot token service. */ public class TokenExchangeState { - @JsonProperty("msAppId") + @JsonProperty(value = "msAppId") @JsonInclude(JsonInclude.Include.NON_EMPTY) private String msAppId; @@ -22,11 +22,11 @@ public class TokenExchangeState { @JsonInclude(JsonInclude.Include.NON_EMPTY) private ConversationReference conversation; - @JsonProperty("botUrl") + @JsonProperty(value = "botUrl") @JsonInclude(JsonInclude.Include.NON_EMPTY) private String botUrl; - @JsonProperty("relatesTo") + @JsonProperty(value = "relatesTo") @JsonInclude(JsonInclude.Include.NON_EMPTY) private ConversationReference relatesTo; diff --git a/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenResponse.java b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenResponse.java index b3d228c07..4ad9fac6d 100644 --- a/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenResponse.java +++ b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/TokenResponse.java @@ -10,6 +10,28 @@ * A response that includes a user token. */ public class TokenResponse { + + /** + * Initializes a new instance of the TokenResponse class. + */ + public TokenResponse() { + + } + + /** + * Initializes a new instance of the TokenResponse class. + * @param channelId The channelId. + * @param connectionName The connectionName. + * @param token The token. + * @param expiration the expiration. + */ + public TokenResponse(String channelId, String connectionName, String token, String expiration) { + this.channelId = channelId; + this.connectionName = connectionName; + this.token = token; + this.expiration = expiration; + } + /** * The channelId of the TokenResponse. */ @@ -40,7 +62,7 @@ public class TokenResponse { /** * Gets the channelId value. - * + * * @return THe channel id. */ public String getChannelId() { diff --git a/samples/18.bot-authentication/LICENSE b/samples/18.bot-authentication/LICENSE new file mode 100644 index 000000000..21071075c --- /dev/null +++ b/samples/18.bot-authentication/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/samples/18.bot-authentication/README.md b/samples/18.bot-authentication/README.md new file mode 100644 index 000000000..456bab485 --- /dev/null +++ b/samples/18.bot-authentication/README.md @@ -0,0 +1,94 @@ +# Bot Authentication + +Bot Framework v4 bot authentication sample + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to use authentication in your bot using OAuth. + +The sample uses the bot authentication capabilities in [Azure Bot Service](https://docs.botframework.com), providing features to make it easier to develop a bot that authenticates users to various identity providers such as Azure AD (Azure Active Directory), GitHub, Uber, etc. + +NOTE: Microsoft Teams currently differs slightly in the way auth is integrated with the bot. Refer to sample 46.teams-auth. + +## Prerequisites + +- Java 1.8+ +- Install [Maven](https://maven.apache.org/) +- An account on [Azure](https://azure.microsoft.com) if you want to deploy to Azure. + +## To try this sample locally +- From the root of this project folder: + - Build the sample using `mvn package` + - Run it by using `java -jar .\target\bot-authentication-sample.jar` + +- Test the bot using Bot Framework Emulator + + [Bot Framework Emulator](https://github.com/microsoft/botframework-emulator) is a desktop application that allows bot developers to test and debug their bots on localhost or running remotely through a tunnel. + + - Install the Bot Framework Emulator version 4.3.0 or greater from [here](https://github.com/Microsoft/BotFramework-Emulator/releases) + + - Connect to the bot using Bot Framework Emulator + + - Launch Bot Framework Emulator + - File -> Open Bot + - Enter a Bot URL of `http://localhost:3978/api/messages` + +## Deploy the bot to Azure + +As described on [Deploy your bot](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-deploy-az-cli), you will perform the first 4 steps to setup the Azure app, then deploy the code using the azure-webapp Maven plugin. + +### 1. Login to Azure +From a command (or PowerShell) prompt in the root of the bot folder, execute: +`az login` + +### 2. Set the subscription +`az account set --subscription ""` + +If you aren't sure which subscription to use for deploying the bot, you can view the list of subscriptions for your account by using `az account list` command. + +### 3. Create an App registration +`az ad app create --display-name "" --password "" --available-to-other-tenants` + +Replace `` and `` with your own values. + +`` is the unique name of your bot. +`` is a minimum 16 character password for your bot. + +Record the `appid` from the returned JSON + +### 4. Create the Azure resources +Replace the values for ``, ``, ``, and `` in the following commands: + +#### To a new Resource Group +`az deployment sub create --name "authenticationBotDeploy" --location "westus" --template-file ".\deploymentTemplates\template-with-new-rg.json" --parameters appId="" appSecret="" botId="" botSku=S1 newAppServicePlanName="authenticationBotPlan" newWebAppName="authenticationBot" groupLocation="westus" newAppServicePlanLocation="westus"` + +#### To an existing Resource Group +`az deployment group create --resource-group "" --template-file ".\deploymentTemplates\template-with-preexisting-rg.json" --parameters appId="" appSecret="" botId="" newWebAppName="authenticationBot" newAppServicePlanName="authenticationBotPlan" appServicePlanLocation="westus" --name "authenticationBot"` + +### 5. Update app id and password +In src/main/resources/application.properties update + - `MicrosoftAppPassword` with the botsecret value + - `MicrosoftAppId` with the appid from the first step + +### 6. Deploy the code +- Execute `mvn clean package` +- Execute `mvn azure-webapp:deploy -Dgroupname="" -Dbotname=""` + +If the deployment is successful, you will be able to test it via "Test in Web Chat" from the Azure Portal using the "Bot Channel Registration" for the bot. + +After the bot is deployed, you only need to execute #6 if you make changes to the bot. + + +## Further reading + +- [Bot Framework Documentation](https://docs.botframework.com) +- [Bot Basics](https://docs.microsoft.com/azure/bot-service/bot-builder-basics?view=azure-bot-service-4.0) +- [Dialogs](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) +- [Gathering Input Using Prompts](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-prompts?view=azure-bot-service-4.0&tabs=csharp) +- [Activity processing](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0) +- [Azure Bot Service Introduction](https://docs.microsoft.com/azure/bot-service/bot-service-overview-introduction?view=azure-bot-service-4.0) +- [Azure Bot Service Documentation](https://docs.microsoft.com/azure/bot-service/?view=azure-bot-service-4.0) +- [Azure CLI](https://docs.microsoft.com/cli/azure/?view=azure-cli-latest) +- [Azure Portal](https://portal.azure.com) +- [Channels and Bot Connector Service](https://docs.microsoft.com/en-us/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) +- [Maven Plugin for Azure App Service](https://docs.microsoft.com/en-us/java/api/overview/azure/maven/azure-webapp-maven-plugin/readme?view=azure-java-stable) +- [Spring Boot](https://spring.io/projects/spring-boot) +- [Azure for Java cloud developers](https://docs.microsoft.com/en-us/azure/java/?view=azure-java-stable) diff --git a/samples/18.bot-authentication/deploymentTemplates/template-with-new-rg.json b/samples/18.bot-authentication/deploymentTemplates/template-with-new-rg.json new file mode 100644 index 000000000..ec2460d3a --- /dev/null +++ b/samples/18.bot-authentication/deploymentTemplates/template-with-new-rg.json @@ -0,0 +1,291 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "groupLocation": { + "defaultValue": "", + "type": "string", + "metadata": { + "description": "Specifies the location of the Resource Group." + } + }, + "groupName": { + "type": "string", + "metadata": { + "description": "Specifies the name of the Resource Group." + } + }, + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "S1", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "defaultValue": "", + "type": "string", + "metadata": { + "description": "The name of the App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "P1v2", + "tier": "PremiumV2", + "size": "P1v2", + "family": "Pv2", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "newAppServicePlanLocation": { + "defaultValue": "", + "type": "string", + "metadata": { + "description": "The location of the App Service Plan. Defaults to \"westus\"." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "appServicePlanName": "[parameters('newAppServicePlanName')]", + "resourcesLocation": "[parameters('newAppServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourceGroupId": "[concat(subscription().id, '/resourceGroups/', parameters('groupName'))]" + }, + "resources": [ + { + "name": "[parameters('groupName')]", + "type": "Microsoft.Resources/resourceGroups", + "apiVersion": "2018-05-01", + "location": "[parameters('groupLocation')]", + "properties": {} + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2018-05-01", + "name": "storageDeployment", + "resourceGroup": "[parameters('groupName')]", + "dependsOn": [ + "[resourceId('Microsoft.Resources/resourceGroups/', parameters('groupName'))]" + ], + "properties": { + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": {}, + "variables": {}, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "name": "[variables('appServicePlanName')]", + "apiVersion": "2018-02-01", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "perSiteScaling": false, + "maximumElasticWorkerCount": 1, + "isSpot": false, + "reserved": true, + "isXenon": false, + "hyperV": false, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2018-11-01", + "location": "[variables('resourcesLocation')]", + "kind": "app,linux", + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/serverfarms/', variables('appServicePlanName'))]" + ], + "name": "[variables('webAppName')]", + "properties": { + "name": "[variables('webAppName')]", + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[variables('appServicePlanName')]", + "reserved": true, + "isXenon": false, + "hyperV": false, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": true, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "redundancyMode": "None", + "siteConfig": { + "appSettings": [ + { + "name": "JAVA_OPTS", + "value": "-Dserver.port=80" + }, + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2018-11-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/sites/', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "linuxFxVersion": "JAVA|8-jre8", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "httpLoggingEnabled": false, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": true, + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": true + } + ], + "loadBalancing": "LeastRequests", + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "localMySqlEnabled": false, + "ipSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictionsUseMain": false, + "http20Enabled": false, + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ], + "outputs": {} + } + } + } + ] +} diff --git a/samples/18.bot-authentication/deploymentTemplates/template-with-preexisting-rg.json b/samples/18.bot-authentication/deploymentTemplates/template-with-preexisting-rg.json new file mode 100644 index 000000000..024dcf08d --- /dev/null +++ b/samples/18.bot-authentication/deploymentTemplates/template-with-preexisting-rg.json @@ -0,0 +1,259 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings. Defaults to \"\"." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "S1", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The name of the new App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "P1v2", + "tier": "PremiumV2", + "size": "P1v2", + "family": "Pv2", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "appServicePlanLocation": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The location of the App Service Plan." + } + }, + "existingAppServicePlan": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Name of the existing App Service Plan used to create the Web App for the bot." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", + "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", + "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourcesLocation": "[parameters('appServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" + }, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "condition": "[not(variables('useExistingAppServicePlan'))]", + "name": "[variables('servicePlanName')]", + "apiVersion": "2018-02-01", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "perSiteScaling": false, + "maximumElasticWorkerCount": 1, + "isSpot": false, + "reserved": true, + "isXenon": false, + "hyperV": false, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2018-11-01", + "location": "[variables('resourcesLocation')]", + "kind": "app,linux", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]" + ], + "name": "[variables('webAppName')]", + "properties": { + "name": "[variables('webAppName')]", + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", + "reserved": true, + "isXenon": false, + "hyperV": false, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": true, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "redundancyMode": "None", + "siteConfig": { + "appSettings": [ + { + "name": "JAVA_OPTS", + "value": "-Dserver.port=80" + }, + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2018-11-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "linuxFxVersion": "JAVA|8-jre8", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "httpLoggingEnabled": false, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": true, + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": true + } + ], + "loadBalancing": "LeastRequests", + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "localMySqlEnabled": false, + "ipSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictionsUseMain": false, + "http20Enabled": false, + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ] +} diff --git a/samples/18.bot-authentication/pom.xml b/samples/18.bot-authentication/pom.xml new file mode 100644 index 000000000..ac2acf198 --- /dev/null +++ b/samples/18.bot-authentication/pom.xml @@ -0,0 +1,244 @@ + + + + 4.0.0 + + com.microsoft.bot.sample + bot-authentication + sample + jar + + ${project.groupId}:${project.artifactId} + This package contains the Bot Authentication sample using Spring Boot. + http://maven.apache.org + + + org.springframework.boot + spring-boot-starter-parent + 2.4.0 + + + + + + MIT License + http://www.opensource.org/licenses/mit-license.php + + + + + + Bot Framework Development + + Microsoft + https://dev.botframework.com/ + + + + + 1.8 + 1.8 + 1.8 + com.microsoft.bot.sample.authentication.Application + + + + + org.springframework.boot + spring-boot-starter-test + 2.4.0 + test + + + junit + junit + 4.13.1 + test + + + org.junit.vintage + junit-vintage-engine + test + + + + org.slf4j + slf4j-api + + + org.apache.logging.log4j + log4j-api + 2.11.0 + + + org.apache.logging.log4j + log4j-core + 2.13.2 + + + + com.microsoft.bot + bot-integration-spring + 4.6.0-preview8 + compile + + + com.microsoft.bot + bot-dialogs + 4.6.0-preview8 + compile + + + + + + build + + true + + + + + src/main/resources + false + + + + + maven-compiler-plugin + 3.8.1 + + + maven-war-plugin + 3.2.3 + + src/main/webapp + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + com.microsoft.bot.sample.authentication.Application + + + + + + com.microsoft.azure + azure-webapp-maven-plugin + 1.12.0 + + V2 + ${groupname} + ${botname} + + + JAVA_OPTS + -Dserver.port=80 + + + + linux + Java 8 + Java SE + + + + + ${project.basedir}/target + + *.jar + + + + + + + + + + + + publish + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + maven-war-plugin + 3.2.3 + + src/main/webapp + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.8 + true + + true + ossrh + https://oss.sonatype.org/ + true + + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + verify + + sign + + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + 8 + false + + + + attach-javadocs + + jar + + + + + + + + + diff --git a/samples/18.bot-authentication/src/main/java/com/microsoft/bot/sample/authentication/Application.java b/samples/18.bot-authentication/src/main/java/com/microsoft/bot/sample/authentication/Application.java new file mode 100644 index 000000000..28dfd8148 --- /dev/null +++ b/samples/18.bot-authentication/src/main/java/com/microsoft/bot/sample/authentication/Application.java @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.authentication; + +import com.microsoft.bot.integration.AdapterWithErrorHandler; +import com.microsoft.bot.integration.BotFrameworkHttpAdapter; +import com.microsoft.bot.integration.Configuration; +import com.microsoft.bot.integration.spring.BotController; +import com.microsoft.bot.integration.spring.BotDependencyConfiguration; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Import; + +/** + * This is the starting point of the Sprint Boot Bot application. + * + * This class also provides overrides for dependency injections. A class that + * extends the {@link com.microsoft.bot.builder.Bot} interface should be + * annotated with @Component. + * + * @see RichCardsBot + */ +@SpringBootApplication + +// Use the default BotController to receive incoming Channel messages. A custom +// controller could be used by eliminating this import and creating a new +// RestController. +// The default controller is created by the Spring Boot container using +// dependency injection. The default route is /api/messages. +@Import({BotController.class}) + +public class Application extends BotDependencyConfiguration { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + + /** + * Returns a custom Adapter that provides error handling. + * + * @param configuration The Configuration object to use. + * @return An error handling BotFrameworkHttpAdapter. + */ + @Override + public BotFrameworkHttpAdapter getBotFrameworkHttpAdaptor(Configuration configuration) { + return new AdapterWithErrorHandler(configuration); + } +} diff --git a/samples/18.bot-authentication/src/main/java/com/microsoft/bot/sample/authentication/AuthBot.java b/samples/18.bot-authentication/src/main/java/com/microsoft/bot/sample/authentication/AuthBot.java new file mode 100644 index 000000000..bc691d4af --- /dev/null +++ b/samples/18.bot-authentication/src/main/java/com/microsoft/bot/sample/authentication/AuthBot.java @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.authentication; + +import java.util.concurrent.CompletableFuture; +import java.util.List; + +import com.microsoft.bot.builder.ConversationState; +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.builder.UserState; +import com.microsoft.bot.dialogs.Dialog; +import com.microsoft.bot.schema.ChannelAccount; + +import org.springframework.stereotype.Component; +import com.codepoetics.protonpack.collectors.CompletableFutures; +import com.microsoft.bot.schema.Activity; +import org.apache.commons.lang3.StringUtils; + + +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +@Component +public class AuthBot extends DialogBot { + + public AuthBot(ConversationState conversationState, UserState userState, MainDialog dialog) { + super(conversationState, userState, dialog); + } + + @Override + protected CompletableFuture onMembersAdded( + List membersAdded, TurnContext turnContext + ) { + return turnContext.getActivity().getMembersAdded().stream() + .filter(member -> !StringUtils + .equals(member.getId(), turnContext.getActivity().getRecipient().getId())) + .map(channel -> { + Activity reply = MessageFactory.text("Welcome to AuthBot." + + " Type anything to get logged in. Type 'logout' to sign-out."); + + return turnContext.sendActivity(reply); + }) + .collect(CompletableFutures.toFutureList()) + .thenApply(resourceResponse -> null); + } + + @Override + protected CompletableFuture onTokenResponseEvent(TurnContext turnContext) { + // Run the Dialog with the new Token Response Event Activity. + return Dialog.run(dialog, turnContext, conversationState.createProperty("DialogState")); + } + +} diff --git a/samples/18.bot-authentication/src/main/java/com/microsoft/bot/sample/authentication/DialogBot.java b/samples/18.bot-authentication/src/main/java/com/microsoft/bot/sample/authentication/DialogBot.java new file mode 100644 index 000000000..e7d4a833a --- /dev/null +++ b/samples/18.bot-authentication/src/main/java/com/microsoft/bot/sample/authentication/DialogBot.java @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.authentication; + +import com.microsoft.bot.builder.ActivityHandler; +import com.microsoft.bot.builder.BotState; +import com.microsoft.bot.builder.ConversationState; +import com.microsoft.bot.dialogs.Dialog; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.builder.UserState; +import java.util.concurrent.CompletableFuture; + +/** + * This Bot implementation can run any type of Dialog. The use of type parameterization is to + * allows multiple different bots to be run at different endpoints within the same project. This + * can be achieved by defining distinct Controller types each with dependency on distinct IBot + * types, this way ASP Dependency Injection can glue everything together without ambiguity. The + * ConversationState is used by the Dialog system. The UserState isn't, however, it might have + * been used in a Dialog implementation, and the requirement is that all BotState objects are + * saved at the end of a turn. + */ +public class DialogBot extends ActivityHandler { + protected Dialog dialog; + protected BotState conversationState; + protected BotState userState; + + public DialogBot( + ConversationState withConversationState, + UserState withUserState, + T withDialog + ) { + dialog = withDialog; + conversationState = withConversationState; + userState = withUserState; + } + + @Override + public CompletableFuture onTurn( + TurnContext turnContext + ) { + return super.onTurn(turnContext) + .thenCompose(result -> conversationState.saveChanges(turnContext)) + .thenCompose(result -> userState.saveChanges(turnContext)); + } + + @Override + protected CompletableFuture onMessageActivity( + TurnContext turnContext + ) { + return Dialog.run(dialog, turnContext, conversationState.createProperty("DialogState")); + } +} diff --git a/samples/18.bot-authentication/src/main/java/com/microsoft/bot/sample/authentication/LogoutDialog.java b/samples/18.bot-authentication/src/main/java/com/microsoft/bot/sample/authentication/LogoutDialog.java new file mode 100644 index 000000000..e31510940 --- /dev/null +++ b/samples/18.bot-authentication/src/main/java/com/microsoft/bot/sample/authentication/LogoutDialog.java @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.authentication; + +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.builder.BotFrameworkAdapter; +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.dialogs.ComponentDialog; +import com.microsoft.bot.dialogs.DialogContext; +import com.microsoft.bot.dialogs.DialogTurnResult; +import com.microsoft.bot.schema.ActivityTypes; + +public class LogoutDialog extends ComponentDialog { + + private final String connectionName; + + public LogoutDialog(String id, String connectionName) { + super(id); + this.connectionName = connectionName; + } + + + @Override + protected CompletableFuture onBeginDialog(DialogContext innerDc, Object options) { + DialogTurnResult result = interrupt(innerDc).join(); + if (result != null) { + return CompletableFuture.completedFuture(result); + } + + return super.onBeginDialog(innerDc, options); + } + + @Override + protected CompletableFuture onContinueDialog(DialogContext innerDc) { + DialogTurnResult result = interrupt(innerDc).join(); + if (result != null) { + return CompletableFuture.completedFuture(result); + } + + return super.onContinueDialog(innerDc); + } + + private CompletableFuture interrupt(DialogContext innerDc) { + if (innerDc.getContext().getActivity().getType().equals(ActivityTypes.MESSAGE)) { + String text = innerDc.getContext().getActivity().getText().toLowerCase(); + + if (text.equals("logout")) { + // The bot adapter encapsulates the authentication processes. + BotFrameworkAdapter botAdapter = (BotFrameworkAdapter) innerDc.getContext().getAdapter(); + botAdapter.signOutUser(innerDc.getContext(), getConnectionName(), null).join(); + innerDc.getContext().sendActivity(MessageFactory.text("You have been signed out.")).join(); + return innerDc.cancelAllDialogs(); + } + } + + return CompletableFuture.completedFuture(null); + } + + /** + * @return the ConnectionName value as a String. + */ + protected String getConnectionName() { + return this.connectionName; + } + +} + diff --git a/samples/18.bot-authentication/src/main/java/com/microsoft/bot/sample/authentication/MainDialog.java b/samples/18.bot-authentication/src/main/java/com/microsoft/bot/sample/authentication/MainDialog.java new file mode 100644 index 000000000..5bbadb1da --- /dev/null +++ b/samples/18.bot-authentication/src/main/java/com/microsoft/bot/sample/authentication/MainDialog.java @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.authentication; + +import java.util.Arrays; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.dialogs.DialogTurnResult; +import com.microsoft.bot.dialogs.WaterfallDialog; +import com.microsoft.bot.dialogs.WaterfallStep; +import com.microsoft.bot.dialogs.WaterfallStepContext; +import com.microsoft.bot.dialogs.prompts.ConfirmPrompt; +import com.microsoft.bot.dialogs.prompts.OAuthPrompt; +import com.microsoft.bot.dialogs.prompts.OAuthPromptSettings; +import com.microsoft.bot.dialogs.prompts.PromptOptions; +import com.microsoft.bot.integration.Configuration; +import com.microsoft.bot.schema.TokenResponse; + +import org.springframework.stereotype.Component; + +@Component + class MainDialog extends LogoutDialog { + + public MainDialog(Configuration configuration) { + super("MainDialog", configuration.getProperty("ConnectionName")); + + OAuthPromptSettings settings = new OAuthPromptSettings(); + settings.setConnectionName(""); + settings.setText("Please Sign In"); + settings.setTitle("Sign In"); + settings.setConnectionName(configuration.getProperty("ConnectionName")); + settings.setTimeout(300000); // User has 5 minutes to login (1000 * 60 * 5) + + addDialog(new OAuthPrompt("OAuthPrompt", settings)); + + addDialog(new ConfirmPrompt("ConfirmPrompt")); + + WaterfallStep[] waterfallSteps = { + this::promptStep, + this::loginStep, + this::displayTokenPhase1, + this::displayTokenPhase2 + }; + + addDialog(new WaterfallDialog("WaterfallDialog", Arrays.asList(waterfallSteps))); + + // The initial child Dialog to run. + setInitialDialogId("WaterfallDialog"); + } + + private CompletableFuture promptStep(WaterfallStepContext stepContext) { + return stepContext.beginDialog("OAuthPrompt", null); + } + + private CompletableFuture loginStep(WaterfallStepContext stepContext) { + // Get the token from the previous step. Note that we could also have gotten the + // token directly from the prompt itself. There instanceof an example of this in the next method. + TokenResponse tokenResponse = (TokenResponse)stepContext.getResult(); + if (tokenResponse != null) { + stepContext.getContext().sendActivity(MessageFactory.text("You are now logged in.")); + PromptOptions options = new PromptOptions(); + options.setPrompt(MessageFactory.text("Would you like to view your token?")); + return stepContext.prompt("ConfirmPrompt", options); + } + + stepContext.getContext().sendActivity(MessageFactory.text("Login was not successful please try again.")); + return stepContext.endDialog(); + } + + private CompletableFuture displayTokenPhase1(WaterfallStepContext stepContext) { + stepContext.getContext().sendActivity(MessageFactory.text("Thank you.")); + + boolean result = (boolean)stepContext.getResult(); + if (result) { + // Call the prompt again because we need the token. The reasons for this are: + // 1. If the user instanceof already logged in we do not need to store the token locally in the bot and worry + // about refreshing it. We can always just call the prompt again to get the token. + // 2. We never know how long it will take a user to respond. By the time the + // user responds the token may have expired. The user would then be prompted to login again. + // + // There instanceof no reason to store the token locally in the bot because we can always just call + // the OAuth prompt to get the token or get a new token if needed. + return stepContext.beginDialog("OAuthPrompt"); + } + + return stepContext.endDialog(); + } + + private CompletableFuture displayTokenPhase2(WaterfallStepContext stepContext) { + TokenResponse tokenResponse = (TokenResponse)stepContext.getResult(); + if (tokenResponse != null) { + stepContext.getContext().sendActivity(MessageFactory.text( + String.format("Here instanceof your token %s", tokenResponse.getToken() + ))); + } + + return stepContext.endDialog(); + } +} + diff --git a/samples/18.bot-authentication/src/main/resources/application.properties b/samples/18.bot-authentication/src/main/resources/application.properties new file mode 100644 index 000000000..9af36b70a --- /dev/null +++ b/samples/18.bot-authentication/src/main/resources/application.properties @@ -0,0 +1,4 @@ +MicrosoftAppId= +MicrosoftAppPassword= +server.port=3978 +ConnectionName= diff --git a/samples/18.bot-authentication/src/main/resources/log4j2.json b/samples/18.bot-authentication/src/main/resources/log4j2.json new file mode 100644 index 000000000..67c0ad530 --- /dev/null +++ b/samples/18.bot-authentication/src/main/resources/log4j2.json @@ -0,0 +1,18 @@ +{ + "configuration": { + "name": "Default", + "appenders": { + "Console": { + "name": "Console-Appender", + "target": "SYSTEM_OUT", + "PatternLayout": {"pattern": "[%-5level] %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %c{1} - %msg%n"} + } + }, + "loggers": { + "root": { + "level": "debug", + "appender-ref": {"ref": "Console-Appender","level": "debug"} + } + } + } +} diff --git a/samples/18.bot-authentication/src/main/webapp/META-INF/MANIFEST.MF b/samples/18.bot-authentication/src/main/webapp/META-INF/MANIFEST.MF new file mode 100644 index 000000000..254272e1c --- /dev/null +++ b/samples/18.bot-authentication/src/main/webapp/META-INF/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Class-Path: + diff --git a/samples/18.bot-authentication/src/main/webapp/WEB-INF/web.xml b/samples/18.bot-authentication/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 000000000..383c19004 --- /dev/null +++ b/samples/18.bot-authentication/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,12 @@ + + + dispatcher + + org.springframework.web.servlet.DispatcherServlet + + + contextConfigLocation + /WEB-INF/spring/dispatcher-config.xml + + 1 + \ No newline at end of file diff --git a/samples/18.bot-authentication/src/main/webapp/index.html b/samples/18.bot-authentication/src/main/webapp/index.html new file mode 100644 index 000000000..d5ba5158e --- /dev/null +++ b/samples/18.bot-authentication/src/main/webapp/index.html @@ -0,0 +1,418 @@ + + + + + + + EchoBot + + + + + +

+
+
+
Spring Boot Bot
+
+
+
+
+
Your bot is ready!
+
You can test your bot in the Bot Framework Emulator
+ by connecting to http://localhost:3978/api/messages.
+ +
Visit Azure + Bot Service to register your bot and add it to
+ various channels. The bot's endpoint URL typically looks + like this:
+
https://your_bots_hostname/api/messages
+
+
+
+
+ +
+ + + diff --git a/samples/18.bot-authentication/src/test/java/com/microsoft/bot/sample/authentication/ApplicationTest.java b/samples/18.bot-authentication/src/test/java/com/microsoft/bot/sample/authentication/ApplicationTest.java new file mode 100644 index 000000000..fcb0cdd43 --- /dev/null +++ b/samples/18.bot-authentication/src/test/java/com/microsoft/bot/sample/authentication/ApplicationTest.java @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.authentication; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class ApplicationTest { + + @Test + public void contextLoads() { + } + +}