Initial commit

This commit is contained in:
2025-03-07 19:22:02 +01:00
commit 4a98255d83
55743 changed files with 5280367 additions and 0 deletions
@@ -0,0 +1,65 @@
@page "/bot-auth-end.html"
<html>
<head>
<title>Login End Page</title>
<meta charset="utf-8" />
</head>
<body>
<script src="https://res.cdn.office.net/teams-js/2.17.0/js/MicrosoftTeams.min.js"
integrity="sha384-xp55t/129OsN192JZYLP0rGhzjCF9aYtjY0LVtXvolkDrBe4Jchylp56NrUYJ4S2"
crossorigin="anonymous"></script>
<div id="divError"></div>
<script type="text/javascript">
microsoftTeams.app.initialize().then(() => {
let hashParams = getHashParameters();
if (hashParams["error"]) {
// Authentication failed
handleAuthError(hashParams["error"], hashParams);
} else if (hashParams["code"]) {
// Get the stored state parameter and compare with incoming state
let expectedState = localStorage.getItem("state");
if (expectedState !== hashParams["state"]) {
// State does not match, report error
handleAuthError("StateDoesNotMatch", hashParams);
} else {
microsoftTeams.authentication.notifySuccess();
}
} else {
// Unexpected condition: hash does not contain error or access_token parameter
handleAuthError("UnexpectedFailure", hashParams);
}
});
// Parse hash parameters into key-value pairs
function getHashParameters() {
let hashParams = {};
location.hash
.substr(1)
.split("&")
.forEach(function (item) {
let s = item.split("="),
k = s[0],
v = s[1] && decodeURIComponent(s[1]);
hashParams[k] = v;
});
return hashParams;
}
// Show error information
function handleAuthError(errorType, errorMessage) {
const err = JSON.stringify({
error: errorType,
message: JSON.stringify(errorMessage),
});
let para = document.createElement("p");
let node = document.createTextNode(err);
para.appendChild(node);
let element = document.getElementById("divError");
element.appendChild(para);
}
</script>
</body>
</html>
@@ -0,0 +1,168 @@
@page "/bot-auth-start"
<!--This file is used during the Teams Bot authentication flow to assist with retrieval of the access token.-->
<!--If you're not familiar with this, do not alter or remove this file from your project.-->
<html>
<head>
<title>Login Start Page</title>
<meta charset="utf-8" />
</head>
<body>
<script type="text/javascript">
popUpSignInWindow();
async function popUpSignInWindow() {
// Generate random state string and store it, so we can verify it in the callback
let state = _guid();
localStorage.setItem("state", state);
localStorage.removeItem("codeVerifier");
var currentURL = new URL(window.location);
var clientId = currentURL.searchParams.get("clientId");
var tenantId = currentURL.searchParams.get("tenantId");
var loginHint = currentURL.searchParams.get("loginHint");
if (!loginHint) {
loginHint = "";
}
var scope = currentURL.searchParams.get("scope");
var originalCode = _guid();
var codeChallenge = await pkceChallengeFromVerifier(originalCode);
localStorage.setItem("codeVerifier", originalCode);
let queryParams = {
client_id: clientId,
response_type: "code",
response_mode: "fragment",
scope: scope,
redirect_uri: window.location.origin + "/bot-auth-end.html",
nonce: _guid(),
login_hint: loginHint,
state: state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
};
let authorizeEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?${toQueryString(
queryParams
)}`;
window.location.assign(authorizeEndpoint);
}
// Build query string from map of query parameter
function toQueryString(queryParams) {
let encodedQueryParams = [];
for (let key in queryParams) {
encodedQueryParams.push(key + "=" + encodeURIComponent(queryParams[key]));
}
return encodedQueryParams.join("&");
}
// Converts decimal to hex equivalent
// (From ADAL.js: https://github.com/AzureAD/azure-activedirectory-library-for-js/blob/dev/lib/adal.js)
function _decimalToHex(number) {
var hex = number.toString(16);
while (hex.length < 2) {
hex = "0" + hex;
}
return hex;
}
// Generates RFC4122 version 4 guid (128 bits)
// (From ADAL.js: https://github.com/AzureAD/azure-activedirectory-library-for-js/blob/dev/lib/adal.js)
function _guid() {
// RFC4122: The version 4 UUID is meant for generating UUIDs from truly-random or
// pseudo-random numbers.
// The algorithm is as follows:
// Set the two most significant bits (bits 6 and 7) of the
// clock_seq_hi_and_reserved to zero and one, respectively.
// Set the four most significant bits (bits 12 through 15) of the
// time_hi_and_version field to the 4-bit version number from
// Section 4.1.3. Version4
// Set all the other bits to randomly (or pseudo-randomly) chosen
// values.
// UUID = time-low "-" time-mid "-"time-high-and-version "-"clock-seq-reserved and low(2hexOctet)"-" node
// time-low = 4hexOctet
// time-mid = 2hexOctet
// time-high-and-version = 2hexOctet
// clock-seq-and-reserved = hexOctet:
// clock-seq-low = hexOctet
// node = 6hexOctet
// Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
// y could be 1000, 1001, 1010, 1011 since most significant two bits needs to be 10
// y values are 8, 9, A, B
var cryptoObj = window.crypto || window.msCrypto; // for IE 11
if (cryptoObj && cryptoObj.getRandomValues) {
var buffer = new Uint8Array(16);
cryptoObj.getRandomValues(buffer);
//buffer[6] and buffer[7] represents the time_hi_and_version field. We will set the four most significant bits (4 through 7) of buffer[6] to represent decimal number 4 (UUID version number).
buffer[6] |= 0x40; //buffer[6] | 01000000 will set the 6 bit to 1.
buffer[6] &= 0x4f; //buffer[6] & 01001111 will set the 4, 5, and 7 bit to 0 such that bits 4-7 == 0100 = "4".
//buffer[8] represents the clock_seq_hi_and_reserved field. We will set the two most significant bits (6 and 7) of the clock_seq_hi_and_reserved to zero and one, respectively.
buffer[8] |= 0x80; //buffer[8] | 10000000 will set the 7 bit to 1.
buffer[8] &= 0xbf; //buffer[8] & 10111111 will set the 6 bit to 0.
return (
_decimalToHex(buffer[0]) +
_decimalToHex(buffer[1]) +
_decimalToHex(buffer[2]) +
_decimalToHex(buffer[3]) +
"-" +
_decimalToHex(buffer[4]) +
_decimalToHex(buffer[5]) +
"-" +
_decimalToHex(buffer[6]) +
_decimalToHex(buffer[7]) +
"-" +
_decimalToHex(buffer[8]) +
_decimalToHex(buffer[9]) +
"-" +
_decimalToHex(buffer[10]) +
_decimalToHex(buffer[11]) +
_decimalToHex(buffer[12]) +
_decimalToHex(buffer[13]) +
_decimalToHex(buffer[14]) +
_decimalToHex(buffer[15])
);
} else {
var guidHolder = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx";
var hex = "0123456789abcdef";
var r = 0;
var guidResponse = "";
for (var i = 0; i < 36; i++) {
if (guidHolder[i] !== "-" && guidHolder[i] !== "4") {
// each x and y needs to be random
r = (Math.random() * 16) | 0;
}
if (guidHolder[i] === "x") {
guidResponse += hex[r];
} else if (guidHolder[i] === "y") {
// clock-seq-and-reserved first hex is filtered and remaining hex values are random
r &= 0x3; // bit and with 0011 to set pos 2 to zero ?0??
r |= 0x8; // set pos 3 to 1 as 1???
guidResponse += hex[r];
} else {
guidResponse += guidHolder[i];
}
}
return guidResponse;
}
}
// Calculate the SHA256 hash of the input text.
// Returns a promise that resolves to an ArrayBuffer
function sha256(plain) {
const encoder = new TextEncoder();
const data = encoder.encode(plain);
return window.crypto.subtle.digest("SHA-256", data);
}
// Base64-urlencodes the input string
function base64urlencode(str) {
// Convert the ArrayBuffer to string using Uint8 array to convert to what btoa accepts.
// btoa accepts chars only within ascii 0-255 and base64 encodes them.
// Then convert the base64 encoded to base64url encoded
// (replace + with -, replace / with _, trim trailing =)
return btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
// Return the base64-urlencoded sha256 hash for the PKCE challenge
async function pkceChallengeFromVerifier(v) {
hashed = await sha256(v);
return base64urlencode(hashed);
}
</script>
</body>
</html>
@@ -0,0 +1,230 @@
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Schema;
using Microsoft.Extensions.Options;
using Microsoft.TeamsFx.Bot;
using Microsoft.TeamsFx.Configuration;
namespace {{YOUR_NAMESPACE}}.SSO;
public class DialogConstants
{
internal static readonly string COMMAND_ROUTE_DIALOG = "CommandRouteDialog";
internal static readonly string TEAMS_SSO_PROMPT = "TeamsFxSsoPrompt";
}
public class StoreItem
{
public string eTag;
}
public class SsoDialog : ComponentDialog
{
ILogger<SsoDialog> _logger;
BotAuthenticationOptions _botAuthOptions;
Dictionary<string, string> _commandMapping = new Dictionary<string, string>();
IStorage _dedupStorage = new MemoryStorage();
List<string> _dedupStorageKeys = new List<string>() {};
public SsoDialog(IOptions<BotAuthenticationOptions> botAuthenticationOptions, ILogger<SsoDialog> logger)
{
_logger = logger;
InitialDialogId = DialogConstants.COMMAND_ROUTE_DIALOG;
try
{
_logger.LogTrace("Validate bot authentication configuration");
_botAuthOptions = botAuthenticationOptions.Value;
}
catch (OptionsValidationException e)
{
throw new Exception($"Bot authentication config is missing or not correct with error: {e.Message}");
}
var settings = new TeamsBotSsoPromptSettings(_botAuthOptions, new string[] { "User.Read" });
AddDialog(new TeamsBotSsoPrompt(DialogConstants.TEAMS_SSO_PROMPT, settings));
WaterfallDialog commandRouteDialog = new WaterfallDialog(
DialogConstants.COMMAND_ROUTE_DIALOG,
new WaterfallStep[]
{
CommandRouteStepAsync
});
AddDialog(commandRouteDialog);
_logger.LogInformation("Construct Main Dialog");
}
public async Task RunAsync(ITurnContext context, IStatePropertyAccessor<DialogState> accessor)
{
DialogSet dialogSet = new DialogSet(accessor);
dialogSet.Add(this);
DialogContext dialogContext = await dialogSet.CreateContextAsync(context);
DialogTurnResult results = await dialogContext.ContinueDialogAsync();
if (results != null && results.Status == DialogTurnStatus.Empty)
{
await dialogContext.BeginDialogAsync(Id);
}
}
public void addCommand(
string commandId,
string commandText,
Func<ITurnContext, string, BotAuthenticationOptions, Task> operation)
{
if (_commandMapping.ContainsValue(commandId))
{
return;
}
_commandMapping.Add(commandText, commandId);
WaterfallDialog dialog = new WaterfallDialog(
commandId,
new WaterfallStep[]
{
PromptStepAsync,
DedupStepAsync,
async (WaterfallStepContext stepContext, CancellationToken cancellationToken) => {
TeamsBotSsoPromptTokenResponse tokenResponce = (TeamsBotSsoPromptTokenResponse)stepContext.Result;
var turnContext = stepContext.Context;
try
{
if (tokenResponce != null)
{
await operation(turnContext, tokenResponce.Token, _botAuthOptions);
} else
{
await turnContext.SendActivityAsync("Failed to retrieve user token from conversation context.");
}
return await stepContext.EndDialogAsync();
} catch (Exception error)
{
await turnContext.SendActivityAsync("Failed to retrieve user token from conversation context.");
await turnContext.SendActivityAsync(error.Message);
return await stepContext.EndDialogAsync();
}
}
});
AddDialog(dialog);
}
protected override async Task OnEndDialogAsync(ITurnContext context, DialogInstance instance, DialogReason reason, CancellationToken cancellationToken = default(CancellationToken))
{
var conversationId = context.Activity.Conversation.Id;
var currentDedupKeys = _dedupStorageKeys.Where((key) => key.IndexOf(conversationId) > 0).ToArray();
await _dedupStorage.DeleteAsync(currentDedupKeys);
_dedupStorageKeys = _dedupStorageKeys.Where((key) => key.IndexOf(conversationId) < 0).ToList<string>();
}
private async Task<DialogTurnResult> PromptStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
_logger.LogInformation("Step: Prompt to get SSO token");
try
{
return await stepContext.BeginDialogAsync(DialogConstants.TEAMS_SSO_PROMPT, null, cancellationToken);
} catch (Exception error)
{
await stepContext.Context.SendActivityAsync("Failed to run SSO prompt");
await stepContext.Context.SendActivityAsync(error.Message);
return await stepContext.EndDialogAsync();
}
}
private async Task<DialogTurnResult> CommandRouteStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
_logger.LogInformation("Step: Route to pre-added commands");
var turnContext = stepContext.Context;
string text = turnContext.Activity.RemoveRecipientMention();
if (text != null)
{
text = text.ToLower().Trim();
}
string commandId = MatchCommands(text);
if (commandId != null)
{
return await stepContext.BeginDialogAsync(commandId);
}
await stepContext.Context.SendActivityAsync(String.Format("Cannot find command: {0}", text));
return await stepContext.EndDialogAsync();
}
private async Task<DialogTurnResult> DedupStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
try
{
var tokenResponse = stepContext.Result;
if (tokenResponse != null && (await ShouldDedup(stepContext.Context)))
{
return EndOfTurn;
}
return await stepContext.NextAsync(tokenResponse);
}
catch (Exception error)
{
await stepContext.Context.SendActivityAsync("Failed to run dedup step");
await stepContext.Context.SendActivityAsync(error.Message);
return await stepContext.EndDialogAsync();
}
}
private async Task<bool> ShouldDedup(ITurnContext context)
{
var storeItem = new StoreItem()
{
eTag = (context.Activity.Value as dynamic).id,
};
var key = GetStorageKey(context);
var storeItems = new Dictionary<string, object>()
{
{key, storeItem}
};
var res = await _dedupStorage.ReadAsync(new string[] { key });
if (res.Count != 0)
{
return true;
}
await _dedupStorage.WriteAsync(storeItems);
_dedupStorageKeys.Add(key);
return false;
}
private string GetStorageKey(ITurnContext context)
{
if (context == null || context.Activity == null || context.Activity.Conversation == null)
{
throw new Exception("Invalid context, can not get storage key!");
}
var activity = context.Activity;
var channelId = activity.ChannelId;
var conversationId = activity.Conversation.Id;
if (activity.Type != ActivityTypes.Invoke || activity.Name != SignInConstants.TokenExchangeOperationName)
{
throw new Exception("TokenExchangeState can only be used with Invokes of signin/tokenExchange.");
}
var value = activity.Value;
if (value == null || (value as dynamic).id == null)
{
throw new Exception("Invalid signin/tokenExchange. Missing activity.value.id.");
}
return $"{channelId}/{conversationId}/{(value as dynamic).id}";
}
private string MatchCommands(string text)
{
if (_commandMapping.ContainsKey(text))
{
return _commandMapping[text];
}
return null;
}
}
@@ -0,0 +1,37 @@
using Microsoft.Bot.Builder;
using Microsoft.Graph;
using Microsoft.Kiota.Abstractions.Authentication;
using Microsoft.TeamsFx.Configuration;
namespace {{YOUR_NAMESPACE}}.SSO;
public class TokenProvider : IAccessTokenProvider
{
private string token { get; set; }
public TokenProvider(String token)
{
this.token = token;
}
public Task<string> GetAuthorizationTokenAsync(Uri uri, Dictionary<string, object> additionalAuthenticationContext = default,
CancellationToken cancellationToken = default)
{
// get the token and return it
return Task.FromResult(this.token);
}
public AllowedHostsValidator AllowedHostsValidator { get; }
}
public static class SsoOperations
{
public static async Task ShowUserInfo(ITurnContext stepContext, string token, BotAuthenticationOptions botAuthOptions)
{
await stepContext.SendActivityAsync("Retrieving user information from Microsoft Graph ...");
var tokenCredential = new BaseBearerTokenAuthenticationProvider(new TokenProvider(token));
var graphClient = new GraphServiceClient(tokenCredential);
var profile = await graphClient.Me.GetAsync();
await stepContext.SendActivityAsync($"You're logged in as {profile.DisplayName}");
}
}
@@ -0,0 +1,50 @@
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Teams;
using Microsoft.Bot.Schema;
using Microsoft.Bot.Builder.Dialogs;
namespace {{YOUR_NAMESPACE}}.SSO;
public class TeamsSsoBot<T> : TeamsActivityHandler where T : Dialog
{
private readonly ILogger<TeamsSsoBot<T>> _logger;
private readonly BotState _conversationState;
private readonly Dialog _dialog;
private readonly IStatePropertyAccessor<DialogState> _dialogState;
public TeamsSsoBot(ConversationState conversationState, T dialog, ILogger<TeamsSsoBot<T>> logger)
{
Console.WriteLine("sso bot init");
_conversationState = conversationState;
_dialog = dialog;
_logger = logger;
_dialogState = _conversationState.CreateProperty<DialogState>("DialogState");
((SsoDialog)_dialog).addCommand("showUserInfo", "show", SsoOperations.ShowUserInfo);
}
protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
{
Console.WriteLine("Receive message activity");
_logger.LogInformation("Receive message activity");
await ((SsoDialog)_dialog).RunAsync(turnContext, _dialogState);
}
protected override async Task OnTeamsSigninVerifyStateAsync(ITurnContext<IInvokeActivity> turnContext, CancellationToken cancellationToken)
{
_logger.LogInformation("Receive invoke activity of teams sign in verify state");
await _dialog.RunAsync(turnContext, _conversationState.CreateProperty<DialogState>(nameof(DialogState)), cancellationToken);
}
protected override async Task OnSignInInvokeAsync(ITurnContext<IInvokeActivity> turnContext, CancellationToken cancellationToken)
{
_logger.LogInformation("Receive invoke activity of sign in");
await _dialog.RunAsync(turnContext, _conversationState.CreateProperty<DialogState>(nameof(DialogState)), cancellationToken);
}
public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
{
await base.OnTurnAsync(turnContext, cancellationToken);
await _conversationState.SaveChangesAsync(turnContext, false, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,16 @@
Enable Single Sign-on for Teams Applications
-------------------------
On click of Visual Studio menu Project -> Teams Toolkit -> Add Authentication Code, couple of files for Single Sign-on are generated in "TeamsFx-Auth" folder, including a manifest template file for Microsoft Entra application and authentication redirect pages.
Teams Toolkit helps you generate the authentication files, then you will need to link the files to your Teams application by updating authentication configurations to make sure the Single Sign-on works for your application. Please be noted that for different Teams application type like Tab or Bot, the detailed steps are slightly different.
Basically, you will need to take care of these configurations:
* In the Microsoft Entra manifest file, you need to specify URIs such as the URI to identify the Microsoft Entra authentication app and the redirect URI for returning token.
* In the Teams manifest file, add the SSO application to link it with Teams application.
* Add SSO application information in Teams Toolkit configuration files in order to make sure the authentication app can be registered on backend service and started by Teams Toolkit when you debugging or previewing Teams application.
Refer to the step-by-step guide for Teams Tab Application at https://aka.ms/teams-toolkit-vs-add-SSO-tab.
Refer to the step-by-step guide for Teams Bot Applications at https://aka.ms/teams-toolkit-vs-add-SSO-bot. This guide use Command and Response bot as an example to show case how to enable SSO.
@@ -0,0 +1,98 @@
@using System.IO
@using Azure.Core
@using Microsoft.Graph
@using Microsoft.Graph.Models
@inject TeamsFx teamsfx
@inject TeamsUserCredential teamsUserCredential
<div>
<h2>Get the user's profile</h2>
@if (NeedConsent)
{
<p>Click below to authorize this app to read your profile photo using Microsoft Graph.</p>
<FluentButton Appearance="Appearance.Accent" Disabled="@IsLoading" @onclick="ConsentAndShow">Authorize</FluentButton>
}
else if (!string.IsNullOrEmpty(@ErrorMessage))
{
<div class="error">@ErrorMessage</div>
}
else if (Profile != null)
{
<div>Hello @Profile.DisplayName</div>
}
</div>
@code {
[Parameter]
public string ErrorMessage { get; set; }
public bool IsLoading { get; set; }
public bool NeedConsent { get; set; }
public User Profile { get; set; }
private readonly string _scope = "User.Read";
protected override async Task OnInitializedAsync()
{
IsLoading = true;
if (await HasPermission(_scope))
{
await ShowProfile();
}
}
private async Task ShowProfile()
{
IsLoading = true;
var graph = GetGraphServiceClient();
Profile = await graph.Me.GetAsync();
IsLoading = false;
ErrorMessage = string.Empty;
}
private async Task ConsentAndShow()
{
try
{
await teamsUserCredential.LoginAsync(_scope);
NeedConsent = false;
await ShowProfile();
}
catch (ExceptionWithCode e)
{
ErrorMessage = e.Message;
}
}
private async Task<bool> HasPermission(string scope)
{
IsLoading = true;
try
{
await teamsUserCredential.GetTokenAsync(new TokenRequestContext(new string[] { _scope }), new System.Threading.CancellationToken());
return true;
}
catch (ExceptionWithCode e)
{
if (e.Code == ExceptionCode.UiRequiredError)
{
NeedConsent = true;
}
else
{
ErrorMessage = e.Message;
}
}
IsLoading = false;
return false;
}
private GraphServiceClient GetGraphServiceClient()
{
var client = new GraphServiceClient(teamsUserCredential, new string[] { _scope });
return client;
}
}
@@ -0,0 +1,102 @@
{
"id": "${{AAD_APP_OBJECT_ID}}",
"appId": "${{AAD_APP_CLIENT_ID}}",
"name": "YOUR_AAD_APP_NAME",
"accessTokenAcceptedVersion": 2,
"signInAudience": "AzureADMyOrg",
"optionalClaims": {
"idToken": [],
"accessToken": [
{
"name": "idtyp",
"source": null,
"essential": false,
"additionalProperties": []
}
],
"saml2Token": []
},
"requiredResourceAccess": [
{
"resourceAppId": "Microsoft Graph",
"resourceAccess": [
{
"id": "User.Read",
"type": "Scope"
}
]
}
],
"oauth2Permissions": [
{
"adminConsentDescription": "Allows Teams to call the app's web APIs as the current user.",
"adminConsentDisplayName": "Teams can access app's web APIs",
"id": "${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}",
"isEnabled": true,
"type": "User",
"userConsentDescription": "Enable Teams to call this app's web APIs with the same rights that you have",
"userConsentDisplayName": "Teams can access app's web APIs and make requests on your behalf",
"value": "access_as_user"
}
],
"preAuthorizedApplications": [
{
"appId": "1fec8e78-bce4-4aaf-ab1b-5451cc387264",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "5e3ce6c0-2b1f-4285-8d4b-75ee78787346",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "d3590ed6-52b3-4102-aeff-aad2292ab01c",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "00000002-0000-0ff1-ce00-000000000000",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "bc59ab01-8403-45c6-8796-ac3ef710b3e3",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "0ec893e0-5785-4de6-99da-4ed124e5296c",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "4765445b-32c6-49b0-83e6-1d93765276ca",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "4345a7b9-9a63-4910-a426-35363201d503",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "27922004-5251-4030-b22d-91ecd9a37ea4",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
}
],
"identifierUris": [
],
"replyUrlsWithType": [
]
}
@@ -0,0 +1,434 @@
# Enable single sign-on for bot applications
> Note: This document includes single sign-on instructions applicable for both bot and message extension. Make sure to add the corresponding Teams capability first and then follow the documentation.
Microsoft Teams provides a mechanism by which an application can obtain the signed-in Teams user token to access Microsoft Graph (and other APIs). Teams Toolkit facilitates this interaction by abstracting some of the Microsoft Entra ID flows and integrations behind some simple, high level APIs. This enables you to add single sign-on (SSO) features easily to your Teams application.
For a bot application, user can invoke the Microsoft Entra consent flow to obtain sso token to call Graph and other APIs.
<h2>Contents </h2>
- [Changes to your project](#1)
- [Update code to Use SSO for Bot](#2)
- [Set up the Microsoft Entra redirects](#2.1)
- [Update your business logic](#2.2)
- [(Optional) Add a new sso command to the bot](#2.3)
- [Update code to Use SSO for Message Extension](#3)
- [Debug your application](#4)
- [Customize Microsoft Entra applications](#5)
- [Trouble Shooting](#6)
<h2 id='1'>Changes to your project</h2>
When you added the SSO feature to your application, Teams Toolkit updated your project to support SSO:
After you successfully added SSO into your project, Teams Toolkit will create and modify some files that helps you implement SSO feature.
| Action | File | Description |
| ------ | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Modify | `azureWebAppBotConfig.bicep` under `templates/azure/teamsFx` and `azure.parameters.dev.json` under `.fx/configs` | Insert environment variables used for bot web app to enable SSO feature |
| Modify | `manifest.template.json` under `templates/appPackage` | An `webApplicationInfo` object will be added into your Teams app manifest template. This field is required by Teams when enabling SSO. |
| Modify | `projectSettings.json` under `.fx/configs` | Add bot sso capability, which will be used internally by Teams Toolkit. |
| Create | `aad.template.json` under `templates/appPackage` | The Microsoft Entra application manifest that is used to register the application with Microsoft Entra. |
| Create | `auth/bot` | Reference code, redirect pages and a `README.md` file. These files are provided for reference. See below for more information. |
<h2 id='2'>Update your code to Use SSO for Bot</h2>
As described above, the Teams Toolkit generated some configuration to set up your application for SSO, but you need to update your application business logic to take advantage of the SSO feature as appropriate.
> Note: The following part is for `command and response bot`. For `basic bot`, please refer to the [bot-sso sample](https://aka.ms/bot-sso-sample).
<h3 id='2.1'>Set up the Microsoft Entra redirects</h3>
1. Move the `auth/bot/public` folder to `bot/src`. This folder contains HTML pages that the bot application hosts. When single sign-on flows are initiated with Microsoft Entra, Microsoft Entra will redirect the user to these pages.
1. Modify your `bot/src/index` to add the appropriate `express` routes to these pages.
```ts
const path = require("path");
const send = require("send");
expressApp.get(["/auth-start.html", "/auth-end.html"], async (req, res) => {
send(
req,
path.join(
__dirname,
"public",
req.url.includes("auth-start.html") ? "auth-start.html" : "auth-end.html"
)
).pipe(res);
});
```
<h3 id='2.2'>Update your business logic</h3>
The sample business logic provides a sso command handler `ProfileSsoCommandHandler` that use a Microsoft Entra token to call Microsoft Graph. This token is obtained by using the logged-in Teams user token. The flow is brought together in a dialog that will display a consent dialog if required.
To make this work in your application:
1. Move `profileSsoCommandHandler` file under `auth/bot/sso` folder to `bot/src`. ProfileSsoCommandHandler class is a sso command handler to get user info with SSO token. You can follow this method and create your own sso command handler.
1. Open `package.json` file, make sure that teamsfx SDK version >= 2.2.0
1. Execute the following commands under `bot` folder: `npm install isomorphic-fetch --save`
1. (For ts only) Execute the following commands under `bot` folder: `npm install copyfiles --save-dev` and replace following line in package.json:
```json
"build": "tsc --build && shx cp -r ./src/adaptiveCards ./lib/src",
```
with:
```json
"build": "tsc --build && shx cp -r ./src/adaptiveCards ./lib/src && copyfiles src/public/*.html lib/",
```
By doing this, the HTML pages used for auth redirect will be copied when building this bot project.
1. After adding the following files, you need to update `bot/src/index` file.
Please replace the following code to make sso consent flow works:
```ts
server.post("/api/messages", async (req, res) => {
await commandBot.requestHandler(req, res);
});
```
with:
```ts
server.post("/api/messages", async (req, res) => {
await commandBot.requestHandler(req, res).catch((err) => {
// Error message including "412" means it is waiting for user's consent, which is a normal process of SSO, sholdn't throw this error.
if (!err.message.includes("412")) {
throw err;
}
});
});
```
1. Replace the options for `ConversationBot` instance in `bot/src/internal/initialize` to add the sso config and sso command handler:
```ts
export const commandBot = new ConversationBot({
...
command: {
enabled: true,
commands: [new HelloWorldCommandHandler()],
},
});
```
with:
```ts
import { ProfileSsoCommandHandler } from "../profileSsoCommandHandler";
export const commandBot = new ConversationBot({
...
// To learn more about ssoConfig, please refer teamsfx sdk document: https://docs.microsoft.com/microsoftteams/platform/toolkit/teamsfx-sdk
ssoConfig: {
aad :{
scopes:["User.Read"],
},
},
command: {
enabled: true,
commands: [new HelloWorldCommandHandler() ],
ssoCommands: [new ProfileSsoCommandHandler()],
},
});
```
1. Register your command in the Teams app manifest. Open `templates/appPackage/manifest.template.json`, and add following lines under `commands` in `commandLists` of your bot:
```json
{
"title": "profile",
"description": "Show user profile using Single Sign On feature"
}
```
<h3 id='2.3'>(Optional) Add a new sso command to the bot</h3>
After successfully add SSO in your project, you can also add a new sso command.
1. Create a new file (e.g. `photoSsoCommandHandler.ts` or `photoSsoCommandHandler.js`) under `bot/src/` and add your own business logic to call Graph API:
```TypeScript
// for TypeScript:
import { Activity, TurnContext, ActivityTypes } from "botbuilder";
import "isomorphic-fetch";
import {
CommandMessage,
TriggerPatterns,
TeamsFxBotSsoCommandHandler,
TeamsBotSsoPromptTokenResponse,
OnBehalfOfUserCredential,
OnBehalfOfCredentialAuthConfig,
} from "@microsoft/teamsfx";
import { Client } from "@microsoft/microsoft-graph-client";
import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials";
const oboAuthConfig: OnBehalfOfCredentialAuthConfig = {
authorityHost: process.env.M365_AUTHORITY_HOST,
clientId: process.env.M365_CLIENT_ID,
tenantId: process.env.M365_TENANT_ID,
clientSecret: process.env.M365_CLIENT_SECRET,
};
export class PhotoSsoCommandHandler implements TeamsFxBotSsoCommandHandler {
triggerPatterns: TriggerPatterns = "photo";
async handleCommandReceived(
context: TurnContext,
message: CommandMessage,
tokenResponse: TeamsBotSsoPromptTokenResponse,
): Promise<string | Partial<Activity> | void> {
await context.sendActivity("Retrieving user information from Microsoft Graph ...");
// Init OnBehalfOfUserCredential instance with SSO token
const oboCredential = new OnBehalfOfUserCredential(tokenResponse.ssoToken, oboAuthConfig);
// Create an instance of the TokenCredentialAuthenticationProvider by passing the tokenCredential instance and options to the constructor
const authProvider = new TokenCredentialAuthenticationProvider(oboCredential, {
scopes: ["User.Read"],
});
// Initialize Graph client instance with authProvider
const graphClient = Client.initWithMiddleware({
authProvider: authProvider,
});
let photoUrl = "";
try {
const photo = await graphClient.api("/me/photo/$value").get();
const arrayBuffer = await photo.arrayBuffer();
const buffer=Buffer.from(arrayBuffer, 'binary');
photoUrl = "data:image/png;base64," + buffer.toString("base64");
} catch {
// Could not fetch photo from user's profile, return empty string as placeholder.
}
if (photoUrl) {
const photoMessage: Partial<Activity> = {
type: ActivityTypes.Message,
text: 'This is your photo:',
attachments: [
{
name: 'photo.png',
contentType: 'image/png',
contentUrl: photoUrl
}
]
};
return photoMessage;
} else {
return "Could not retrieve your photo from Microsoft Graph. Please make sure you have uploaded your photo.";
}
}
}
```
```javascript
// for JavaScript:
const { ActivityTypes } = require("botbuilder");
require("isomorphic-fetch");
const { OnBehalfOfUserCredential } = require("@microsoft/teamsfx");
const { Client } = require("@microsoft/microsoft-graph-client");
const {
TokenCredentialAuthenticationProvider,
} = require("@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials");
const oboAuthConfig = {
authorityHost: process.env.M365_AUTHORITY_HOST,
clientId: process.env.M365_CLIENT_ID,
tenantId: process.env.M365_TENANT_ID,
clientSecret: process.env.M365_CLIENT_SECRET,
};
class PhotoSsoCommandHandler {
triggerPatterns = "photo";
async handleCommandReceived(context, message, tokenResponse) {
await context.sendActivity("Retrieving user information from Microsoft Graph ...");
// Init OnBehalfOfUserCredential instance with SSO token
const oboCredential = new OnBehalfOfUserCredential(tokenResponse.ssoToken, oboAuthConfig);
// Create an instance of the TokenCredentialAuthenticationProvider by passing the tokenCredential instance and options to the constructor
const authProvider = new TokenCredentialAuthenticationProvider(credential, {
scopes: ["User.Read"],
});
// Initialize Graph client instance with authProvider
const graphClient = Client.initWithMiddleware({
authProvider: authProvider,
});
let photoUrl = "";
try {
const photo = await graphClient.api("/me/photo/$value").get();
const arrayBuffer = await photo.arrayBuffer();
const buffer = Buffer.from(arrayBuffer, "binary");
photoUrl = "data:image/png;base64," + buffer.toString("base64");
} catch {
// Could not fetch photo from user's profile, return empty string as placeholder.
}
if (photoUrl) {
const photoMessage = {
type: ActivityTypes.Message,
text: "This is your photo:",
attachments: [
{
name: "photo.png",
contentType: "image/png",
contentUrl: photoUrl,
},
],
};
return photoMessage;
} else {
return "Could not retrieve your photo from Microsoft Graph. Please make sure you have uploaded your photo.";
}
}
}
module.exports = {
PhotoSsoCommandHandler,
};
```
1. Put `PhotoSsoCommandHandler` instance to `ssoCommands` array in `bot/src/internal/initialize.ts` as below:
```ts
// for TypeScript:
import { PhotoSsoCommandHandler } from "../photoSsoCommandHandler";
export const commandBot = new ConversationBot({
...
command: {
...
ssoCommands: [new ProfileSsoCommandHandler(), new PhotoSsoCommandHandler()],
},
});
```
```javascript
// for JavaScript:
...
const { PhotoSsoCommandHandler } = require("../photoSsoCommandHandler");
const commandBot = new ConversationBot({
...
command: {
...
ssoCommands: [new ProfileSsoCommandHandler(), new PhotoSsoCommandHandler()]
},
});
...
```
1. Register your command in the Teams app manifest. Open 'templates/appPackage/manifest.template.json', and add following lines under `commands` in `commandLists` of your bot:
```json
{
"title": "photo",
"description": "Show user photo using Single Sign On feature"
}
```
<h2 id='3'>Update your business logic for Message Extension</h2>
The sample business logic provides a handler `TeamsBot` extends TeamsActivityHandler and override `handleTeamsMessagingExtensionQuery`.
You can update the query logic in the `handleMessageExtensionQueryWithSSO` with token which is obtained by using the logged-in Teams user token.
To make this work in your application:
1. Move the `auth/bot/public` folder to `bot`. This folder contains HTML pages that the bot application hosts. When single sign-on flows are initiated with Microsoft Entra, Microsoft Entra will redirect the user to these pages.
1. Modify your `bot/index` to add the appropriate `express` routes to these pages.
```ts
const path = require("path");
const send = require("send");
// Listen for incoming requests.
expressApp.post("/api/messages", async (req, res) => {
await adapter
.process(req, res, async (context) => {
await bot.run(context);
})
.catch((err) => {
// Error message including "412" means it is waiting for user's consent, which is a normal process of SSO, sholdn't throw this error.
if (!err.message.includes("412")) {
throw err;
}
});
});
expressApp.get(["/auth-start.html", "/auth-end.html"], async (req, res) => {
send(
req,
path.join(
__dirname,
"public",
req.url.includes("auth-start.html") ? "auth-start.html" : "auth-end.html"
)
).pipe(res);
});
```
1. Override `handleTeamsMessagingExtensionQuery` interface under `bot/teamsBot`. You can follow the sample code in the `handleMessageExtensionQueryWithSSO` to do your own query logic.
1. Open `bot/package.json`, ensure that `@microsoft/teamsfx` version >= 2.2.0
1. Install `isomorphic-fetch` npm packages in your bot project.
1. (For ts only) Install `copyfiles` npm packages in your bot project, add or update the `build` script in `bot/package.json` as following
```json
"build": "tsc --build && copyfiles ./public/*.html lib/",
```
By doing this, the HTML pages used for auth redirect will be copied when building this bot project.
1. Update `templates/appPackage/aad.template.json` your scopes which used in `handleMessageExtensionQueryWithSSO`.
```json
"requiredResourceAccess": [
{
"resourceAppId": "Microsoft Graph",
"resourceAccess": [
{
"id": "User.Read",
"type": "Scope"
}
]
}
]
```
<h2 id='4'>Debug your application </h2>
You can debug your application by pressing F5.
Teams Toolkit will use the Microsoft Entra manifest file to register a Microsoft Entra application registered for SSO.
To learn more about Teams Toolkit local debug functionalities, refer to this [document](https://docs.microsoft.com/microsoftteams/platform/toolkit/debug-local).
<h2 id='5'>Customize Microsoft Entra applications</h2>
The Microsoft Entra [manifest](https://docs.microsoft.com/azure/active-directory/develop/reference-app-manifest) allows you to customize various aspects of your application registration. You can update the manifest as needed.
Follow this [document](https://aka.ms/teamsfx-aad-manifest#customize-aad-manifest-template) if you need to include additional API permissions to access your desired APIs.
Follow this [document](https://aka.ms/teamsfx-aad-manifest#How-to-view-the-AAD-app-on-the-Azure-portal) to view your Microsoft Entra application in Azure Portal.
<h2 id='6'>Trouble Shooting </h2>
<h3>Login page does not pop up after clicking `continue`</h3>
First check whether your auth-start page is available by directly go to "{your-bot-endpoint}/auth-start.html" in your browser. You can find your-bot-endpoint in `.fx/states/state.{env}.json`.
- If the auth-start page can be opened in your browser, please try sign out current account in Teams app page and sign in again and run the command again.
- If encounter with ngrok page below when local debug, please follow the steps to solve this issue.
1. Stop debugging in Visual Studio Code.
1. Sign up an ngrok account in https://dashboard.ngrok.com/signup.
1. Copy your personal ngrok authtoken from https://dashboard.ngrok.com/get-started/your-authtoken.
1. Run `npx ngrok authtoken <your-personal-ngrok-authtoken>` in Visual Studio Code terminal.
1. Start debugging the project again by hitting the F5 key in Visual Studio Code.
![ngrok auth page](https://user-images.githubusercontent.com/63089166/190566043-6957edc9-c5b8-409d-b532-979ee0ef6ce5.png)
@@ -0,0 +1,188 @@
Enable single sign-on for Teams bot applications
-------------------------
For Teams bot application, SSO manifests as an Adaptive Card which the user can interact with to invoke the Microsoft Entra consent flow.
Files generated/updated in your project
-------------------------
1. New file - `aad.template.json` is created in folder `templates/appPackage`
- The Azure Active Directory application manifest that is used to register the application with Microsoft Entra.
2. Update file - 'templates/appPackage/manifest.template.json'
- An `webApplicationInfo` object will be added into your Teams app manifest template. This field is required by Teams when enabling SSO. |
3. New file - `Auth/bot`
- Sample code, redirect pages and a `README.txt` file. These files are provided for reference. See below for more information. |
4. Update file - 'appsettings.json' and 'appsettings.Development.json'
- Configs that will be used by TeamsFx SDK will be added into your app settings. Please update add the 'TeamsFx' object if you have other appsettings files.
Actions required - update your code to add SSO authentication
-------------------------
Note: This part is for `command and response bot`.
1. Please upgrade your SDK and make sure your SDK version:
TeamsFx: >= 1.1.0
Microsoft.Bot.Builder >= 4.17.1
2. Create "Pages" folder and move files in `Auth/bot/Pages` folder to `Pages`
`Auth/bot/Pages` folder contains HTML pages that hosted by bot application. When single sign-on flows are initiated with Microsoft Entra ID, Microsoft Entra ID will redirect the user to these pages.
3. Create "SSO" folder and move files in 'Auth/bot/SSO' folder to 'SSO'
This folder contains two files as reference for SSO implementation:
2.1 SsoDialog.cs: This creates a ComponentDialog that used for SSO.
2.2 TeamsSsoBot.cs: This create a TeamsActivityHandler with `SsoDialog` and add 'showUserInfo' as a command that can be triggered.
2.3 SsoOperations.cs: This implements class with a function to get user info with SSO token. You can follow this method and create your own method that requires SSO token.
Note: Remember to replace '{Your_NameSpace}' with your project namespace.
4. Update 'Program.cs'
4.1 Find code: 'builder.Services.AddSingleton<BotFrameworkAuthentication, ConfigurationBotFrameworkAuthentication>();'
and add the following code below:
'''
builder.Services.AddRazorPages();
// Create the Bot Framework Adapter with error handling enabled.
builder.Services.AddSingleton<IBotFrameworkHttpAdapter, AdapterWithErrorHandler>();
builder.Services.AddSingleton<IStorage, MemoryStorage>();
// Create the Conversation state. (Used by the Dialog system itself.)
builder.Services.AddSingleton<ConversationState>();
// The Dialog that will be run by the bot.
builder.Services.AddSingleton<SsoDialog>();
// Create the bot as a transient. In this case the ASP Controller is expecting an IBot.
builder.Services.AddTransient<IBot, TeamsSsoBot<SsoDialog>>();
builder.Services.AddOptions<AuthenticationOptions>().Bind(builder.Configuration.GetSection("TeamsFx").GetSection(AuthenticationOptions.Authentication)).ValidateDataAnnotations();
builder.Services.AddOptions<BotAuthenticationOptions>().Configure<IOptions<AuthenticationOptions>>((botAuthOption, authOptions) => {
AuthenticationOptions authOptionsValue = authOptions.Value;
botAuthOption.ClientId = authOptionsValue.ClientId;
botAuthOption.ClientSecret = authOptionsValue.ClientSecret;
botAuthOption.OAuthAuthority = authOptionsValue.OAuthAuthority;
botAuthOption.ApplicationIdUri = authOptionsValue.ApplicationIdUri;
botAuthOption.InitiateLoginEndpoint = authOptionsValue.Bot.InitiateLoginEndpoint;
}).ValidateDataAnnotations();
'''
4.2 Find the following lines:
'''
builder.Services.AddSingleton<HelloWorldCommandHandler>();
builder.Services.AddSingleton(sp =>
{
var options = new ConversationOptions()
{
Adapter = sp.GetService<CloudAdapter>(),
Command = new CommandOptions()
{
Commands = new List<ITeamsCommandHandler> { sp.GetService<HelloWorldCommandHandler>() }
}
};
return new ConversationBot(options);
});
'''
and replace with:
'''
builder.Services.AddSingleton(sp =>
{
var options = new ConversationOptions()
{
Adapter = sp.GetService<CloudAdapter>(),
Command = new CommandOptions()
{
Commands = new List<ITeamsCommandHandler> { }
}
};
return new ConversationBot(options);
});
'''
4.3 Find and delete the following code:
'''
// Create the bot as a transient. In this case the ASP Controller is expecting an IBot.
builder.Services.AddTransient<IBot, TeamsBot>();
'''
4.4 Find the following code:
'''
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
'''
and replace with:
'''
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapRazorPages();
});
'''
5. Register your command in the Teams app manifest. Open 'Templates/appPackage/manifest.template.json', and add following lines under `commands` in `commandLists` of your bot:
'''
{
"title": "show",
"description": "Show user profile using Single Sign On feature"
}
'''
(Optional) Add a new command to the bot
-------------------------
After successfully add SSO in your project, you can also add a new command.
1. Create a new method in class SsoOperations in 'SSO/SsoOperations' and add your own business logic to call Graph API:
'''
public static async Task GetUserImageInfo(ITurnContext stepContext, string token, BotAuthenticationOptions botAuthOptions)
{
await stepContext.SendActivityAsync("Retrieving user information from Microsoft Graph ...");
var authProvider = new DelegateAuthenticationProvider((request) =>
{
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
return Task.CompletedTask;
});
var graphClient = new GraphServiceClient(authProvider);
// You can add following code to get your photo size:
// var photo = await graphClient.Me.Photo.Request().GetAsync();
// await stepContext.SendActivityAsync($"Size of your photo is: {photo.Width} * {photo.Height}");
}
'''
2. Register a new command using 'addCommand' in 'TeamsSsoBot':
Find the following line:
'''
((SsoDialog)_dialog).addCommand("showUserInfo", "show", SsoOperations.ShowUserInfo);
'''
and add following lines after the above line to register a new command 'photo' and hook up with method 'GetUserImageInfo' added above:
'''
((SsoDialog)_dialog).addCommand("getUserImageInfo", "photo", SsoOperations.GetUserImageInfo);
'''
3. Register your command in the Teams app manifest. Open 'Templates/appPackage/manifest.template.json', and add following lines under `commands` in `commandLists` of your bot:
'''
{
"title": "photo",
"description": "Show user photo size using Single Sign On feature"
}
'''
Debug your application
-------------------------
You can debug your application by:
1. Right-click your project and select Teams Toolkit > Prepare Teams app dependencies
2. If prompted, sign in with an M365 account for the Teams organization you want
to install the app to
3. Press F5, or select the Debug > Start Debugging menu in Visual Studio
4. In the launched browser, select the Add button to load the app in Teams
Teams Toolkit will use the Microsoft Entra manifest file to register a Microsoft Entra application registered for SSO.
To learn more about Teams Toolkit local debug functionalities, refer to https://docs.microsoft.com/microsoftteams/platform/toolkit/debug-local.
Customize Microsoft Entra applications
-------------------------
The Microsoft Entra manifest allows you to customize various aspects of your application registration. You can update the manifest as needed.
Related Doc: https://docs.microsoft.com/azure/active-directory/develop/reference-app-manifest
Follow https://aka.ms/teamsfx-aad-manifest#how-to-customize-the-aad-manifest-template if you need to include additional API permissions to access your desired APIs.
Follow https://aka.ms/teamsfx-aad-manifest#How-to-view-the-AAD-app-on-the-Azure-portal to view your Microsoft Entra application in Azure Portal.
@@ -0,0 +1,65 @@
@page "/bot-auth-end.html"
<html>
<head>
<title>Login End Page</title>
<meta charset="utf-8" />
</head>
<body>
<script src="https://res.cdn.office.net/teams-js/2.17.0/js/MicrosoftTeams.min.js"
integrity="sha384-xp55t/129OsN192JZYLP0rGhzjCF9aYtjY0LVtXvolkDrBe4Jchylp56NrUYJ4S2"
crossorigin="anonymous"></script>
<div id="divError"></div>
<script type="text/javascript">
microsoftTeams.app.initialize().then(() => {
let hashParams = getHashParameters();
if (hashParams["error"]) {
// Authentication failed
handleAuthError(hashParams["error"], hashParams);
} else if (hashParams["code"]) {
// Get the stored state parameter and compare with incoming state
let expectedState = localStorage.getItem("state");
if (expectedState !== hashParams["state"]) {
// State does not match, report error
handleAuthError("StateDoesNotMatch", hashParams);
} else {
microsoftTeams.authentication.notifySuccess();
}
} else {
// Unexpected condition: hash does not contain error or access_token parameter
handleAuthError("UnexpectedFailure", hashParams);
}
});
// Parse hash parameters into key-value pairs
function getHashParameters() {
let hashParams = {};
location.hash
.substr(1)
.split("&")
.forEach(function (item) {
let s = item.split("="),
k = s[0],
v = s[1] && decodeURIComponent(s[1]);
hashParams[k] = v;
});
return hashParams;
}
// Show error information
function handleAuthError(errorType, errorMessage) {
const err = JSON.stringify({
error: errorType,
message: JSON.stringify(errorMessage),
});
let para = document.createElement("p");
let node = document.createTextNode(err);
para.appendChild(node);
let element = document.getElementById("divError");
element.appendChild(para);
}
</script>
</body>
</html>
@@ -0,0 +1,168 @@
@page "/bot-auth-start"
<!--This file is used during the Teams Bot authentication flow to assist with retrieval of the access token.-->
<!--If you're not familiar with this, do not alter or remove this file from your project.-->
<html>
<head>
<title>Login Start Page</title>
<meta charset="utf-8" />
</head>
<body>
<script type="text/javascript">
popUpSignInWindow();
async function popUpSignInWindow() {
// Generate random state string and store it, so we can verify it in the callback
let state = _guid();
localStorage.setItem("state", state);
localStorage.removeItem("codeVerifier");
var currentURL = new URL(window.location);
var clientId = currentURL.searchParams.get("clientId");
var tenantId = currentURL.searchParams.get("tenantId");
var loginHint = currentURL.searchParams.get("loginHint");
if (!loginHint) {
loginHint = "";
}
var scope = currentURL.searchParams.get("scope");
var originalCode = _guid();
var codeChallenge = await pkceChallengeFromVerifier(originalCode);
localStorage.setItem("codeVerifier", originalCode);
let queryParams = {
client_id: clientId,
response_type: "code",
response_mode: "fragment",
scope: scope,
redirect_uri: window.location.origin + "/bot-auth-end.html",
nonce: _guid(),
login_hint: loginHint,
state: state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
};
let authorizeEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?${toQueryString(
queryParams
)}`;
window.location.assign(authorizeEndpoint);
}
// Build query string from map of query parameter
function toQueryString(queryParams) {
let encodedQueryParams = [];
for (let key in queryParams) {
encodedQueryParams.push(key + "=" + encodeURIComponent(queryParams[key]));
}
return encodedQueryParams.join("&");
}
// Converts decimal to hex equivalent
// (From ADAL.js: https://github.com/AzureAD/azure-activedirectory-library-for-js/blob/dev/lib/adal.js)
function _decimalToHex(number) {
var hex = number.toString(16);
while (hex.length < 2) {
hex = "0" + hex;
}
return hex;
}
// Generates RFC4122 version 4 guid (128 bits)
// (From ADAL.js: https://github.com/AzureAD/azure-activedirectory-library-for-js/blob/dev/lib/adal.js)
function _guid() {
// RFC4122: The version 4 UUID is meant for generating UUIDs from truly-random or
// pseudo-random numbers.
// The algorithm is as follows:
// Set the two most significant bits (bits 6 and 7) of the
// clock_seq_hi_and_reserved to zero and one, respectively.
// Set the four most significant bits (bits 12 through 15) of the
// time_hi_and_version field to the 4-bit version number from
// Section 4.1.3. Version4
// Set all the other bits to randomly (or pseudo-randomly) chosen
// values.
// UUID = time-low "-" time-mid "-"time-high-and-version "-"clock-seq-reserved and low(2hexOctet)"-" node
// time-low = 4hexOctet
// time-mid = 2hexOctet
// time-high-and-version = 2hexOctet
// clock-seq-and-reserved = hexOctet:
// clock-seq-low = hexOctet
// node = 6hexOctet
// Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
// y could be 1000, 1001, 1010, 1011 since most significant two bits needs to be 10
// y values are 8, 9, A, B
var cryptoObj = window.crypto || window.msCrypto; // for IE 11
if (cryptoObj && cryptoObj.getRandomValues) {
var buffer = new Uint8Array(16);
cryptoObj.getRandomValues(buffer);
//buffer[6] and buffer[7] represents the time_hi_and_version field. We will set the four most significant bits (4 through 7) of buffer[6] to represent decimal number 4 (UUID version number).
buffer[6] |= 0x40; //buffer[6] | 01000000 will set the 6 bit to 1.
buffer[6] &= 0x4f; //buffer[6] & 01001111 will set the 4, 5, and 7 bit to 0 such that bits 4-7 == 0100 = "4".
//buffer[8] represents the clock_seq_hi_and_reserved field. We will set the two most significant bits (6 and 7) of the clock_seq_hi_and_reserved to zero and one, respectively.
buffer[8] |= 0x80; //buffer[8] | 10000000 will set the 7 bit to 1.
buffer[8] &= 0xbf; //buffer[8] & 10111111 will set the 6 bit to 0.
return (
_decimalToHex(buffer[0]) +
_decimalToHex(buffer[1]) +
_decimalToHex(buffer[2]) +
_decimalToHex(buffer[3]) +
"-" +
_decimalToHex(buffer[4]) +
_decimalToHex(buffer[5]) +
"-" +
_decimalToHex(buffer[6]) +
_decimalToHex(buffer[7]) +
"-" +
_decimalToHex(buffer[8]) +
_decimalToHex(buffer[9]) +
"-" +
_decimalToHex(buffer[10]) +
_decimalToHex(buffer[11]) +
_decimalToHex(buffer[12]) +
_decimalToHex(buffer[13]) +
_decimalToHex(buffer[14]) +
_decimalToHex(buffer[15])
);
} else {
var guidHolder = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx";
var hex = "0123456789abcdef";
var r = 0;
var guidResponse = "";
for (var i = 0; i < 36; i++) {
if (guidHolder[i] !== "-" && guidHolder[i] !== "4") {
// each x and y needs to be random
r = (Math.random() * 16) | 0;
}
if (guidHolder[i] === "x") {
guidResponse += hex[r];
} else if (guidHolder[i] === "y") {
// clock-seq-and-reserved first hex is filtered and remaining hex values are random
r &= 0x3; // bit and with 0011 to set pos 2 to zero ?0??
r |= 0x8; // set pos 3 to 1 as 1???
guidResponse += hex[r];
} else {
guidResponse += guidHolder[i];
}
}
return guidResponse;
}
}
// Calculate the SHA256 hash of the input text.
// Returns a promise that resolves to an ArrayBuffer
function sha256(plain) {
const encoder = new TextEncoder();
const data = encoder.encode(plain);
return window.crypto.subtle.digest("SHA-256", data);
}
// Base64-urlencodes the input string
function base64urlencode(str) {
// Convert the ArrayBuffer to string using Uint8 array to convert to what btoa accepts.
// btoa accepts chars only within ascii 0-255 and base64 encodes them.
// Then convert the base64 encoded to base64url encoded
// (replace + with -, replace / with _, trim trailing =)
return btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
// Return the base64-urlencoded sha256 hash for the PKCE challenge
async function pkceChallengeFromVerifier(v) {
hashed = await sha256(v);
return base64urlencode(hashed);
}
</script>
</body>
</html>
@@ -0,0 +1,230 @@
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Schema;
using Microsoft.Extensions.Options;
using Microsoft.TeamsFx.Bot;
using Microsoft.TeamsFx.Configuration;
namespace {Your_NameSpace}.SSO;
public class DialogConstants
{
internal static readonly string COMMAND_ROUTE_DIALOG = "CommandRouteDialog";
internal static readonly string TEAMS_SSO_PROMPT = "TeamsFxSsoPrompt";
}
public class StoreItem
{
public string eTag;
}
public class SsoDialog : ComponentDialog
{
ILogger<SsoDialog> _logger;
BotAuthenticationOptions _botAuthOptions;
Dictionary<string, string> _commandMapping = new Dictionary<string, string>();
IStorage _dedupStorage = new MemoryStorage();
List<string> _dedupStorageKeys = new List<string>() {};
public SsoDialog(IOptions<BotAuthenticationOptions> botAuthenticationOptions, ILogger<SsoDialog> logger)
{
_logger = logger;
InitialDialogId = DialogConstants.COMMAND_ROUTE_DIALOG;
try
{
_logger.LogTrace("Validate bot authentication configuration");
_botAuthOptions = botAuthenticationOptions.Value;
}
catch (OptionsValidationException e)
{
throw new Exception($"Bot authentication config is missing or not correct with error: {e.Message}");
}
var settings = new TeamsBotSsoPromptSettings(_botAuthOptions, new string[] { "User.Read" });
AddDialog(new TeamsBotSsoPrompt(DialogConstants.TEAMS_SSO_PROMPT, settings));
WaterfallDialog commandRouteDialog = new WaterfallDialog(
DialogConstants.COMMAND_ROUTE_DIALOG,
new WaterfallStep[]
{
CommandRouteStepAsync
});
AddDialog(commandRouteDialog);
_logger.LogInformation("Construct Main Dialog");
}
public async Task RunAsync(ITurnContext context, IStatePropertyAccessor<DialogState> accessor)
{
DialogSet dialogSet = new DialogSet(accessor);
dialogSet.Add(this);
DialogContext dialogContext = await dialogSet.CreateContextAsync(context);
DialogTurnResult results = await dialogContext.ContinueDialogAsync();
if (results != null && results.Status == DialogTurnStatus.Empty)
{
await dialogContext.BeginDialogAsync(Id);
}
}
public void addCommand(
string commandId,
string commandText,
Func<ITurnContext, string, BotAuthenticationOptions, Task> operation)
{
if (_commandMapping.ContainsValue(commandId))
{
return;
}
_commandMapping.Add(commandText, commandId);
WaterfallDialog dialog = new WaterfallDialog(
commandId,
new WaterfallStep[]
{
PromptStepAsync,
DedupStepAsync,
async (WaterfallStepContext stepContext, CancellationToken cancellationToken) => {
TeamsBotSsoPromptTokenResponse tokenResponce = (TeamsBotSsoPromptTokenResponse)stepContext.Result;
var turnContext = stepContext.Context;
try
{
if (tokenResponce != null)
{
await operation(turnContext, tokenResponce.Token, _botAuthOptions);
} else
{
await turnContext.SendActivityAsync("Failed to retrieve user token from conversation context.");
}
return await stepContext.EndDialogAsync();
} catch (Exception error)
{
await turnContext.SendActivityAsync("Failed to retrieve user token from conversation context.");
await turnContext.SendActivityAsync(error.Message);
return await stepContext.EndDialogAsync();
}
}
});
AddDialog(dialog);
}
protected override async Task OnEndDialogAsync(ITurnContext context, DialogInstance instance, DialogReason reason, CancellationToken cancellationToken = default(CancellationToken))
{
var conversationId = context.Activity.Conversation.Id;
var currentDedupKeys = _dedupStorageKeys.Where((key) => key.IndexOf(conversationId) > 0).ToArray();
await _dedupStorage.DeleteAsync(currentDedupKeys);
_dedupStorageKeys = _dedupStorageKeys.Where((key) => key.IndexOf(conversationId) < 0).ToList<string>();
}
private async Task<DialogTurnResult> PromptStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
_logger.LogInformation("Step: Prompt to get SSO token");
try
{
return await stepContext.BeginDialogAsync(DialogConstants.TEAMS_SSO_PROMPT, null, cancellationToken);
} catch (Exception error)
{
await stepContext.Context.SendActivityAsync("Failed to run SSO prompt");
await stepContext.Context.SendActivityAsync(error.Message);
return await stepContext.EndDialogAsync();
}
}
private async Task<DialogTurnResult> CommandRouteStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
_logger.LogInformation("Step: Route to pre-added commands");
var turnContext = stepContext.Context;
string text = turnContext.Activity.RemoveRecipientMention();
if (text != null)
{
text = text.ToLower().Trim();
}
string commandId = MatchCommands(text);
if (commandId != null)
{
return await stepContext.BeginDialogAsync(commandId);
}
await stepContext.Context.SendActivityAsync(String.Format("Cannot find command: {0}", text));
return await stepContext.EndDialogAsync();
}
private async Task<DialogTurnResult> DedupStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
try
{
var tokenResponse = stepContext.Result;
if (tokenResponse != null && (await ShouldDedup(stepContext.Context)))
{
return EndOfTurn;
}
return await stepContext.NextAsync(tokenResponse);
}
catch (Exception error)
{
await stepContext.Context.SendActivityAsync("Failed to run dedup step");
await stepContext.Context.SendActivityAsync(error.Message);
return await stepContext.EndDialogAsync();
}
}
private async Task<bool> ShouldDedup(ITurnContext context)
{
var storeItem = new StoreItem()
{
eTag = (context.Activity.Value as dynamic).id,
};
var key = GetStorageKey(context);
var storeItems = new Dictionary<string, object>()
{
{key, storeItem}
};
var res = await _dedupStorage.ReadAsync(new string[] { key });
if (res.Count != 0)
{
return true;
}
await _dedupStorage.WriteAsync(storeItems);
_dedupStorageKeys.Add(key);
return false;
}
private string GetStorageKey(ITurnContext context)
{
if (context == null || context.Activity == null || context.Activity.Conversation == null)
{
throw new Exception("Invalid context, can not get storage key!");
}
var activity = context.Activity;
var channelId = activity.ChannelId;
var conversationId = activity.Conversation.Id;
if (activity.Type != ActivityTypes.Invoke || activity.Name != SignInConstants.TokenExchangeOperationName)
{
throw new Exception("TokenExchangeState can only be used with Invokes of signin/tokenExchange.");
}
var value = activity.Value;
if (value == null || (value as dynamic).id == null)
{
throw new Exception("Invalid signin/tokenExchange. Missing activity.value.id.");
}
return $"{channelId}/{conversationId}/{(value as dynamic).id}";
}
private string MatchCommands(string text)
{
if (_commandMapping.ContainsKey(text))
{
return _commandMapping[text];
}
return null;
}
}
@@ -0,0 +1,22 @@
using Microsoft.Bot.Builder;
using Microsoft.Graph;
using Microsoft.TeamsFx.Configuration;
namespace {Your_NameSpace}.SSO;
public static class SsoOperations
{
public static async Task ShowUserInfo(ITurnContext stepContext, string token, BotAuthenticationOptions botAuthOptions)
{
await stepContext.SendActivityAsync("Retrieving user information from Microsoft Graph ...");
var authProvider = new DelegateAuthenticationProvider((request) =>
{
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
return Task.CompletedTask;
});
var graphClient = new GraphServiceClient(authProvider);
var profile = await graphClient.Me.Request().GetAsync();
await stepContext.SendActivityAsync($"You're logged in as {profile.DisplayName}");
}
}
@@ -0,0 +1,48 @@
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Teams;
using Microsoft.Bot.Schema;
using Microsoft.Bot.Builder.Dialogs;
namespace {Your_NameSpace}.SSO;
public class TeamsSsoBot<T> : TeamsActivityHandler where T : Dialog
{
private readonly ILogger<TeamsSsoBot<T>> _logger;
private readonly BotState _conversationState;
private readonly Dialog _dialog;
private readonly IStatePropertyAccessor<DialogState> _dialogState;
public TeamsSsoBot(ConversationState conversationState, T dialog, ILogger<TeamsSsoBot<T>> logger)
{
_conversationState = conversationState;
_dialog = dialog;
_logger = logger;
_dialogState = _conversationState.CreateProperty<DialogState>("DialogState");
((SsoDialog)_dialog).addCommand("showUserInfo", "show", SsoOperations.ShowUserInfo);
}
protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
{
_logger.LogInformation("Receive message activity");
await ((SsoDialog)_dialog).RunAsync(turnContext, _dialogState);
}
protected override async Task OnTeamsSigninVerifyStateAsync(ITurnContext<IInvokeActivity> turnContext, CancellationToken cancellationToken)
{
_logger.LogInformation("Receive invoke activity of teams sign in verify state");
await _dialog.RunAsync(turnContext, _conversationState.CreateProperty<DialogState>(nameof(DialogState)), cancellationToken);
}
protected override async Task OnSignInInvokeAsync(ITurnContext<IInvokeActivity> turnContext, CancellationToken cancellationToken)
{
_logger.LogInformation("Receive invoke activity of sign in");
await _dialog.RunAsync(turnContext, _conversationState.CreateProperty<DialogState>(nameof(DialogState)), cancellationToken);
}
public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
{
await base.OnTurnAsync(turnContext, cancellationToken);
await _conversationState.SaveChangesAsync(turnContext, false, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,69 @@
<!--This file is auto generated by Teams Toolkit to provide you instructions and reference code to implement single sign on. -->
<!--This file is used during the Teams Bot authentication flow to assist with retrieval of the access token.-->
<!--If you're not familiar with this, do not alter or remove this file from your project.-->
<html>
<head>
<title>Login End Page</title>
<meta charset="utf-8" />
</head>
<body>
<script
src="https://res.cdn.office.net/teams-js/2.17.0/js/MicrosoftTeams.min.js"
integrity="sha384-xp55t/129OsN192JZYLP0rGhzjCF9aYtjY0LVtXvolkDrBe4Jchylp56NrUYJ4S2"
crossorigin="anonymous"
></script>
<div id="divError"></div>
<script type="text/javascript">
microsoftTeams.app.initialize().then(() => {
let hashParams = getHashParameters();
if (hashParams.get("error")) {
// Authentication failed
handleAuthError(hashParams.get("error"), hashParams);
} else if (hashParams.get("code")) {
// Get the stored state parameter and compare with incoming state
let expectedState = localStorage.getItem("state");
if (expectedState !== hashParams.get("state")) {
// State does not match, report error
handleAuthError("StateDoesNotMatch", hashParams);
} else {
microsoftTeams.authentication.notifySuccess();
}
} else {
// Unexpected condition: hash does not contain error or access_token parameter
handleAuthError("UnexpectedFailure", hashParams);
}
});
// Parse hash parameters into key-value pairs
function getHashParameters() {
let hashParams = new Map();
location.hash
.substr(1)
.split("&")
.forEach(function (item) {
let s = item.split("="),
k = s[0],
v = s[1] && decodeURIComponent(s[1]);
hashParams.set(k, v);
});
return hashParams;
}
// Show error information
function handleAuthError(errorType, errorMessage) {
const err = JSON.stringify({
error: encodeURIComponent(errorType),
message: encodeURIComponent(JSON.stringify(errorMessage)),
});
let para = document.createElement("p");
let node = document.createTextNode(err);
para.appendChild(node);
let element = document.getElementById("divError");
element.appendChild(para);
}
</script>
</body>
</html>
@@ -0,0 +1,178 @@
<!--This file is auto generated by Teams Toolkit to provide you instructions and reference code to implement single sign on. -->
<!--This file is used during the Teams Bot authentication flow to assist with retrieval of the access token.-->
<!--If you're not familiar with this, do not alter or remove this file from your project.-->
<html>
<head>
<title>Login Start Page</title>
<meta charset="utf-8" />
</head>
<body>
<script type="text/javascript">
popUpSignInWindow();
async function popUpSignInWindow() {
// Generate random state string and store it, so we can verify it in the callback
let state = _guid();
localStorage.setItem("state", state);
localStorage.removeItem("codeVerifier");
var currentURL = new URL(window.location);
var clientId = currentURL.searchParams.get("clientId");
var tenantId = currentURL.searchParams.get("tenantId");
var loginHint = currentURL.searchParams.get("loginHint");
if (!loginHint) {
loginHint = "";
}
var scope = currentURL.searchParams.get("scope");
var originalCode = _guid();
var codeChallenge = await pkceChallengeFromVerifier(originalCode);
localStorage.setItem("codeVerifier", originalCode);
let queryParams = {
client_id: clientId,
response_type: "code",
response_mode: "fragment",
scope: scope,
redirect_uri: window.location.origin + "/auth-end.html",
nonce: _guid(),
login_hint: loginHint,
state: state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
};
let authorizeEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?${toQueryString(
queryParams
)}`;
window.location.assign(authorizeEndpoint);
}
// Build query string from map of query parameter
function toQueryString(queryParams) {
let encodedQueryParams = [];
for (let key in queryParams) {
encodedQueryParams.push(key + "=" + encodeURIComponent(queryParams[key]));
}
return encodedQueryParams.join("&");
}
// Converts decimal to hex equivalent
// (From ADAL.js: https://github.com/AzureAD/azure-activedirectory-library-for-js/blob/dev/lib/adal.js)
function _decimalToHex(number) {
var hex = number.toString(16);
while (hex.length < 2) {
hex = "0" + hex;
}
return hex;
}
// Generates RFC4122 version 4 guid (128 bits)
// (From ADAL.js: https://github.com/AzureAD/azure-activedirectory-library-for-js/blob/dev/lib/adal.js)
function _guid() {
// RFC4122: The version 4 UUID is meant for generating UUIDs from truly-random or
// pseudo-random numbers.
// The algorithm is as follows:
// Set the two most significant bits (bits 6 and 7) of the
// clock_seq_hi_and_reserved to zero and one, respectively.
// Set the four most significant bits (bits 12 through 15) of the
// time_hi_and_version field to the 4-bit version number from
// Section 4.1.3. Version4
// Set all the other bits to randomly (or pseudo-randomly) chosen
// values.
// UUID = time-low "-" time-mid "-"time-high-and-version "-"clock-seq-reserved and low(2hexOctet)"-" node
// time-low = 4hexOctet
// time-mid = 2hexOctet
// time-high-and-version = 2hexOctet
// clock-seq-and-reserved = hexOctet:
// clock-seq-low = hexOctet
// node = 6hexOctet
// Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
// y could be 1000, 1001, 1010, 1011 since most significant two bits needs to be 10
// y values are 8, 9, A, B
var cryptoObj = window.crypto || window.msCrypto; // for IE 11
if (cryptoObj && cryptoObj.getRandomValues) {
var buffer = new Uint8Array(16);
cryptoObj.getRandomValues(buffer);
//buffer[6] and buffer[7] represents the time_hi_and_version field. We will set the four most significant bits (4 through 7) of buffer[6] to represent decimal number 4 (UUID version number).
buffer[6] |= 0x40; //buffer[6] | 01000000 will set the 6 bit to 1.
buffer[6] &= 0x4f; //buffer[6] & 01001111 will set the 4, 5, and 7 bit to 0 such that bits 4-7 == 0100 = "4".
//buffer[8] represents the clock_seq_hi_and_reserved field. We will set the two most significant bits (6 and 7) of the clock_seq_hi_and_reserved to zero and one, respectively.
buffer[8] |= 0x80; //buffer[8] | 10000000 will set the 7 bit to 1.
buffer[8] &= 0xbf; //buffer[8] & 10111111 will set the 6 bit to 0.
return (
_decimalToHex(buffer[0]) +
_decimalToHex(buffer[1]) +
_decimalToHex(buffer[2]) +
_decimalToHex(buffer[3]) +
"-" +
_decimalToHex(buffer[4]) +
_decimalToHex(buffer[5]) +
"-" +
_decimalToHex(buffer[6]) +
_decimalToHex(buffer[7]) +
"-" +
_decimalToHex(buffer[8]) +
_decimalToHex(buffer[9]) +
"-" +
_decimalToHex(buffer[10]) +
_decimalToHex(buffer[11]) +
_decimalToHex(buffer[12]) +
_decimalToHex(buffer[13]) +
_decimalToHex(buffer[14]) +
_decimalToHex(buffer[15])
);
} else {
var guidHolder = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx";
var hex = "0123456789abcdef";
var r = 0;
var guidResponse = "";
for (var i = 0; i < 36; i++) {
if (guidHolder[i] !== "-" && guidHolder[i] !== "4") {
// each x and y needs to be random
r = (Math.random() * 16) | 0;
}
if (guidHolder[i] === "x") {
guidResponse += hex[r];
} else if (guidHolder[i] === "y") {
// clock-seq-and-reserved first hex is filtered and remaining hex values are random
r &= 0x3; // bit and with 0011 to set pos 2 to zero ?0??
r |= 0x8; // set pos 3 to 1 as 1???
guidResponse += hex[r];
} else {
guidResponse += guidHolder[i];
}
}
return guidResponse;
}
}
// Calculate the SHA256 hash of the input text.
// Returns a promise that resolves to an ArrayBuffer
function sha256(plain) {
const encoder = new TextEncoder();
const data = encoder.encode(plain);
return window.crypto.subtle.digest("SHA-256", data);
}
// Base64-urlencodes the input string
function base64urlencode(str) {
// Convert the ArrayBuffer to string using Uint8 array to convert to what btoa accepts.
// btoa accepts chars only within ascii 0-255 and base64 encodes them.
// Then convert the base64 encoded to base64url encoded
// (replace + with -, replace / with _, trim trailing =)
return btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
// Return the base64-urlencoded sha256 hash for the PKCE challenge
async function pkceChallengeFromVerifier(v) {
hashed = await sha256(v);
return base64urlencode(hashed);
}
</script>
</body>
</html>
@@ -0,0 +1 @@
const{TeamsActivityHandler,CardFactory}=require("botbuilder"),{handleMessageExtensionQueryWithSSO,OnBehalfOfUserCredential}=require("@microsoft/teamsfx"),{Client}=require("@microsoft/microsoft-graph-client"),{TokenCredentialAuthenticationProvider}=require("@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials");require("isomorphic-fetch");const oboAuthConfig={authorityHost:process.env.M365_AUTHORITY_HOST,clientId:process.env.M365_CLIENT_ID,tenantId:process.env.M365_TENANT_ID,clientSecret:process.env.M365_CLIENT_SECRET},initialLoginEndpoint=process.env.INITIATE_LOGIN_ENDPOINT;class TeamsBot extends TeamsActivityHandler{constructor(){super()}async handleTeamsMessagingExtensionQuery(context,query){return await handleMessageExtensionQueryWithSSO(context,oboAuthConfig,initialLoginEndpoint,"User.Read",(async token=>{const credential=new OnBehalfOfUserCredential(token.ssoToken,oboAuthConfig),authProvider=new TokenCredentialAuthenticationProvider(credential,{scopes:["User.Read"]}),graphClient=Client.initWithMiddleware({authProvider}),profile=await graphClient.api("/me").get();return{composeExtension:{type:"result",attachmentLayout:"list",attachments:[CardFactory.thumbnailCard(profile.displayName,profile.mail)]}}}))}async handleTeamsMessagingExtensionSelectItem(context,obj){return{composeExtension:{type:"result",attachmentLayout:"list",attachments:[CardFactory.heroCard(obj.name,obj.description)]}}}}module.exports.TeamsBot=TeamsBot;
@@ -0,0 +1 @@
const{OnBehalfOfUserCredential}=require("@microsoft/teamsfx"),{Client}=require("@microsoft/microsoft-graph-client"),{TokenCredentialAuthenticationProvider}=require("@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials");require("isomorphic-fetch");const oboAuthConfig={authorityHost:process.env.M365_AUTHORITY_HOST,clientId:process.env.M365_CLIENT_ID,tenantId:process.env.M365_TENANT_ID,clientSecret:process.env.M365_CLIENT_SECRET};class ProfileSsoCommandHandler{triggerPatterns="profile";async handleCommandReceived(context,message,tokenResponse){await context.sendActivity("Retrieving user information from Microsoft Graph ...");const oboCredential=new OnBehalfOfUserCredential(tokenResponse.ssoToken,oboAuthConfig),authProvider=new TokenCredentialAuthenticationProvider(oboCredential,{scopes:["User.Read"]}),graphClient=Client.initWithMiddleware({authProvider}),me=await graphClient.api("/me").get();return me?`Your command is '${message.text}' and you're logged in as ${me.displayName} (${me.userPrincipalName}).`:"Could not retrieve profile information from Microsoft Graph."}}module.exports={ProfileSsoCommandHandler};
@@ -0,0 +1,68 @@
<!--This file is auto generated by Teams Toolkit to provide you instructions and reference code to implement single sign on. -->
<!--This file is used during the Teams Bot authentication flow to assist with retrieval of the access token.-->
<!--If you're not familiar with this, do not alter or remove this file from your project.-->
<html>
<head>
<title>Login End Page</title>
<meta charset="utf-8" />
</head>
<body>
<script
src="https://res.cdn.office.net/teams-js/2.17.0/js/MicrosoftTeams.min.js"
integrity="sha384-xp55t/129OsN192JZYLP0rGhzjCF9aYtjY0LVtXvolkDrBe4Jchylp56NrUYJ4S2"
crossorigin="anonymous"
></script>
<div id="divError"></div>
<script type="text/javascript">
microsoftTeams.app.initialize().then(() => {
let hashParams = getHashParameters();
if (hashParams.get("error")) {
// Authentication failed
handleAuthError(hashParams.get("error"), hashParams);
} else if (hashParams.get("code")) {
// Get the stored state parameter and compare with incoming state
let expectedState = localStorage.getItem("state");
if (expectedState !== hashParams.get("state")) {
// State does not match, report error
handleAuthError("StateDoesNotMatch", hashParams);
} else {
microsoftTeams.authentication.notifySuccess();
}
} else {
// Unexpected condition: hash does not contain error or access_token parameter
handleAuthError("UnexpectedFailure", hashParams);
}
});
// Parse hash parameters into key-value pairs
function getHashParameters() {
let hashParams = new Map();
location.hash
.substr(1)
.split("&")
.forEach(function (item) {
let s = item.split("="),
k = s[0],
v = s[1] && decodeURIComponent(s[1]);
hashParams.set(k, v);
});
return hashParams;
}
// Show error information
function handleAuthError(errorType, errorMessage) {
const err = JSON.stringify({
error: encodeURIComponent(errorType),
message: encodeURIComponent(JSON.stringify(errorMessage)),
});
let para = document.createElement("p");
let node = document.createTextNode(err);
para.appendChild(node);
let element = document.getElementById("divError");
element.appendChild(para);
}
</script>
</body>
</html>
@@ -0,0 +1,178 @@
<!--This file is auto generated by Teams Toolkit to provide you instructions and reference code to implement single sign on. -->
<!--This file is used during the Teams Bot authentication flow to assist with retrieval of the access token.-->
<!--If you're not familiar with this, do not alter or remove this file from your project.-->
<html>
<head>
<title>Login Start Page</title>
<meta charset="utf-8" />
</head>
<body>
<script type="text/javascript">
popUpSignInWindow();
async function popUpSignInWindow() {
// Generate random state string and store it, so we can verify it in the callback
let state = _guid();
localStorage.setItem("state", state);
localStorage.removeItem("codeVerifier");
var currentURL = new URL(window.location);
var clientId = currentURL.searchParams.get("clientId");
var tenantId = currentURL.searchParams.get("tenantId");
var loginHint = currentURL.searchParams.get("loginHint");
if (!loginHint) {
loginHint = "";
}
var scope = currentURL.searchParams.get("scope");
var originalCode = _guid();
var codeChallenge = await pkceChallengeFromVerifier(originalCode);
localStorage.setItem("codeVerifier", originalCode);
let queryParams = {
client_id: clientId,
response_type: "code",
response_mode: "fragment",
scope: scope,
redirect_uri: window.location.origin + "/auth-end.html",
nonce: _guid(),
login_hint: loginHint,
state: state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
};
let authorizeEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?${toQueryString(
queryParams
)}`;
window.location.assign(authorizeEndpoint);
}
// Build query string from map of query parameter
function toQueryString(queryParams) {
let encodedQueryParams = [];
for (let key in queryParams) {
encodedQueryParams.push(key + "=" + encodeURIComponent(queryParams[key]));
}
return encodedQueryParams.join("&");
}
// Converts decimal to hex equivalent
// (From ADAL.js: https://github.com/AzureAD/azure-activedirectory-library-for-js/blob/dev/lib/adal.js)
function _decimalToHex(number) {
var hex = number.toString(16);
while (hex.length < 2) {
hex = "0" + hex;
}
return hex;
}
// Generates RFC4122 version 4 guid (128 bits)
// (From ADAL.js: https://github.com/AzureAD/azure-activedirectory-library-for-js/blob/dev/lib/adal.js)
function _guid() {
// RFC4122: The version 4 UUID is meant for generating UUIDs from truly-random or
// pseudo-random numbers.
// The algorithm is as follows:
// Set the two most significant bits (bits 6 and 7) of the
// clock_seq_hi_and_reserved to zero and one, respectively.
// Set the four most significant bits (bits 12 through 15) of the
// time_hi_and_version field to the 4-bit version number from
// Section 4.1.3. Version4
// Set all the other bits to randomly (or pseudo-randomly) chosen
// values.
// UUID = time-low "-" time-mid "-"time-high-and-version "-"clock-seq-reserved and low(2hexOctet)"-" node
// time-low = 4hexOctet
// time-mid = 2hexOctet
// time-high-and-version = 2hexOctet
// clock-seq-and-reserved = hexOctet:
// clock-seq-low = hexOctet
// node = 6hexOctet
// Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
// y could be 1000, 1001, 1010, 1011 since most significant two bits needs to be 10
// y values are 8, 9, A, B
var cryptoObj = window.crypto || window.msCrypto; // for IE 11
if (cryptoObj && cryptoObj.getRandomValues) {
var buffer = new Uint8Array(16);
cryptoObj.getRandomValues(buffer);
//buffer[6] and buffer[7] represents the time_hi_and_version field. We will set the four most significant bits (4 through 7) of buffer[6] to represent decimal number 4 (UUID version number).
buffer[6] |= 0x40; //buffer[6] | 01000000 will set the 6 bit to 1.
buffer[6] &= 0x4f; //buffer[6] & 01001111 will set the 4, 5, and 7 bit to 0 such that bits 4-7 == 0100 = "4".
//buffer[8] represents the clock_seq_hi_and_reserved field. We will set the two most significant bits (6 and 7) of the clock_seq_hi_and_reserved to zero and one, respectively.
buffer[8] |= 0x80; //buffer[8] | 10000000 will set the 7 bit to 1.
buffer[8] &= 0xbf; //buffer[8] & 10111111 will set the 6 bit to 0.
return (
_decimalToHex(buffer[0]) +
_decimalToHex(buffer[1]) +
_decimalToHex(buffer[2]) +
_decimalToHex(buffer[3]) +
"-" +
_decimalToHex(buffer[4]) +
_decimalToHex(buffer[5]) +
"-" +
_decimalToHex(buffer[6]) +
_decimalToHex(buffer[7]) +
"-" +
_decimalToHex(buffer[8]) +
_decimalToHex(buffer[9]) +
"-" +
_decimalToHex(buffer[10]) +
_decimalToHex(buffer[11]) +
_decimalToHex(buffer[12]) +
_decimalToHex(buffer[13]) +
_decimalToHex(buffer[14]) +
_decimalToHex(buffer[15])
);
} else {
var guidHolder = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx";
var hex = "0123456789abcdef";
var r = 0;
var guidResponse = "";
for (var i = 0; i < 36; i++) {
if (guidHolder[i] !== "-" && guidHolder[i] !== "4") {
// each x and y needs to be random
r = (Math.random() * 16) | 0;
}
if (guidHolder[i] === "x") {
guidResponse += hex[r];
} else if (guidHolder[i] === "y") {
// clock-seq-and-reserved first hex is filtered and remaining hex values are random
r &= 0x3; // bit and with 0011 to set pos 2 to zero ?0??
r |= 0x8; // set pos 3 to 1 as 1???
guidResponse += hex[r];
} else {
guidResponse += guidHolder[i];
}
}
return guidResponse;
}
}
// Calculate the SHA256 hash of the input text.
// Returns a promise that resolves to an ArrayBuffer
function sha256(plain) {
const encoder = new TextEncoder();
const data = encoder.encode(plain);
return window.crypto.subtle.digest("SHA-256", data);
}
// Base64-urlencodes the input string
function base64urlencode(str) {
// Convert the ArrayBuffer to string using Uint8 array to convert to what btoa accepts.
// btoa accepts chars only within ascii 0-255 and base64 encodes them.
// Then convert the base64 encoded to base64url encoded
// (replace + with -, replace / with _, trim trailing =)
return btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
// Return the base64-urlencoded sha256 hash for the PKCE challenge
async function pkceChallengeFromVerifier(v) {
hashed = await sha256(v);
return base64urlencode(hashed);
}
</script>
</body>
</html>
@@ -0,0 +1,84 @@
import { TeamsActivityHandler, CardFactory, TurnContext } from "botbuilder";
import {
MessageExtensionTokenResponse,
handleMessageExtensionQueryWithSSO,
OnBehalfOfCredentialAuthConfig,
OnBehalfOfUserCredential,
} from "@microsoft/teamsfx";
import { Client } from "@microsoft/microsoft-graph-client";
import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials";
import "isomorphic-fetch";
const oboAuthConfig: OnBehalfOfCredentialAuthConfig = {
authorityHost: process.env.M365_AUTHORITY_HOST,
clientId: process.env.M365_CLIENT_ID,
tenantId: process.env.M365_TENANT_ID,
clientSecret: process.env.M365_CLIENT_SECRET,
};
const initialLoginEndpoint = process.env.INITIATE_LOGIN_ENDPOINT;
export class TeamsBot extends TeamsActivityHandler {
constructor() {
super();
}
public async handleTeamsMessagingExtensionQuery(context: TurnContext, query: any): Promise<any> {
// eslint-disable-next-line no-secrets/no-secrets
/**
* User Code Here.
* If query without token, no need to implement handleMessageExtensionQueryWithSSO;
* Otherwise, just follow the sample code below to modify the user code.
*/
return await handleMessageExtensionQueryWithSSO(
context,
oboAuthConfig,
initialLoginEndpoint,
"User.Read",
async (token: MessageExtensionTokenResponse) => {
// User Code
// Init OnBehalfOfUserCredential instance with SSO token
const credential = new OnBehalfOfUserCredential(token.ssoToken, oboAuthConfig);
// Create an instance of the TokenCredentialAuthenticationProvider by passing the tokenCredential instance and options to the constructor
const authProvider = new TokenCredentialAuthenticationProvider(credential, {
scopes: ["User.Read"],
});
// Initialize Graph client instance with authProvider
const graphClient = Client.initWithMiddleware({
authProvider: authProvider,
});
// Call graph api use `graph` instance to get user profile information.
const profile = await graphClient.api("/me").get();
// Organize thumbnailCard to display User's profile info.
const thumbnailCard = CardFactory.thumbnailCard(profile.displayName, profile.mail);
// Message Extension return the user profile info to user.
return {
composeExtension: {
type: "result",
attachmentLayout: "list",
attachments: [thumbnailCard],
},
};
}
);
}
public async handleTeamsMessagingExtensionSelectItem(
context: TurnContext,
obj: any
): Promise<any> {
return {
composeExtension: {
type: "result",
attachmentLayout: "list",
attachments: [CardFactory.heroCard(obj.name, obj.description)],
},
};
}
}
@@ -0,0 +1,54 @@
import { Activity, TurnContext } from "botbuilder";
import {
CommandMessage,
TriggerPatterns,
TeamsFxBotSsoCommandHandler,
TeamsBotSsoPromptTokenResponse,
OnBehalfOfUserCredential,
OnBehalfOfCredentialAuthConfig,
} from "@microsoft/teamsfx";
import { Client } from "@microsoft/microsoft-graph-client";
import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials";
import "isomorphic-fetch";
const oboAuthConfig: OnBehalfOfCredentialAuthConfig = {
authorityHost: process.env.M365_AUTHORITY_HOST,
clientId: process.env.M365_CLIENT_ID,
tenantId: process.env.M365_TENANT_ID,
clientSecret: process.env.M365_CLIENT_SECRET,
};
export class ProfileSsoCommandHandler implements TeamsFxBotSsoCommandHandler {
triggerPatterns: TriggerPatterns = "profile";
async handleCommandReceived(
context: TurnContext,
message: CommandMessage,
tokenResponse: TeamsBotSsoPromptTokenResponse
): Promise<string | Partial<Activity> | void> {
await context.sendActivity("Retrieving user information from Microsoft Graph ...");
// Init OnBehalfOfUserCredential instance with SSO token
const oboCredential = new OnBehalfOfUserCredential(tokenResponse.ssoToken, oboAuthConfig);
// Create an instance of the TokenCredentialAuthenticationProvider by passing the tokenCredential instance and options to the constructor
const authProvider = new TokenCredentialAuthenticationProvider(oboCredential, {
scopes: ["User.Read"],
});
// Initialize Graph client instance with authProvider
const graphClient = Client.initWithMiddleware({
authProvider: authProvider,
});
// Call graph api use `graph` instance to get user profile information
const me = await graphClient.api("/me").get();
if (me) {
// Bot will send the user profile info to user
return `Your command is '${message.text}' and you're logged in as ${me.displayName} (${me.userPrincipalName}).`;
} else {
return "Could not retrieve profile information from Microsoft Graph.";
}
}
}
@@ -0,0 +1,50 @@
# Enable single sign-on for tab applications
Microsoft Teams provides a mechanism by which an application can obtain the signed-in Teams user token to access Microsoft Graph (and other APIs). Teams Toolkit facilitates this interaction by abstracting some of the Microsoft Entra flows and integrations behind some simple, high level APIs. This enables you to add single sign-on (SSO) features easily to your Teams application.
# Changes to your project
When you added the SSO feature to your application, Teams Toolkit updated your project to support SSO:
After you successfully added SSO into your project, Teams Toolkit will create and modify some files that helps you implement SSO feature.
| Action | File | Description |
| - | - | - |
| Create| `aad.template.json` under `templates/appPackage` | The Microsoft Entra application manifest that is used to register the application with Microsoft Entra. |
| Modify | `manifest.template.json` under `templates/appPackage` | An `webApplicationInfo` object will be added into your Teams app manifest template. This field is required by Teams when enabling SSO. |
| Create | `auth/tab` | Reference code, redirect pages and a `README.md` file. These files are provided for reference. See below for more information. |
# Update your code to add SSO
As described above, the Teams Toolkit generated some configuration to set up your application for SSO, but you need to update your application business logic to take advantage of the SSO feature as appropriate.
1. Move `auth-start.html` and `auth-end.html` in `auth/tab/public` folder to `tabs/public/`.
These two HTML files are used for auth redirects.
1. Move `sso` folder under `auth/tab` to `tabs/src/sso/`.
`InitTeamsFx`: This file implements a function that initialize TeamsFx SDK and will open `GetUserProfile` component after SDK is initialized.
`GetUserProfile`: This file implements a function that calls Microsoft Graph API to get user info.
2. Add the following lines to `tabs/src/components/sample/Welcome.*` to import `InitTeamsFx`:
```
import { InitTeamsFx } from "../../sso/InitTeamsFx";
```
3. Replace the following line: `<AddSSO />` with `<InitTeamsFx />` to replace the `AddSSO` component with `InitTeamsFx` component.
# Debug your application
You can debug your application by pressing F5.
Teams Toolkit will use the Microsoft Entra manifest file to register a Microsoft Entra application registered for SSO.
To learn more about Teams Toolkit local debug functionalities, refer to this [document](https://docs.microsoft.com/microsoftteams/platform/toolkit/debug-local).
# Customize Microsoft Entra applications
The Microsoft Entra [manifest](https://docs.microsoft.com/azure/active-directory/develop/reference-app-manifest) allows you to customize various aspects of your application registration. You can update the manifest as needed.
Follow this [document](https://aka.ms/teamsfx-aad-manifest#how-to-customize-the-aad-manifest-template) if you need to include additional API permissions to access your desired APIs.
Follow this [document](https://aka.ms/teamsfx-aad-manifest#How-to-view-the-AAD-app-on-the-Azure-portal) to view your Microsoft Entra application in Azure Portal.
@@ -0,0 +1,45 @@
Enable single sign-on for Teams tab applications
-------------------------
Files generated/updated in your project
-------------------------
1. New file - 'aad.template.json' is created in folder 'templates/appPackage'
- The Microsoft Entra application manifest that is used to register the application with Microsoft Entra.
2. Update file - 'templates/appPackage/manifest.template.json'
- An 'webApplicationInfo' object will be added into your Teams app manifest template. This field is required by Teams when enabling SSO.
3. New file - 'Auth/tab'
- Sample code and a 'README.txt' file. These files are provided for reference. See below for more information.
4. Update file - 'appsettings.json' and 'appsettings.Development.json'
- Configs that will be used by TeamsFx SDK will be added into your app settings. Please update add the 'TeamsFx' object if you have other appsettings files.
Actions required - update your code to add SSO authentication
-------------------------
You need to update your application code to take advantage of the SSO authentication.
1. Move 'GetUserProfile.razor' file from 'Auth/tab' folder to 'Components/' folder.
- 'GetUserProfile': This file implements a function that uses TeamsFx SDK to call Microsoft Graph API to get user info.
2. Rplace the 'AddSSO' component with 'GetUserProfile' component. To do this, just replace the following line: '<AddSSO />' with '<GetUserProfile />' in 'Components/Welcome.razor' file.
Debug your application
-------------------------
1. Right-click your project and select Teams Toolkit > Prepare Teams app dependencies
2. If prompted, sign in with an M365 account for the Teams organization you want
to install the app to
3. Press F5, or select the Debug > Start Debugging menu in Visual Studio
4. In the launched browser, select the Add button to load the app in Teams
Teams Toolkit will use the Microsoft Entra manifest file to register a Microsoft Entra application registered for SSO.
To learn more about Teams Toolkit local debug functionalities, refer to https://docs.microsoft.com/microsoftteams/platform/toolkit/debug-local.
Customize Microsoft Entra applications
-------------------------
The Microsoft Entra manifest allows you to customize various aspects of your application registration. You can update the manifest as needed.
Related Doc: https://docs.microsoft.com/azure/active-directory/develop/reference-app-manifest
Follow https://aka.ms/teamsfx-aad-manifest#how-to-customize-the-aad-manifest-template if you need to include additional API permissions to access your desired APIs.
Follow https://aka.ms/teamsfx-aad-manifest#How-to-view-the-AAD-app-on-the-Azure-portal to view your Microsoft Entra application in Azure Portal.
@@ -0,0 +1,97 @@
@using System.IO
@using Azure.Core
@using Microsoft.Graph
@inject TeamsFx teamsfx
@inject TeamsUserCredential teamsUserCredential
<div>
<h2>Get the user's profile</h2>
@if (NeedConsent)
{
<p>Click below to authorize this app to read your profile photo using Microsoft Graph.</p>
<FluentButton Appearance="Appearance.Accent" Disabled="@IsLoading" @onclick="ConsentAndShow">Authorize</FluentButton>
}
else if (!string.IsNullOrEmpty(@ErrorMessage))
{
<div class="error">@ErrorMessage</div>
}
else if (Profile != null)
{
<div>Hello @Profile.DisplayName</div>
}
</div>
@code {
[Parameter]
public string ErrorMessage { get; set; }
public bool IsLoading { get; set; }
public bool NeedConsent { get; set; }
public User Profile { get; set; }
private readonly string _scope = "User.Read";
protected override async Task OnInitializedAsync()
{
IsLoading = true;
if (await HasPermission(_scope))
{
await ShowProfile();
}
}
private async Task ShowProfile()
{
IsLoading = true;
var graph = GetGraphServiceClient();
Profile = await graph.Me.Request().GetAsync();
IsLoading = false;
ErrorMessage = string.Empty;
}
private async Task ConsentAndShow()
{
try
{
await teamsUserCredential.LoginAsync(_scope);
NeedConsent = false;
await ShowProfile();
}
catch (ExceptionWithCode e)
{
ErrorMessage = e.Message;
}
}
private async Task<bool> HasPermission(string scope)
{
IsLoading = true;
try
{
await teamsUserCredential.GetTokenAsync(new TokenRequestContext(new string[] { _scope }), new System.Threading.CancellationToken());
return true;
}
catch (ExceptionWithCode e)
{
if (e.Code == ExceptionCode.UiRequiredError)
{
NeedConsent = true;
}
else
{
ErrorMessage = e.Message;
}
}
IsLoading = false;
return false;
}
private GraphServiceClient GetGraphServiceClient()
{
var client = new GraphServiceClient(teamsUserCredential, new string[] { _scope });
return client;
}
}
@@ -0,0 +1,59 @@
<!--This file is used during the Teams authentication flow to assist with retrieval of the access token.-->
<!--If you're not familiar with this, do not alter or remove this file from your project.-->
<html>
<head>
<title>Login End Page</title>
<meta charset="utf-8" />
</head>
<body>
<script
src="https://res.cdn.office.net/teams-js/2.17.0/js/MicrosoftTeams.min.js"
integrity="sha384-xp55t/129OsN192JZYLP0rGhzjCF9aYtjY0LVtXvolkDrBe4Jchylp56NrUYJ4S2"
crossorigin="anonymous"
></script>
<script
type="text/javascript"
src="https://alcdn.msauth.net/browser/2.21.0/js/msal-browser.min.js"
integrity="sha384-s/NxjjAgw1QgpDhOlVjTceLl4axrp5nqpUbCPOEQy1PqbFit9On6uw2XmEF1eq0s"
crossorigin="anonymous"
></script>
<script type="text/javascript">
var currentURL = new URL(window.location);
var clientId = currentURL.searchParams.get("clientId");
microsoftTeams.app.initialize().then(() => {
microsoftTeams.app.getContext().then(async (context) => {
const msalConfig = {
auth: {
clientId: clientId,
authority: `https://login.microsoftonline.com/${context.user.tenant.id}`,
navigateToLoginRequestUrl: false,
},
cache: {
cacheLocation: "sessionStorage",
},
};
const msalInstance = new window.msal.PublicClientApplication(msalConfig);
msalInstance
.handleRedirectPromise()
.then((tokenResponse) => {
if (tokenResponse !== null) {
microsoftTeams.authentication.notifySuccess(
JSON.stringify({
sessionStorage: sessionStorage,
})
);
} else {
microsoftTeams.authentication.notifyFailure("Get empty response.");
}
})
.catch((error) => {
microsoftTeams.authentication.notifyFailure(JSON.stringify(error));
});
});
});
</script>
</body>
</html>
@@ -0,0 +1,53 @@
<!--This file is used during the Teams authentication flow to assist with retrieval of the access token.-->
<!--If you're not familiar with this, do not alter or remove this file from your project.-->
<html>
<head>
<title>Login Start Page</title>
<meta charset="utf-8" />
</head>
<body>
<script
src="https://res.cdn.office.net/teams-js/2.17.0/js/MicrosoftTeams.min.js"
integrity="sha384-xp55t/129OsN192JZYLP0rGhzjCF9aYtjY0LVtXvolkDrBe4Jchylp56NrUYJ4S2"
crossorigin="anonymous"
></script>
<script
type="text/javascript"
src="https://alcdn.msauth.net/browser/2.21.0/js/msal-browser.min.js"
integrity="sha384-s/NxjjAgw1QgpDhOlVjTceLl4axrp5nqpUbCPOEQy1PqbFit9On6uw2XmEF1eq0s"
crossorigin="anonymous"
></script>
<script type="text/javascript">
microsoftTeams.app.initialize().then(() => {
microsoftTeams.app.getContext().then(async (context) => {
// Generate random state string and store it, so we can verify it in the callback
var currentURL = new URL(window.location);
var clientId = currentURL.searchParams.get("clientId");
var scope = currentURL.searchParams.get("scope");
var loginHint = currentURL.searchParams.get("loginHint");
const msalConfig = {
auth: {
clientId: clientId,
authority: `https://login.microsoftonline.com/${context.user.tenant.id}`,
navigateToLoginRequestUrl: false,
},
cache: {
cacheLocation: "sessionStorage",
},
};
const msalInstance = new msal.PublicClientApplication(msalConfig);
const scopesArray = scope.split(" ");
const scopesRequest = {
scopes: scopesArray,
redirectUri: window.location.origin + `/auth-end.html?clientId=${clientId}`,
loginHint: loginHint,
};
await msalInstance.loginRedirect(scopesRequest);
});
});
</script>
</body>
</html>
@@ -0,0 +1,46 @@
// This file is auto generated by Teams Toolkit to provide you instructions and reference code to implement single sign on.
// This file will use TeamsFx SDK to call Graph API to get user profile.
// Refer to this link to learn more: https://www.npmjs.com/package/@microsoft/teamsfx-react#calling-the-microsoft-graph-api.
import { Button } from "@fluentui/react-northstar";
import { useGraph } from "@microsoft/teamsfx-react";
export function GetUserProfile(props) {
const { teamsfx } = {
teamsfx: undefined,
...props,
};
// For usage of useGraph(), please refer to: https://www.npmjs.com/package/@microsoft/teamsfx-react#usegraph.
const { loading, error, data, reload } = useGraph(
async (graph, teamsfx, scope) => {
// Call graph api directly to get user profile information
const profile = await graph.api("/me").get();
// You can also add following code to get your photo:
// let photoUrl = "";
// try {
// const photo = await graph.api("/me/photo/$value").get();
// photoUrl = URL.createObjectURL(photo);
// } catch {
// // Could not fetch photo from user's profile, return empty string as placeholder.
// }
return { profile };
},
// Add scope for your Microsoft Entra app. For example: Mail.Read, etc.
// Use teamsfx instance from `InitTeamsFx`
{ scope: ["User.Read"], teamsfx: teamsfx }
);
return (
<div>
<h2>GetUserProfile</h2>
<p>Click below to authorize button to grant permission to using Microsoft Graph.</p>
<Button primary content="Authorize" disabled={loading} onClick={reload} />
{!loading && error && (
<div className="error">Failed to read your profile. Please try again later.</div>
)}
{!loading && data && <div>Hello {data.profile.displayName}</div>}
</div>
);
}
@@ -0,0 +1,23 @@
// This file is auto generated by Teams Toolkit to provide you instructions and reference code to implement single sign on.
// This file will initialize TeamsFx SDK and show `GetUserProfile` component after initialization.
import { useTeamsFx } from "@microsoft/teamsfx-react";
import { GetUserProfile } from "./GetUserProfile";
export function InitTeamsFx() {
// For usage of useTeamsFx(), please refer to: https://www.npmjs.com/package/@microsoft/teamsfx-react#useTeamsfx.
// You need to wait until `loading == false` to use TeamsFx SDK.
const { loading, error, teamsfx } = useTeamsFx({
initiateLoginEndpoint: process.env.REACT_APP_START_LOGIN_PAGE_URL,
clientId: process.env.REACT_APP_CLIENT_ID,
});
return (
<div>
{!loading && error && (
<div className="error">Failed init TeamsFx. Please try again later.</div>
)}
{!loading && teamsfx && <GetUserProfile teamsfx={teamsfx} />}
</div>
);
}
@@ -0,0 +1,59 @@
<!--This file is used during the Teams authentication flow to assist with retrieval of the access token.-->
<!--If you're not familiar with this, do not alter or remove this file from your project.-->
<html>
<head>
<title>Login End Page</title>
<meta charset="utf-8" />
</head>
<body>
<script
src="https://res.cdn.office.net/teams-js/2.17.0/js/MicrosoftTeams.min.js"
integrity="sha384-xp55t/129OsN192JZYLP0rGhzjCF9aYtjY0LVtXvolkDrBe4Jchylp56NrUYJ4S2"
crossorigin="anonymous"
></script>
<script
type="text/javascript"
src="https://alcdn.msauth.net/browser/2.21.0/js/msal-browser.min.js"
integrity="sha384-s/NxjjAgw1QgpDhOlVjTceLl4axrp5nqpUbCPOEQy1PqbFit9On6uw2XmEF1eq0s"
crossorigin="anonymous"
></script>
<script type="text/javascript">
var currentURL = new URL(window.location);
var clientId = currentURL.searchParams.get("clientId");
microsoftTeams.app.initialize().then(() => {
microsoftTeams.app.getContext().then(async (context) => {
const msalConfig = {
auth: {
clientId: clientId,
authority: `https://login.microsoftonline.com/${context.user.tenant.id}`,
navigateToLoginRequestUrl: false,
},
cache: {
cacheLocation: "sessionStorage",
},
};
const msalInstance = new window.msal.PublicClientApplication(msalConfig);
msalInstance
.handleRedirectPromise()
.then((tokenResponse) => {
if (tokenResponse !== null) {
microsoftTeams.authentication.notifySuccess(
JSON.stringify({
sessionStorage: sessionStorage,
})
);
} else {
microsoftTeams.authentication.notifyFailure("Get empty response.");
}
})
.catch((error) => {
microsoftTeams.authentication.notifyFailure(JSON.stringify(error));
});
});
});
</script>
</body>
</html>
@@ -0,0 +1,53 @@
<!--This file is used during the Teams authentication flow to assist with retrieval of the access token.-->
<!--If you're not familiar with this, do not alter or remove this file from your project.-->
<html>
<head>
<title>Login Start Page</title>
<meta charset="utf-8" />
</head>
<body>
<script
src="https://res.cdn.office.net/teams-js/2.17.0/js/MicrosoftTeams.min.js"
integrity="sha384-xp55t/129OsN192JZYLP0rGhzjCF9aYtjY0LVtXvolkDrBe4Jchylp56NrUYJ4S2"
crossorigin="anonymous"
></script>
<script
type="text/javascript"
src="https://alcdn.msauth.net/browser/2.21.0/js/msal-browser.min.js"
integrity="sha384-s/NxjjAgw1QgpDhOlVjTceLl4axrp5nqpUbCPOEQy1PqbFit9On6uw2XmEF1eq0s"
crossorigin="anonymous"
></script>
<script type="text/javascript">
microsoftTeams.app.initialize().then(() => {
microsoftTeams.app.getContext().then(async (context) => {
// Generate random state string and store it, so we can verify it in the callback
var currentURL = new URL(window.location);
var clientId = currentURL.searchParams.get("clientId");
var scope = currentURL.searchParams.get("scope");
var loginHint = currentURL.searchParams.get("loginHint");
const msalConfig = {
auth: {
clientId: clientId,
authority: `https://login.microsoftonline.com/${context.user.tenant.id}`,
navigateToLoginRequestUrl: false,
},
cache: {
cacheLocation: "sessionStorage",
},
};
const msalInstance = new msal.PublicClientApplication(msalConfig);
const scopesArray = scope.split(" ");
const scopesRequest = {
scopes: scopesArray,
redirectUri: window.location.origin + `/auth-end.html?clientId=${clientId}`,
loginHint: loginHint,
};
await msalInstance.loginRedirect(scopesRequest);
});
});
</script>
</body>
</html>
@@ -0,0 +1,47 @@
// This file is auto generated by Teams Toolkit to provide you instructions and reference code to implement single sign on.
// This file will use TeamsFx SDK to call Graph API to get user profile.
// Refer to this link to learn more: https://www.npmjs.com/package/@microsoft/teamsfx-react#calling-the-microsoft-graph-api.
import { Button } from "@fluentui/react-northstar";
import { TeamsFx } from "@microsoft/teamsfx";
import { useGraph } from "@microsoft/teamsfx-react";
export function GetUserProfile(props: { teamsfx?: TeamsFx }) {
const { teamsfx } = {
teamsfx: undefined,
...props,
};
// For usage of useGraph(), please refer to: https://www.npmjs.com/package/@microsoft/teamsfx-react#usegraph.
const { loading, error, data, reload } = useGraph(
async (graph, teamsfx, scope) => {
// Call graph api use `graph` instance to get user profile information
const profile = await graph.api("/me").get();
// You can also add following code to get your photo:
// let photoUrl = "";
// try {
// const photo = await graph.api("/me/photo/$value").get();
// photoUrl = URL.createObjectURL(photo);
// } catch {
// // Could not fetch photo from user's profile, return empty string as placeholder.
// }
return { profile };
},
// Add scope for your Microsoft Entra app. For example: Mail.Read, etc.
// Use teamsfx instance from `InitTeamsFx`
{ scope: ["User.Read"], teamsfx: teamsfx }
);
return (
<div>
<h2>GetUserProfile</h2>
<p>Click below to authorize button to grant permission to using Microsoft Graph.</p>
<Button primary content="Authorize" disabled={loading} onClick={reload} />
{!loading && error && (
<div className="error">Failed to read your profile. Please try again later.</div>
)}
{!loading && data && <div>Hello {data.profile.displayName}</div>}
</div>
);
}
@@ -0,0 +1,23 @@
// This file is auto generated by Teams Toolkit to provide you instructions and reference code to implement single sign on.
// This file will initialize TeamsFx SDK and show `GetUserProfile` component after initialization.
import { useTeamsFx } from "@microsoft/teamsfx-react";
import { GetUserProfile } from "./GetUserProfile";
export function InitTeamsFx() {
// For usage of useTeamsFx(), please refer to: https://www.npmjs.com/package/@microsoft/teamsfx-react#useTeamsfx.
// You need to wait until `loading == false` to use TeamsFx SDK.
const { loading, error, teamsfx } = useTeamsFx({
initiateLoginEndpoint: process.env.REACT_APP_START_LOGIN_PAGE_URL!,
clientId: process.env.REACT_APP_CLIENT_ID!,
});
return (
<div>
{!loading && error && (
<div className="error">Failed init TeamsFx. Please try again later.</div>
)}
{!loading && teamsfx && <GetUserProfile teamsfx={teamsfx} />}
</div>
);
}
@@ -0,0 +1,102 @@
{
"id": "{{state.fx-resource-aad-app-for-teams.objectId}}",
"appId": "{{state.fx-resource-aad-app-for-teams.clientId}}",
"name": "{{config.manifest.appName.short}}-aad",
"accessTokenAcceptedVersion": 2,
"signInAudience": "AzureADMyOrg",
"optionalClaims": {
"idToken": [],
"accessToken": [
{
"name": "idtyp",
"source": null,
"essential": false,
"additionalProperties": []
}
],
"saml2Token": []
},
"requiredResourceAccess": [
{
"resourceAppId": "Microsoft Graph",
"resourceAccess": [
{
"id": "User.Read",
"type": "Scope"
}
]
}
],
"oauth2Permissions": [
{
"adminConsentDescription": "Allows Teams to call the app's web APIs as the current user.",
"adminConsentDisplayName": "Teams can access app's web APIs",
"id": "{{state.fx-resource-aad-app-for-teams.oauth2PermissionScopeId}}",
"isEnabled": true,
"type": "User",
"userConsentDescription": "Enable Teams to call this app's web APIs with the same rights that you have",
"userConsentDisplayName": "Teams can access app's web APIs and make requests on your behalf",
"value": "access_as_user"
}
],
"preAuthorizedApplications": [
{
"appId": "1fec8e78-bce4-4aaf-ab1b-5451cc387264",
"permissionIds": [
"{{state.fx-resource-aad-app-for-teams.oauth2PermissionScopeId}}"
]
},
{
"appId": "5e3ce6c0-2b1f-4285-8d4b-75ee78787346",
"permissionIds": [
"{{state.fx-resource-aad-app-for-teams.oauth2PermissionScopeId}}"
]
},
{
"appId": "d3590ed6-52b3-4102-aeff-aad2292ab01c",
"permissionIds": [
"{{state.fx-resource-aad-app-for-teams.oauth2PermissionScopeId}}"
]
},
{
"appId": "00000002-0000-0ff1-ce00-000000000000",
"permissionIds": [
"{{state.fx-resource-aad-app-for-teams.oauth2PermissionScopeId}}"
]
},
{
"appId": "bc59ab01-8403-45c6-8796-ac3ef710b3e3",
"permissionIds": [
"{{state.fx-resource-aad-app-for-teams.oauth2PermissionScopeId}}"
]
},
{
"appId": "0ec893e0-5785-4de6-99da-4ed124e5296c",
"permissionIds": [
"{{state.fx-resource-aad-app-for-teams.oauth2PermissionScopeId}}"
]
},
{
"appId": "4765445b-32c6-49b0-83e6-1d93765276ca",
"permissionIds": [
"{{state.fx-resource-aad-app-for-teams.oauth2PermissionScopeId}}"
]
},
{
"appId": "4345a7b9-9a63-4910-a426-35363201d503",
"permissionIds": [
"{{state.fx-resource-aad-app-for-teams.oauth2PermissionScopeId}}"
]
},
{
"appId": "27922004-5251-4030-b22d-91ecd9a37ea4",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
}
],
"identifierUris": [
"{{state.fx-resource-aad-app-for-teams.applicationIdUris}}"
],
"replyUrlsWithType": []
}
@@ -0,0 +1,107 @@
{
"id": "${{AAD_APP_OBJECT_ID}}",
"appId": "${{AAD_APP_CLIENT_ID}}",
"name": "YOUR_AAD_APP_NAME",
"accessTokenAcceptedVersion": 2,
"signInAudience": "AzureADMyOrg",
"optionalClaims": {
"idToken": [],
"accessToken": [
{
"name": "idtyp",
"source": null,
"essential": false,
"additionalProperties": []
}
],
"saml2Token": []
},
"requiredResourceAccess": [
{
"resourceAppId": "Microsoft Graph",
"resourceAccess": [
{
"id": "User.Read",
"type": "Scope"
}
]
}
],
"oauth2Permissions": [
{
"adminConsentDescription": "Allows Teams to call the app's web APIs as the current user.",
"adminConsentDisplayName": "Teams can access app's web APIs",
"id": "${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}",
"isEnabled": true,
"type": "User",
"userConsentDescription": "Enable Teams to call this app's web APIs with the same rights that you have",
"userConsentDisplayName": "Teams can access app's web APIs and make requests on your behalf",
"value": "access_as_user"
}
],
"preAuthorizedApplications": [
{
"appId": "1fec8e78-bce4-4aaf-ab1b-5451cc387264",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "5e3ce6c0-2b1f-4285-8d4b-75ee78787346",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "d3590ed6-52b3-4102-aeff-aad2292ab01c",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "00000002-0000-0ff1-ce00-000000000000",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "bc59ab01-8403-45c6-8796-ac3ef710b3e3",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "0ec893e0-5785-4de6-99da-4ed124e5296c",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "4765445b-32c6-49b0-83e6-1d93765276ca",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "4345a7b9-9a63-4910-a426-35363201d503",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "27922004-5251-4030-b22d-91ecd9a37ea4",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
}
],
"identifierUris": [
"api://botid-${{BOT_ID}}"
],
"replyUrlsWithType": [
{
"url": "https://${{BOT_DOMAIN}}/auth-end.html",
"type": "Web"
}
]
}
@@ -0,0 +1,115 @@
{
"id": "${{AAD_APP_OBJECT_ID}}",
"appId": "${{AAD_APP_CLIENT_ID}}",
"name": "YOUR_AAD_APP_NAME",
"accessTokenAcceptedVersion": 2,
"signInAudience": "AzureADMyOrg",
"optionalClaims": {
"idToken": [],
"accessToken": [
{
"name": "idtyp",
"source": null,
"essential": false,
"additionalProperties": []
}
],
"saml2Token": []
},
"requiredResourceAccess": [
{
"resourceAppId": "Microsoft Graph",
"resourceAccess": [
{
"id": "User.Read",
"type": "Scope"
}
]
}
],
"oauth2Permissions": [
{
"adminConsentDescription": "Allows Teams to call the app's web APIs as the current user.",
"adminConsentDisplayName": "Teams can access app's web APIs",
"id": "${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}",
"isEnabled": true,
"type": "User",
"userConsentDescription": "Enable Teams to call this app's web APIs with the same rights that you have",
"userConsentDisplayName": "Teams can access app's web APIs and make requests on your behalf",
"value": "access_as_user"
}
],
"preAuthorizedApplications": [
{
"appId": "1fec8e78-bce4-4aaf-ab1b-5451cc387264",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "5e3ce6c0-2b1f-4285-8d4b-75ee78787346",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "d3590ed6-52b3-4102-aeff-aad2292ab01c",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "00000002-0000-0ff1-ce00-000000000000",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "bc59ab01-8403-45c6-8796-ac3ef710b3e3",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "0ec893e0-5785-4de6-99da-4ed124e5296c",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "4765445b-32c6-49b0-83e6-1d93765276ca",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "4345a7b9-9a63-4910-a426-35363201d503",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
},
{
"appId": "27922004-5251-4030-b22d-91ecd9a37ea4",
"permissionIds": [
"${{AAD_APP_ACCESS_AS_USER_PERMISSION_ID}}"
]
}
],
"identifierUris": [
"api://${{TAB_DOMAIN}}/${{AAD_APP_CLIENT_ID}}"
],
"replyUrlsWithType": [
{
"url": "${{TAB_ENDPOINT}}/auth-end.html",
"type": "Web"
},
{
"url": "${{TAB_ENDPOINT}}/auth-end.html?clientId=${{AAD_APP_CLIENT_ID}}",
"type": "Spa"
},
{
"url": "${{TAB_ENDPOINT}}/blank-auth-end.html",
"type": "Spa"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 B