// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using Antlr4.Runtime.Misc; using Azure.Core; using Microsoft.Agents.Builder; using Microsoft.Agents.Builder.Dialogs; using Microsoft.Agents.Builder.Dialogs.Prompts; using Microsoft.Agents.Core.Models; using Microsoft.Agents.Extensions.Teams.Models; using Microsoft.Identity.Client; using System.Net; using System.Text.RegularExpressions; using System.IdentityModel.Tokens.Jwt; using {{YOUR_NAMESPACE}}.Configuration; using {{YOUR_NAMESPACE}}.SSO; using System.Text.Json; using Json.More; namespace {{YOUR_NAMESPACE}}; /// /// Creates a new prompt that leverage Teams Single Sign On (SSO) support for bot to automatically sign in user and /// help receive oauth token, asks the user to consent if needed. /// /// /// The prompt will attempt to retrieve the user's current token of the desired scopes. /// User will be automatically signed in leveraging Teams support of Bot Single Sign On(SSO): /// https://docs.microsoft.com/en-us/microsoftteams/platform/bots/how-to/authentication/auth-aad-sso-bots /// /// /// /// ## Prompt Usage /// /// When used with your bot's you can simply add a new instance of the prompt as a named /// dialog using . You can then start the prompt from a waterfall step using either /// or /// . The user /// will be prompted to signin as needed and their access token will be passed as an argument to /// the caller's next waterfall step. /// /// /// var convoState = new ConversationState(new MemoryStorage()); /// var dialogState = convoState.CreateProperty<DialogState>("dialogState"); /// var dialogs = new DialogSet(dialogState); /// var botAuthOptions = new BotAuthenticationOptions { /// ClientId = "{client_id_guid_value}", /// ClientSecret = "{client_secret_value}", /// TenantId = "{tenant_id_guid_value}", /// ApplicationIdUri = "{application_id_uri_value}", /// OAuthAuthority = "https://login.microsoftonline.com/{tenant_id_guid_value}", /// LoginStartPageEndpoint = "https://{bot_web_app_domain}/bot-auth-start" /// }; /// /// var scopes = new string[] { "User.Read" }; /// var teamsBotSsoPromptSettings = new TeamsBotSsoPromptSettings(botAuthOptions, scopes); /// /// dialogs.Add(new TeamsBotSsoPrompt("{unique_id_for_the_prompt}", teamsBotSsoPromptSettings)); /// dialogs.Add(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[] /// { /// async(WaterfallStepContext stepContext, CancellationToken cancellationToken) => { /// return await stepContext.BeginDialogAsync(nameof(TeamsBotSsoPrompt), null, cancellationToken); /// }, /// async(WaterfallStepContext stepContext, CancellationToken cancellationToken) => { /// var tokenResponse = (TeamsBotSsoPromptTokenResponse)stepContext.Result; /// if (tokenResponse?.Token != null) /// { /// // ... continue with task needing access token ... /// } /// else /// { /// await stepContext.Context.SendActivityAsync(MessageFactory.Text("Login was not successful please try again."), cancellationToken); /// } /// return await stepContext.EndDialogAsync(cancellationToken: cancellationToken); /// } /// })); /// /// /// public class TeamsBotSsoPrompt : Dialog { private readonly TeamsBotSsoPromptSettings _settings; private const string PersistedExpires = "expires"; internal IIdentityClientAdapter _identityClientAdapter { private get; set; } internal ITeamsInfo _teamsInfo { private get; set; } /// /// Initializes a new instance of the class. /// /// The ID to assign to this prompt. /// Additional OAuth settings to use with this instance of the prompt. /// The value of must be unique within the /// or to which the prompt is added. /// When input parameters is null. public TeamsBotSsoPrompt(string dialogId, TeamsBotSsoPromptSettings settings) : base(dialogId) { if (string.IsNullOrWhiteSpace(dialogId)) { throw new Exception($"Parameter {nameof(dialogId)} is null or empty."); } _settings = settings ?? throw new Exception($"Parameter {nameof(settings)} is null or empty."); var confidentialClientApplication = ConfidentialClientApplicationBuilder.Create(_settings.BotAuthOptions.ClientId) .WithClientSecret(_settings.BotAuthOptions.ClientSecret) .WithAuthority(_settings.BotAuthOptions.OAuthAuthority) .Build(); _identityClientAdapter = new IdentityClientAdapter(confidentialClientApplication); _teamsInfo = new TeamsInfoWrapper(); } /// /// Called when the dialog is started and pushed onto the dialog stack. /// /// The Microsoft.Bot.Builder.Dialogs.DialogContext for the current turn of conversation. /// Optional, initial information to pass to the dialog. /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// A System.Threading.Tasks.Task representing the asynchronous operation. /// if dialog context argument is null public override async Task BeginDialogAsync(DialogContext dialogContext, object options = null, CancellationToken cancellationToken = default) { if (dialogContext == null) { throw new Exception($"Parameter {nameof(dialogContext)} is null or empty."); } EnsureMsTeamsChannel(dialogContext); var state = dialogContext.ActiveDialog?.State; state[PersistedExpires] = DateTime.UtcNow.AddMilliseconds(_settings.Timeout); // Send OAuthCard that tells Teams to obtain an authentication token for the bot application. await SendOAuthCardToObtainTokenAsync(dialogContext.Context, cancellationToken).ConfigureAwait(false); return EndOfTurn; } /// /// Called when a prompt dialog is the active dialog and the user replied with a new activity. /// /// The for the current turn of conversation. /// A cancellation token that can be used by other objects /// or threads to receive notice of cancellation. /// A representing the asynchronous 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 ends on invalid message from user's reply. /// When failed to login with unknown error. /// When failed to get access token from identity server(AAD). public override async Task ContinueDialogAsync(DialogContext dc, CancellationToken cancellationToken = default(CancellationToken)) { EnsureMsTeamsChannel(dc); // Check for timeout var state = dc.ActiveDialog?.State; bool isMessage = (dc.Context.Activity.Type == ActivityTypes.Message); bool isTimeoutActivityType = isMessage || IsTeamsVerificationInvoke(dc.Context) || IsTokenExchangeRequestInvoke(dc.Context); // If the incoming Activity is a message, or an Activity Type normally handled by TeamsBotSsoPrompt, // check to see if this TeamsBotSsoPrompt Expiration has elapsed, and end the dialog if so. bool hasTimedOut = isTimeoutActivityType && DateTime.Compare(DateTime.UtcNow, (DateTime)state[PersistedExpires]) > 0; if (hasTimedOut) { return await dc.EndDialogAsync(cancellationToken: cancellationToken).ConfigureAwait(false); } else { if (IsTeamsVerificationInvoke(dc.Context) || IsTokenExchangeRequestInvoke(dc.Context)) { // Recognize token PromptRecognizerResult recognized = await RecognizeTokenAsync(dc, cancellationToken).ConfigureAwait(false); if (recognized.Succeeded) { return await dc.EndDialogAsync(recognized.Value, cancellationToken).ConfigureAwait(false); } } else if (isMessage) { return await dc.EndDialogAsync(cancellationToken: cancellationToken).ConfigureAwait(false); } return EndOfTurn; } } /// /// This is intended for internal use. /// /// DialogContext. /// CancellationToken. /// PromptRecognizerResult. /// When failed to login with unknown error. /// When failed to get access token from identity server(AAD). private async Task> RecognizeTokenAsync(DialogContext dc, CancellationToken cancellationToken) { ITurnContext context = dc.Context; var result = new PromptRecognizerResult(); TeamsBotSsoPromptTokenResponse tokenResponse = null; if (IsTokenExchangeRequestInvoke(context)) { var tokenResponseObject = context.Activity.Value.ToJsonDocument(); string ssoToken = tokenResponseObject.RootElement.GetProperty("token").ToString(); // Received activity is not a token exchange request if (String.IsNullOrEmpty(ssoToken)) { var warningMsg = "The bot received an InvokeActivity that is missing a TokenExchangeInvokeRequest value. This is required to be sent with the InvokeActivity."; await SendInvokeResponseAsync(context, HttpStatusCode.BadRequest, warningMsg, cancellationToken).ConfigureAwait(false); } else { try { var exchangedToken = await GetToken(ssoToken, _settings.Scopes).ConfigureAwait(false); var ssoTokenObj = ParseJwt(ssoToken); var ssoExpiration = DateTimeOffset.FromUnixTimeSeconds(long.Parse(ssoTokenObj.Payload["exp"].ToString())); tokenResponse = new TeamsBotSsoPromptTokenResponse { SsoToken = ssoToken, SsoTokenExpiration = ssoExpiration.ToString(), Token = exchangedToken.Token, Expiration = exchangedToken.ExpiresOn.ToString(), ConnectionName = "fakeConnectionName" }; await SendInvokeResponseAsync(context, HttpStatusCode.OK, null, cancellationToken).ConfigureAwait(false); } catch (MsalUiRequiredException) // Need user interaction { var warningMsg = "The bot is unable to exchange token. Ask for user consent first."; await SendInvokeResponseAsync(context, HttpStatusCode.PreconditionFailed, new TokenExchangeInvokeResponse { Id = context.Activity.Id, FailureDetail = warningMsg, }, cancellationToken).ConfigureAwait(false); } catch (MsalServiceException ex) // Errors that returned from AAD service { throw new Exception($"Failed to get access token from OAuth identity server with error: {ex.ResponseBody}"); } catch (MsalClientException ex) // Exceptions that are local to the MSAL library { throw new Exception($"Failed to get access token with error: {ex.Message}"); } } } else if (IsTeamsVerificationInvoke(context)) { await SendOAuthCardToObtainTokenAsync(context, cancellationToken).ConfigureAwait(false); await SendInvokeResponseAsync(context, HttpStatusCode.OK, null, cancellationToken).ConfigureAwait(false); } if (tokenResponse != null) { result.Succeeded = true; result.Value = tokenResponse; } else { result.Succeeded = false; } return result; } private async Task GetToken(string ssoToken, string[] scopes) { AccessToken result; var ssoTokenObj = ParseJwt(ssoToken); var ssoTokenExpiration = DateTimeOffset.FromUnixTimeSeconds(long.Parse(ssoTokenObj.Payload["exp"].ToString())); // Get sso token if (scopes.Length == 0) { if (DateTimeOffset.Compare(DateTimeOffset.UtcNow, ssoTokenExpiration) > 0) { throw new Exception("SSO token has already expired."); } result = new AccessToken(ssoToken, ssoTokenExpiration); } else { var authenticationResult = await _identityClientAdapter.GetAccessToken(ssoToken, scopes).ConfigureAwait(false); result = new AccessToken(authenticationResult.AccessToken, authenticationResult.ExpiresOn); } return result; } private static async Task SendInvokeResponseAsync(ITurnContext turnContext, HttpStatusCode statusCode, object body, CancellationToken cancellationToken) { await turnContext.SendActivityAsync( new Activity { Type = ActivityTypes.InvokeResponse, Value = new InvokeResponse { Status = (int)statusCode, Body = body, }, }, cancellationToken).ConfigureAwait(false); } private bool IsTeamsVerificationInvoke(ITurnContext context) { return (context.Activity.Type == ActivityTypes.Invoke) && (context.Activity.Name == SignInConstants.VerifyStateOperationName); } private bool IsTokenExchangeRequestInvoke(ITurnContext context) { return (context.Activity.Type == ActivityTypes.Invoke) && (context.Activity.Name == SignInConstants.TokenExchangeOperationName); } /// /// Send OAuthCard that tells Teams to obtain an authentication token for the bot application. /// For details see https://docs.microsoft.com/en-us/microsoftteams/platform/bots/how-to/authentication/auth-aad-sso-bots. /// /// ITurnContext /// CancellationToken. /// The task to await. private async Task SendOAuthCardToObtainTokenAsync(ITurnContext context, CancellationToken cancellationToken) { TeamsChannelAccount account = await _teamsInfo.GetTeamsMemberAsync(context, context.Activity.From.Id, cancellationToken).ConfigureAwait(false); string loginHint = account.UserPrincipalName ?? ""; if (String.IsNullOrEmpty(account.TenantId)) { throw new Exception("Failed to get tenant id through bot framework."); } string tenantId = account.TenantId ?? ""; SignInResource signInResource = GetSignInResource(loginHint, tenantId); // Ensure prompt initialized IActivity prompt = Activity.CreateMessageActivity(); prompt.Attachments = new List(); prompt.Attachments.Add(new Attachment { ContentType = OAuthCard.ContentType, Content = new OAuthCard { Text = "Sign In", ConnectionName = "fakeConnectionName", Buttons = new[] { new CardAction { Title = "Teams SSO Sign In", Value = signInResource.SignInLink, Type = ActionTypes.Signin, }, }, TokenExchangeResource = signInResource.TokenExchangeResource, }, }); // Send prompt await context.SendActivityAsync(prompt, cancellationToken).ConfigureAwait(false); } /// /// Get sign in authentication configuration /// /// login hint /// tenant id /// sign in resource private SignInResource GetSignInResource(string loginHint, string tenantId) { string signInLink = $"{_settings.BotAuthOptions.InitiateLoginEndpoint}?scope={Uri.EscapeDataString(string.Join(" ", _settings.Scopes))}&clientId={_settings.BotAuthOptions.ClientId}&tenantId={tenantId}&loginHint={loginHint}"; SignInResource signInResource = new SignInResource { SignInLink = signInLink, TokenExchangeResource = new TokenExchangeResource { Id = Guid.NewGuid().ToString(), Uri = Regex.Replace(_settings.BotAuthOptions.ApplicationIdUri, @"/\/$/", "") + "/access_as_user" } }; return signInResource; } /// /// Ensure bot is running in MS Teams since TeamsBotSsoPrompt is only supported in MS Teams channel. /// /// dialog context /// if bot channel is not MS Teams private void EnsureMsTeamsChannel(DialogContext dc) { if (dc.Context.Activity.ChannelId != Channels.Msteams) { var errorMessage = "Teams Bot SSO Prompt is only supported in MS Teams Channel"; throw new Exception(errorMessage); } } private static JwtSecurityToken ParseJwt(string token) { if (string.IsNullOrEmpty(token)) { throw new Exception("SSO token is null or empty."); } var handler = new JwtSecurityTokenHandler(); try { var jsonToken = handler.ReadToken(token); if (jsonToken is not JwtSecurityToken tokenS || string.IsNullOrEmpty(tokenS.Payload["exp"].ToString())) { throw new Exception("Decoded token is null or exp claim does not exists."); } return tokenS; } catch (ArgumentException e) { var errorMessage = $"Parse jwt token failed with error: {e.Message}"; throw new Exception(errorMessage); } } }