かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

ASP.NET(Azure WebApp)でMicrosoft.Office365.OutlookServicesを使って予定をとってくる

超苦労したのでメモっておきます。

まず、Office 365のテナントからいけるAzure ADにアプリケーションを追加します。追加したら、CliendIDとClientSecret(期限つきのキーのやつ)とTenantID(エンドポイントを表示したときに見れるやつ)をとっておきます。

次に、ASP.NET MVCのプロジェクトに以下のライブラリを追加します。

  • Microsoft.IdentityModel.Clients.ActiveDirectory
    • 2016/7/22現在、最新版をとってくるとエラーになるので3.10.xを入れること!(はまったポイント)
  • Microsoft.Office365.Discovery
  • Microsoft.Office365.OutlookServices
  • Microsoft.Owin.Host.SystemWeb
  • Microsoft.Owin.Security.Cookies
  • Microsoft.Owin.Security.OpenIdConnect

そして、OWINのStartupクラスを作ってAzure ADとの連携に必要な決まり文句を書きます。

using System;
using System.Threading.Tasks;
using Microsoft.Owin;
using Owin;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.OpenIdConnect;
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using System.IdentityModel.Claims;
using System.Web;
using OwinSecurityError.Models;

[assembly: OwinStartup(typeof(OwinSecurityError.Startup))]

namespace OwinSecurityError
{
    public class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
            app.UseCookieAuthentication(new CookieAuthenticationOptions());
            app.UseOpenIdConnectAuthentication(
                new OpenIdConnectAuthenticationOptions
                {
                    ClientId = Consts.ClientId,
                    ClientSecret = Consts.ClientSecret,
                    Authority = "https://login.windows.net/O365のテナント名.onmicrosoft.com/",
                    Notifications = new OpenIdConnectAuthenticationNotifications()
                    {
                        //
                        // If there is a code in the OpenID Connect response, redeem it for an access token and refresh token, and store those away.

                        AuthorizationCodeReceived = async (context) =>
                        {
                            var code = context.Code;

                            var credential = new ClientCredential(Consts.ClientId, Consts.ClientSecret);
                            var userObjectId = context.AuthenticationTicket.Identity.FindFirst(ClaimTypes.NameIdentifier).Value;

                            var authContext = new AuthenticationContext(Consts.Authority, new ADALTokenCache(userObjectId));

                            await authContext.AcquireTokenByAuthorizationCodeAsync(code, new Uri(HttpContext.Current.Request.Url.GetLeftPart(UriPartial.Path)), credential, "https://graph.windows.net");
                        },


                        RedirectToIdentityProvider = (context) =>
                        {
                            // This ensures that the address used for sign in and sign out is picked up dynamically from the request
                            // this allows you to deploy your app (to Azure Web Sites, for example)without having to change settings
                            // Remember that the base URL of the address used here must be provisioned in Azure AD beforehand.
                            string appBaseUrl = context.Request.Scheme + "://" + context.Request.Host + context.Request.PathBase;
                            context.ProtocolMessage.RedirectUri = appBaseUrl + "/";
                            context.ProtocolMessage.PostLogoutRedirectUri = appBaseUrl;

                            return Task.FromResult(0);
                        },

                        AuthenticationFailed = (context) =>
                        {
                            // Suppress the exception if you don't want to see the error
                            context.HandleResponse();

                            return Task.FromResult(0);
                        }

                    },
                });
        }
    }

    static class Consts
    {
        public static string ClientId { get; } = "クライアントID";

        public static string ClientSecret { get; } = "クライアントシークレット";

        public static string Authority { get; } = "https://login.windows.net/テナントID";
    }
}

ADALTokenCacheがポイントで、こいつでTokenをキャッシュしておきます。正しい実装はデータベースとかに値をキャッシュするのですが、今回はサンプルなのでインメモリで保持してます(再デプロイとかで消えるので実運用ではだめよ)

using Microsoft.IdentityModel.Clients.ActiveDirectory;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

namespace OwinSecurityError.Models
{
    public class ADALTokenCache : TokenCache
    {
        private static Dictionary<string, byte[]> Cache { get; } = new Dictionary<string, byte[]>();

        private string User { get; }

