Managed to get it working - gist here: https://gist.github.com/zplume/e4244fb9415799a111e5062a23e22d1e
I updated the Azure app's manifest.json to enable oauth2 implicit flow:
"oauth2AllowImplicitFlow": true
I granted the app access to "Read and write items and lists in all site collections" on behalf of the user (under delegated permissions) from the Azure AD app settings page ("permissions to other applications").
I made sure the web.config ida:ClientId value matched the client id in Azure AD and set up a new app key in Azure AD for the app, which I copied to the web.config ida:AppKey value
<appSettings>
<add key="webpages:Version" value="3.0.0.0" />
<add key="webpages:Enabled" value="false" />
<add key="ClientValidationEnabled" value="true" />
<add key="UnobtrusiveJavaScriptEnabled" value="true" />
<add key="ida:ClientId" value="[app_guid]" />
<add key="ida:AADInstance" value="https://login.microsoftonline.com/" />
<add key="ida:Domain" value="[tenant_name].onmicrosoft.com" />
<add key="ida:TenantId" value="[tenant_id]" />
<add key="ida:PostLogoutRedirectUri" value="https://localhost:44300/" />
<add key="ida:AppKey" value="[app_key]" />
<add key="ida:Resource" value="https://[tenant_name].sharepoint.com/" />
</appSettings>
I retrieve a user token with ADAL.js using authenticationContext.acquireToken(clientId), then include the resulting token in the header of the AJAX request to the WebAPI:
// Access an Azure hosted WebAPI using ADAL.js to authenticate and get user token
// requires jQuery for ajax (could be swapped out with fetch + polyfill)
// credit to the following samples/blogs:
// https://github.com/Azure-Samples/active-directory-javascript-singlepageapp-dotnet-webapi/blob/master/TodoSPA/App/Scripts/app.js
// https://blogs.msdn.microsoft.com/kaevans/2014/04/15/calling-o365-apis-from-your-web-api-on-behalf-of-a-user/
// https://www.itunity.com/article/calling-office-365-apis-jquery-adaljs-2758
(function ($) {
var config = {
instance: 'https://login.microsoftonline.com/',
tenant: '[tenant_name].onmicrosoft.com',
clientId: '[app_guid]',
postLogoutRedirectUri: window.location.origin,
cacheLocation: 'localStorage'
};
function callWebApi(token) {
// url with trailing forward-slash removed + endpoint
var baseUrl = window.location.href.replace(/\/$/, "");
var url = baseUrl + "/api/action/documents?siteUrl=" + encodeURIComponent("https://[tenant_name].sharepoint.com/[site_url]");
$.ajax({
type: 'GET',
url: url,
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + token,
}
}).done(function (data) {
// TODO: do something with the data
console.log(data);
}).fail(function () {
// TODO: let the user know the request failed
console.log("Error", arguments[2]);
});
}
function getToken(callback) {
var authContext = new AuthenticationContext(config);
// save tokens if this is a return from AAD
authContext.handleWindowCallback();
var user = authContext.getCachedUser();
// attempt callback
if (user) {
// acquire user token
authContext.acquireToken(authContext.config.clientId, function (error, token) {
if (error || !token) {
console.log(error);
return;
}
// execute callback function with user token as a parameter
callback(token);
});
}
else if (authContext.getLoginError()) {
// error logging in
console.log("Error logging in", authContext.getLoginError());
}
else {
// not logged in
console.log("Logging in");
authContext.login();
}
}
// get user token, call webapi endpoint
getToken(callWebApi);
})(jQuery);
Then in the ApiController, I can read the user token header and include this in requests against SharePoint, either via CSOM or REST:
// make calls against SPO using a user access token passed from ADAL.js
// credit to Kirk Evans: https://blogs.msdn.microsoft.com/kaevans/2014/04/15/calling-o365-apis-from-your-web-api-on-behalf-of-a-user/
using System;
using System.Web.Http;
using System.Configuration;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using System.Globalization;
using System.Net.Http;
using System.Web;
using System.Security.Claims;
using System.Linq;
namespace zplume
{
// routing + convention = prefix "api/action"
[Authorize]
public class ActionController : ApiController
{
// using default routing, methods are accessible via [prefix]/[methodname], e.g. "/api/action/documents"
[HttpGet]
public async Task<string> Documents(string siteURL)
{
string accessToken = await GetAccessToken();
return GetListTitleWithCSOM(siteURL, accessToken);
//return await GetListTitleWithREST(siteURL, accessToken);
}
private static string GetListTitleWithCSOM(string siteURL, string accessToken)
{
var authMgr = new OfficeDevPnP.Core.AuthenticationManager();
using (var ctx = authMgr.GetAzureADAccessTokenAuthenticatedContext(siteURL, accessToken))
{
var list = ctx.Web.Lists.GetByTitle("Documents");
ctx.Load(list, l => l.Title);
ctx.ExecuteQuery();
return list.Title;
}
}
private static async Task<string> GetListTitleWithREST(string siteURL, string accessToken)
{
string requestUrl = siteURL + "/_api/Web/Lists/GetByTitle('Documents')/Items?$select=Title";
HttpClient client = new HttpClient();
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
request.Headers.Add("Accept", "application/atom+xml");
request.Headers.Add("Authorization", $"Bearer {accessToken}");
HttpResponseMessage response = await client.SendAsync(request);
if (response.IsSuccessStatusCode)
{
string responseString = await response.Content.ReadAsStringAsync();
return responseString;
}
// TODO: log this value as an error, throw
return await response.Content.ReadAsStringAsync();
}
private async Task<string> GetAccessToken()
{
string clientId = ConfigurationManager.AppSettings["ida:ClientId"];
string appKey = ConfigurationManager.AppSettings["ida:AppKey"];
string aadInstance = ConfigurationManager.AppSettings["ida:AADInstance"];
string tenant = ConfigurationManager.AppSettings["ida:TenantId"];
string domain = ConfigurationManager.AppSettings["ida:Domain"];
string resource = ConfigurationManager.AppSettings["ida:Resource"];
AuthenticationResult result = null;
ClientCredential clientCred = new ClientCredential(clientId, appKey);
string authHeader = HttpContext.Current.Request.Headers["Authorization"];
string userAccessToken = authHeader.Substring(authHeader.LastIndexOf(' ')).Trim();
UserAssertion userAssertion = new UserAssertion(userAccessToken);
string authority = aadInstance + domain;
AuthenticationContext authContext = new AuthenticationContext(authority);
//result = await authContext.AcquireTokenAsync(resource, clientCred); // auth without user assertion (fails, app only not allowed)
result = await authContext.AcquireTokenAsync(resource, clientCred, userAssertion); // clientCred and userAssertion params have swapped places since Kirk's blog
return result.AccessToken;
}
}
}