からめもぶろぐ。

俺たちは雰囲気で OAuth をやっている

SharePoint Framework (SPFx) から Office 365 API を叩いてみる

SharePoint Framework (SPFx) の Web パーツでは新しいエクスペリエンスが採用され、これまでの SharePoint アドインで使用されていた iframe が廃止されました。SharePoint アドインでは OAuth2 の暗黙的な許可フローでうまいこと iframe 内でリダイレクトしてできたのですが、SPFx ではそれができなくなりました。さて困ったということで頑張ってなんとかしてみました。

念のため公式の対応方法を紹介しておきます。

dev.office.com

adal.js が Web パーツに対応していないから AuthorizationContext の中身を書き換えろとかそれライブラリで対応する話じゃないんですかね。無理して adal.js を使うこともないので今回は使わずに実装します。

サンプル コード

github.com

アプリケーションの登録

Azure ActiveDirectory にアプリケーションを登録してアプリケーション ID を控えておきます。アクセス許可に Exchange Online の Mail.Read を選択します。

プロジェクトの作成

SharePoint Framework を使うまでの流れは以下を参考にしてください。

dev.office.com

yeoman を使って適当な名前 (今回は MyApplication) でプロジェクトを作成します。現時点では MyApplicationWebPart.manifest.json にバグがあって $schema のパスが通っていません。そこでファイル名を clientSideComponentManifestSchema.json から client-side-component-manifest.schema.json に変更します。

Web パーツ プロパティの作成

別に定数でもいいのですがせっかくなのでプロパティを作成します。

IMyApplicationWebPartProps.ts
export interface IMyApplicationWebPartProps {
    appId: string;
    authUrl: string;
    resourceUrl: string;
}
MyApplicationWebPart.manifest.json
{
    "preconfiguredEntries": [
        {
            "properties": {
                "appId": "{{appid}}",
                "authUrl": "https://login.microsoftonline.com/common/oauth2/authorize",
                "resourceUrl": "https://outlook.office.com"
            }
        }
    ]
}

ユーザー プロパティの作成

一度取得した Access Token を sessionStorage に保存しておくようにします。コンストラクターで Web パーツのインスタンス ID を受け取ることで、複数の Web パーツがあった場合でも Access Token が競合しないようにします。

IMyApplicationUserProps.ts
export class MyApplicationUserProps {

    private instanceId: string;

    constructor(instanceId: string) {
        this.instanceId = instanceId;
    }

    public get accessToken(): string {
        return this._getValue("accessToken");
    }

    public set accessToken(value: string) {
        this._setValue("accessToken", value);
    }

    private _getValue(key: string): string {
        var stringValue = sessionStorage.getItem(this.instanceId);
        if (stringValue == null) {
            return null;
        }
        var jsonValue = JSON.parse(stringValue);
        return jsonValue[key];
    }

    private _setValue(key: string, value: string): void {
        var stringValue = sessionStorage.getItem(this.instanceId);
        if (stringValue == null) {
            stringValue = "{}";
        }
        var jsonValue = JSON.parse(stringValue);
        jsonValue[key] = value;
        stringValue = JSON.stringify(jsonValue);
        sessionStorage.setItem(this.instanceId, stringValue);
    }

}

OAuth フローの作成

上記にもある通り iframe によるリダイレクトができないので window.open() で新しいウィンドウを立ち上げるようにします。呼び出し元では setInterval() でウィンドウの状態を監視し、フローが終わったら URL から Access Token を取得します。取得が終わったら Outlook Mail REST API を叩いて未読件数を取得します。

MyApplicationWebPart.ts
public render(): void {
    this.userProperties = new MyApplicationUserProps(this.context.instanceId);
    if (window.location.hash == "") {
        var loginName = this.context.pageContext.user.loginName;
        if (this.userProperties.loginName != loginName) {
            this.userProperties.clear();
            this.userProperties.loginName = loginName;
        }
        if (this.userProperties.accessToken == null) {
            var redirectUrl = window.location.href.split("?")[0];
            var requestUrl = this.properties.authUrl + "?" +
                "response_type=token" + "&" +
                "client_id=" + encodeURI(this.properties.appId) + "&" +
                "resource=" + encodeURI(this.properties.resourceUrl) + "&" +
                "redirect_uri=" + encodeURI(redirectUrl);
            var popupWindow = window.open(requestUrl, this.context.instanceId, "width=600px,height=400px");
            var handle = setInterval((self) => {
                if (popupWindow == null || popupWindow.closed == null || popupWindow.closed == true) {
                    clearInterval(handle);
                }
                try {
                    if (popupWindow.location.href.indexOf(redirectUrl) != -1) {
                        var hash = popupWindow.location.hash;
                        clearInterval(handle);
                        popupWindow.close();
                        var query = {};
                        var elements = hash.slice(1).split("&");
                        for (var index = 0; index < elements.length; index++) {
                            var pair = elements[index].split("=");
                            var key = decodeURIComponent(pair[0]);
                            var value = decodeURIComponent(pair[1]);
                            query[key] = value;
                        }
                        self.userProperties.accessToken = query["access_token"];
                        self.userProperties.expiresIn = query["expires_in"];
                        self._renderBody();
                        self._renderContent();
                    }
                } catch (e) { }
            }, 1, this);
        } else {
            this._renderBody();
            this._renderContent();
        }
    }
}

private _renderBody(): void {
    this.domElement.innerHTML = `
        <div id="${this.context.manifest.id}" class="${styles.container}">
        </div>`;
}

private _renderContent(): void {
    var container = this.domElement.querySelector(`#${this.context.manifest.id}`);
    var requestUrl = this.properties.resourceUrl + "/api/v2.0/me/mailfolders";
    fetch(requestUrl, {
        method: "GET",
        headers: new Headers({
            "Accept": "application/json",
            "Authorization": `Bearer ${this.userProperties.accessToken}`
        })
    })
        .then(response => response.json())
        .then(data => {
            var items = data.value;
            for (var index = 0; index < items.length; index++) {
                var displayName = items[index].DisplayName;
                var unreadItemCount = items[index].UnreadItemCount;
                container.innerHTML += `<div>${displayName}: ${unreadItemCount}</div>`;
            }
        });
}

実行結果

初回のみ新しいウィンドウが立ち上がります。次回以降は保存された Access Token が使われるためウィンドウは表示されません。

もしかしたら動的に iframe を作ってフローを処理する方法もあるかも。