        public ADALTokenCache(string user)
        {
            this.User = user;
            this.BeforeAccess = this.BeforeAccessNotification;
            this.AfterAccess = this.AfterAccessNotification;
            this.BeforeWrite = this.BeforeWriteNotification;

            this.DeserializeIfContains();
        }

        private void BeforeWriteNotification(TokenCacheNotificationArgs args)
        {
        }

        private void AfterAccessNotification(TokenCacheNotificationArgs args)
        {
            lock (Cache)
            {
                Cache[this.User] = this.Serialize();
            }
        }

        private void BeforeAccessNotification(TokenCacheNotificationArgs args)
        {
            this.DeserializeIfContains();
        }

        private void DeserializeIfContains()
        {
            lock (Cache)
            {
                if (Cache.ContainsKey(this.User))
                {
                    this.Deserialize(Cache[this.User]);
                }
            }
        }
    }
}

あとは、コントローラあたりでOutlookServiceClientあたりを作ってやればOKです。この作り方も曲者で認証情報やらをこねくり回す必要があります。コードはこんな感じ。

using Microsoft.IdentityModel.Clients.ActiveDirectory;
using Microsoft.Office365.Discovery;
using Microsoft.Office365.OutlookServices;
using OwinSecurityError.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using System.Web;
using System.Web.Mvc;

namespace OwinSecurityError.Controllers
{
    [Authorize]
    public class HomeController : Controller
    {
        public async Task<ActionResult> Index()
        {
            var client = await EnsureOutlookServicesClientCreatedAsync("Calendar");
            var user = await client.Me.ExecuteAsync();
            var date = DateTimeOffset.UtcNow - TimeSpan.FromDays(1);
            var view = client.Me.Calendar.GetCalendarView(date, date + TimeSpan.FromDays(2));
            var pages = await view.ExecuteAsync();
            var result = new List<string>();
            while (true)
            {
                result.AddRange(pages.CurrentPage.Select(x => $"{x.Location.DisplayName} {x.Subject}"));
                if (!pages.MorePagesAvailable)
                {
                    break;
                }
                await pages.GetNextPageAsync();
            }
            this.ViewBag.Title = user.DisplayName;
            return View(result);
        }

        private static async Task<OutlookServicesClient> EnsureOutlookServicesClientCreatedAsync(string capabilityName)
        {

            var signInUserId = ClaimsPrincipal.Current.FindFirst(ClaimTypes.NameIdentifier).Value;
            var userObjectId = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier").Value;

            AuthenticationContext authContext = new AuthenticationContext(Consts.Authority, new ADALTokenCache(signInUserId));
            try
            {
                var discClient = new DiscoveryClient(new Uri("https://api.office.com/discovery/v1.0/me/"),
                    async () =>
                    {
                        var authResult = await authContext.AcquireTokenSilentAsync("https://api.office.com/discovery/",
                                                                                   new ClientCredential(Consts.ClientId,
                                                                                                        Consts.ClientSecret),
                                                                                   new UserIdentifier(userObjectId,
                                                                                                      UserIdentifierType.UniqueId));

                        return authResult.AccessToken;
                    });

                var dcr = await discClient.DiscoverCapabilityAsync(capabilityName);

                return new OutlookServicesClient(dcr.ServiceEndpointUri,
                    async () =>
                    {
                        var authResult = await authContext.AcquireTokenSilentAsync(dcr.ServiceResourceId,
                                                                                   new ClientCredential(Consts.ClientId,
                                                                                                        Consts.ClientSecret),
                                                                                   new UserIdentifier(userObjectId,
                                                                                                      UserIdentifierType.UniqueId));

                        return authResult.AccessToken;
                    });
            }
            catch (AdalException ex)
            {
                if (ex.ErrorCode == AdalError.FailedToAcquireTokenSilently)
                {
                    authContext.TokenCache.Clear();
                    throw;
                }
                return null;
            }
        }
    }
}

クライアントを作ったら、後はAPIはそれっぽくいけるのでCalendarから値をとったりしてます。

こんな感じのViewを定義すればリストで会議室と予定が表示されます。

@model List<string>
@{
    ViewBag.Title = "Index";
}

<h2>Index</h2>

<ul>
    @foreach (var item in this.Model)
    {
        <li>@item</li>
    }
</ul>

超苦労した!