Because the current version uses Graph to write files to the user's OneDrive, we already have an app registration. We need to update it to meet the requirements of SSO.
- Go to https://apps.dev.microsoft.com and edit the existing app registration.
- Click Add Platform. Choose Web API.
- Under Application ID URI, change the default value by inserting your host and port number before the GUID listed there. For example:
- Let's say the default value is
api://05adb30e-50fa-4ae2-9cec-eab2cd6095b0
. - Look at the value of Redirect URLs under the existing Web platform entry. Let's say it is
https://localhost:44349/MessageRead.html
. Copy thelocalhost:44349
part. - Insert that into the default Application ID URI value after the
//
, then add another/
after it, so the result isapi://localhost:44349/05adb30e-50fa-4ae2-9cec-eab2cd6095b0
- Let's say the default value is
- Under Pre-authorized applications, enter
d3590ed6-52b3-4102-aeff-aad2292ab01c
for the Application ID. Click the Scope dropdown and select the only entry there. This preauthorizes Office to access the app.
When you're done, the Web API section should look similar to the following:
- Locate the Microsoft Graph Permissions section in the app registration. Next to Delegated Permissions, click Add.
- Select Files.ReadWrite, Mail.Read, offline_access, openid, and profile. Click OK.
When you're done, the Microsoft Graph Permissions section should looke like the following:
- Locate the Application Secrets section in the app registration. Click Generate New Password.
- Copy the password that is generated and save it somewhere safe. We'll need this in a bit. Click OK.
Scroll down to the bottom of the app registration and click Save.
In this step we will provide user consent to the permissions we just configured on the app. This step is only necessary because we will be sideloading the add-in for development and testing. Normally a production add-in will be listed in the Office Store, and users will be prompted to give consent during the installation process through the store.
You have two choices for providing consent. You can use an administrator account and consent once for all users in your Office 365 organization, or you can use any account to consent for just that user.
If you have access to a tenant administrator account, this method will allow you to provide consent for all users in your organization, which can be convenient if you have multiple developers that need to develop and test your add-in.
- Browse to
https://login.microsoftonline.com/common/adminconsent?client_id={application_ID}&state=12345
, where{application_ID}
is the application ID shown in your app registration. - Sign in with your administrator account.
- Review the permissions and click Accept.
The browser will attempt to redirect back to your app, which may not be running. You might see a "this site cannot be reached" error after clicking Accept. This is OK, the consent was still recorded.
If you don't have access to a tenant administrator account, or you just want to limit consent to a few users, this method will allow you to provide consent for a single user.
- Browse to
https://login.microsoftonline.com/common/oauth2/authorize?client_id={application_ID}&state=12345&response_type=code
, where{application_ID}
is the application ID shown in your app registration. - Sign in with your account.
- Review the permissions and click Accept.
The browser will attempt to redirect back to your app, which may not be running. You might see a "this site cannot be reached" error after clicking Accept. This is OK, the consent was still recorded.
The next step is to update the add-in manifest to enable the SSO feature.
- Open the
./AttachmentDemo/AttachmentDemoManifest/AttachmentDemo.xml
file. - Copy the entire
VersionOverrides
element. We are going to use this as the basis for a secondVersionOverrides
element that uses theVersionOverridesV1_1
schema. - Paste the copied data into the manifest just before the closing tag for the existing
VersionOverrides
element.
The following steps all apply to the newly inserted VersionOverrides
element.
-
In the
VersionOverrides
element, change the following attributes:- Change
xmlns
tohttp://schemas.microsoft.com/office/mailappversionoverrides/1.1
- Change
xsi:type
toVersionOverridesV1_1
- Change
-
After the
Resources
element, insert the following XML, replacingYOUR APP ID HERE
with the application ID from your app registration:<WebApplicationInfo> <Id>YOUR APP ID HERE</Id> <Resource>api://localhost:44349/YOUR APP ID HERE</Resource> <Scopes> <Scope>user.read</Scope> <Scope>files.readwrite</Scope> <Scope>mail.read</Scope> </Scopes> </WebApplicationInfo>
Note: Make sure that the port number in the
Resource
element matches the port used by your project. It should also match the port you used when registering the application. -
Save your changes.
Also see this commit on GitHub for the specific change to the manifest.
Note: At this point, Visual Studio may show a warning or error about the
WebApplicationInfo
element being invalid. The error may not show up until you try to build the solution. As of this writing, Visual Studio has not updated their schema files to include theWebApplicationInfo
element. To work around this problem, you can use the updated schema file in this repository: MailAppVersionOverridesV1_1.xsd.
- On your development machine, locate the existing MailAppVersionOverridesV1_1.xsd. This should be located in your Visual Studio installation directory under
./Xml/Schemas/{lcid}
. For example, on a typical installation of VS 2017 32-bit on an English (US) system, the full path would beC:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\Xml\Schemas\1033
.- Rename the existing file to
MailAppVersionOverridesV1_1.old
.- Move the version of the file from this repository into the folder.
Next we need to update the Web API. Currently it is very simple, requiring the add-in to send it both an Outlook token (which it gets from getCallbackTokenAsync
and a Graph token for OneDrive access (which it gets from OAuth)). We need to update it to accept a bearer token in the Authorization
header and use that token in the on-behalf-of flow to get an access token.
- In Visual Studio, click Tools, NuGet Package Manager, Manage NuGet Packages for Solution....
- Click Browse, then search for
System.IdentityModel.Tokens.Jwt
. - Select select the result, then make sure that the AttachmentDemoWeb project is selected. In the Version dropdown, select the latest
4.0.*
build and click Install.
Repeat those steps (without worrying about specific version) to install these additional packages:
Microsoft.Owin
Microsoft.Owin.Security
Microsoft.Owin.Security.Jwt
Microsoft.Owin.Security.OpenIdConnect
Microsoft.Owin.Host.SystemWeb
Finally, install the Microsoft Authentication Library (MSAL). This is a preview package, so to find it you must select the Include prerelease checkbox next to the search box, then search for Microsoft Identity Client
.
We're going to use OWIN to handle parsing and validating the access token. In order to get that to work, we need to setup the proper OWIN middleware using an OWIN startup class. We'll also add a custom token provider class that can get the signing tokens from the Azure endpoints to perform validation.
-
Open the
.\AttachmentDemoWeb\Web.config
file. Add the following XML after the first<configuration>
element:<appSettings> <add key="ida:AppId" value="YOUR APP ID HERE"/> <add key="ida:AppPassword" value="YOUR APP PASSWORD HERE"/> <add key="ida:RedirectUri" value="https://localhost:44349"/> </appSettings>
-
Replace
YOUR APP ID HERE
with the application ID from your app registration. -
Replace
YOUR APP PASSWORD HERE
with the application password from your app registration. -
If you are using a different port on your local machine, update the port number in the
ida:RedirectUri
value to match.
-
In Solution Explorer, right-click the App_Start folder and choose Add, then Class.... Name the class
OpenIdConnectCachingSecurityTokenProvider
and click Add. -
Open the
OpenIdConnectCachingSecurityTokenProvider.cs
file and replace its entire contents with the following code.using Microsoft.IdentityModel.Protocols; using Microsoft.Owin.Security.Jwt; using System.Collections.Generic; using System.IdentityModel.Tokens; using System.Threading; namespace AttachmentDemoWeb.App_Start { public class OpenIdConnectCachingSecurityTokenProvider : IIssuerSecurityTokenProvider { public ConfigurationManager<OpenIdConnectConfiguration> _configManager; private string _issuer; private IEnumerable<SecurityToken> _tokens; private readonly string _metadataEndpoint; private readonly ReaderWriterLockSlim _synclock = new ReaderWriterLockSlim(); public OpenIdConnectCachingSecurityTokenProvider(string metadataEndpoint) { _metadataEndpoint = metadataEndpoint; _configManager = new ConfigurationManager<OpenIdConnectConfiguration>(metadataEndpoint); RetrieveMetadata(); } /// <summary> /// Gets the issuer the credentials are for. /// </summary> /// <value> /// The issuer the credentials are for. /// </value> public string Issuer { get { RetrieveMetadata(); _synclock.EnterReadLock(); try { return _issuer; } finally { _synclock.ExitReadLock(); } } } /// <summary> /// Gets all known security tokens. /// </summary> /// <value> /// All known security tokens. /// </value> public IEnumerable<SecurityToken> SecurityTokens { get { RetrieveMetadata(); _synclock.EnterReadLock(); try { return _tokens; } finally { _synclock.ExitReadLock(); } } } private void RetrieveMetadata() { _synclock.EnterWriteLock(); try { OpenIdConnectConfiguration config = _configManager.GetConfigurationAsync().Result; _issuer = config.Issuer; _tokens = config.SigningTokens; } finally { _synclock.ExitWriteLock(); } } } }
-
In Solution Explorer, right-click the AttachmentDemoWeb project and choose Add, New Item.... Search for
OWIN
and select OWIN Startup class. Name the fileStartup.cs
and click Add. -
Open the
Startup.cs
file and replace its entire contents with the following code.using AttachmentDemoWeb.App_Start; using Microsoft.Owin; using Microsoft.Owin.Security.Jwt; using Microsoft.Owin.Security.OAuth; using Owin; using System.Configuration; using System.IdentityModel.Tokens; [assembly: OwinStartup(typeof(AttachmentDemoWeb.Startup))] namespace AttachmentDemoWeb { public class Startup { public void Configuration(IAppBuilder app) { // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=316888 var tokenValidationParms = new TokenValidationParameters { // Audience MUST be the application ID of the app ConfigurationManager.AppSettings["ida:AppId"], // Since this is multi-tenant we will validate the issuer in the controller ValidateIssuer = false, SaveSigninToken = true }; app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions { AccessTokenFormat = new JwtFormat(tokenValidationParms, new OpenIdConnectCachingSecurityTokenProvider("https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration")) }); } } }
The next step is to modify the controller to check for a bearer token. We want to preserve the existing functionality for clients that don't support SSO, so if there is no bearer token, we'll invoke the existing method.
-
Open the
.\AttachmentDemoWeb\Controllers\SaveAttachmentsController.cs
file. Add the following using statements at the top of the file:using System.Linq; using System.Security.Claims;
-
Replace the following line:
public async Task<IHttpActionResult> Post([FromBody]SaveAttachmentRequest request)
with the following:
private async Task<IHttpActionResult> SaveAttachmentsWithDistinctTokens(SaveAttachmentRequest request)
-
Add the following method to the
SaveAttachmentsController
class:public async Task<IHttpActionResult> Post([FromBody]SaveAttachmentRequest request) { if (Request.Headers.Contains("Authorization")) { // Request contains bearer token, validate it var scopeClaim = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/scope"); if (scopeClaim != null) { // Check the allowed scopes string[] addinScopes = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/scope").Value.Split(' '); if (!addinScopes.Contains("access_as_user")) { return BadRequest("The bearer token is missing the required scope."); } } else { return BadRequest("The bearer token is invalid."); } var issuerClaim = ClaimsPrincipal.Current.FindFirst("iss"); var tenantIdClaim = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid"); if (issuerClaim != null && tenantIdClaim != null) { // validate the issuer string expectedIssuer = string.Format("https://login.microsoftonline.com/{0}/v2.0", tenantIdClaim.Value); if (string.Compare(issuerClaim.Value, expectedIssuer, StringComparison.OrdinalIgnoreCase) != 0) { return BadRequest("The token issuer is invalid."); } } else { return BadRequest("The bearer token is invalid."); } // Passed validation, process the request return await SaveAttachmentsWithSsoToken(request); } else { // No bearer token, so this is a request without SSO // Access tokens are included in the request return await SaveAttachmentsWithDistinctTokens(request); } }
Let's take a look at what we did there. We moved the existing functionality to a private method, and created a new public Post
method that checks for the bearer token and either calls a method to save with an SSO token or calls the method to use the existing non-SSO method as appropriate. Now we need to implement the SaveAttachmentsWithSsoToken
method.
-
Add the followin
using
statements to the.\AttachmentDemoWeb\Controllers\SaveAttachmentsController.cs
file.using Microsoft.Identity.Client; using System.Configuration; using System.IdentityModel.Tokens;
-
Add the following method to the
SaveAttachmentsController
class:private async Task<IHttpActionResult> SaveAttachmentsWithSsoToken(SaveAttachmentRequest request) { // First retrieve the raw access token var bootstrapContext = ClaimsPrincipal.Current.Identities.First().BootstrapContext as BootstrapContext; if (bootstrapContext != null) { // Use MSAL to invoke the on-behalf-of flow to exchange token for a Graph token UserAssertion userAssertion = new UserAssertion(bootstrapContext.Token); ClientCredential clientCred = new ClientCredential(ConfigurationManager.AppSettings["ida:AppPassword"]); ConfidentialClientApplication cca = new ConfidentialClientApplication( ConfigurationManager.AppSettings["ida:AppId"], ConfigurationManager.AppSettings["ida:RedirectUri"], clientCred, null, null); string[] graphScopes = { "Files.ReadWrite", "Mail.Read" }; AuthenticationResult authResult = await cca.AcquireTokenOnBehalfOfAsync(graphScopes, userAssertion); // Initialize a Graph client GraphServiceClient graphClient = new GraphServiceClient( new DelegateAuthenticationProvider( (requestMessage) => { // Add the OneDrive access token to each outgoing request requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authResult.AccessToken); return Task.FromResult(0); })); foreach (string attachmentId in request.attachmentIds) { var attachment = await graphClient.Me.Messages[request.messageId].Attachments[attachmentId].Request().GetAsync(); // Is this a file or an Outlook item? if (string.Compare(attachment.ODataType, "#microsoft.graph.itemAttachment") == 0) { // Re-request the attachment with the item expanded var itemAttachment = await graphClient.Me.Messages[request.messageId].Attachments[attachmentId].Request() .Expand("microsoft.graph.itemAttachment/item").GetAsync() as ItemAttachment; // Serialize the item to JSON and save to OneDrive string jsonItem = JsonConvert.SerializeObject(itemAttachment.Item); MemoryStream fileStream = new MemoryStream(); StreamWriter sw = new StreamWriter(fileStream); sw.Write(jsonItem); sw.Flush(); fileStream.Position = 0; bool success = await SaveFileToOneDrive(graphClient, itemAttachment.Name + ".json", fileStream); if (!success) { return BadRequest(string.Format("Could not save {0} to OneDrive", itemAttachment.Name)); } } else { var fileAttachment = attachment as FileAttachment; // For files, we can build a stream directly from ContentBytes if (fileAttachment.Size < (4 * 1024 * 1024)) { MemoryStream fileStream = new MemoryStream(fileAttachment.ContentBytes); bool success = await SaveFileToOneDrive(graphClient, fileAttachment.Name, fileStream); if (!success) { return BadRequest(string.Format("Could not save {0} to OneDrive", fileAttachment.Name)); } } else { // TODO: Add code here to handle larger files. See: // https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/item_createuploadsession // and // https://github.com/microsoftgraph/aspnet-snippets-sample/blob/master/Graph-ASPNET-46-Snippets/Microsoft%20Graph%20ASPNET%20Snippets/Models/FilesService.cs return BadRequest("File is too large for simple upload."); } } } return Ok(); } else { return BadRequest("Could not retrieve access token from request."); } }
The only remaining task is to update the add-in JavaScript to get the SSO token and use it to call the Web API.
-
Open the
.\AttachmentDemoWeb\MessageRead.js
file. Rename the existingsaveAttachments
method tosaveAttachmentsWithPrompt
. Remove the call toshowSpinner
at the beginning of the method. -
Add the following code into the file:
function saveAttachments(attachmentIds) { showSpinner(); // First attempt to get an SSO token if (Office.context.auth !== undefined && Office.context.auth.getAccessTokenAsync !== undefined) { Office.context.auth.getAccessTokenAsync(function (result) { if (result.status === "succeeded") { // No need to prompt user, use this token to call Web API saveAttachmentsWithSSO(result.value, attachmentIds); } else { // Could not get SSO token, proceed with authentication prompt saveAttachmentsWithPrompt(attachmentIds); } }); } } function saveAttachmentsWithSSO(accessToken, attachmentIds) { var saveAttachmentsRequest = { attachmentIds: attachmentIds, messageId: getRestId(Office.context.mailbox.item.itemId) }; $.ajax({ type: "POST", url: "/api/SaveAttachments", headers: { "Authorization": "Bearer " + accessToken }, data: JSON.stringify(saveAttachmentsRequest), contentType: "application/json; charset=utf-8" }).done(function (data) { showNotification("Success", "Attachments saved"); }).fail(function (error) { showNotification("Error saving attachments", error.status); }).always(function () { hideSpinner(); }); }
-
Open the
.\AttachmentDemoWeb\Functions\FunctionFile.js
file. Rename the existingsaveAllAttachments
method tosaveAllAttachmentsWithPrompt
. -
Add the following code to the file:
function saveAllAttachments(event) { showProgress("Try to obtain SSO token"); // First attempt to get an SSO token if (Office.context.auth !== undefined && Office.context.auth.getAccessTokenAsync !== undefined) { Office.context.auth.getAccessTokenAsync(function (result) { if (result.status === "succeeded") { // No need to prompt user, use this token to call Web API saveAllAttachmentsWithSSO(result.value, event); } else { // Could not get SSO token, proceed with authentication prompt saveAllAttachmentsWithPrompt(event); } }); } } function saveAllAttachmentsWithSSO(ssoToken, event) { var attachmentIds = []; Office.context.mailbox.item.attachments.forEach(function (attachment) { attachmentIds.push(getRestId(attachment.id)); }); var saveAttachmentsRequest = { attachmentIds: attachmentIds, messageId: getRestId(Office.context.mailbox.item.itemId) }; $.ajax({ type: "POST", url: "/api/SaveAttachments", headers: { "Authorization": "Bearer " + ssoToken }, data: JSON.stringify(saveAttachmentsRequest), contentType: "application/json; charset=utf-8" }).done(function (data) { showSuccess("Attachments saved"); }).fail(function (error) { showError("Error saving attachments"); }).always(function () { event.completed(); }); }