読者です 読者をやめる 読者になる 読者になる

かずきのBlog@hatena

日本マイクロソフトに勤めています。XAML + C#の組み合わせをメインに、たまにASP.NETやJavaなどの.NET系以外のことも書いています。掲載内容は個人の見解であり、所属する企業を代表するものではありません。

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>

超苦労した!