💻

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

2022/01/01に公開

はじめに

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

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

https://docs.microsoft.com/ja-jp/sharepoint/dev/spfx/web-parts/guidance/connect-to-api-secured-with-aad?WT.mc_id=M365-MVP-5002941

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

サンプル コード

https://github.com/karamem0/samples/tree/main/sharepoint-framework-read-mail-folder

実装方法

アプリケーションの登録

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

プロジェクトの作成

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

https://docs.microsoft.com/ja-jp/sharepoint/dev/spfx/set-up-your-development-environment?WT.mc_id=M365-MVP-5002941

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

変更前 変更後
clientSideComponentManifestSchema.json client-side-component-manifest.schema.json

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

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

ISampleApplicationWebPartProps.ts

export interface ISampleApplicationWebPartProps {
    appId: string;
    authUrl: string;
    resourceUrl: string;
}
SampleApplicationWebPart.manifest.json
{
    "preconfiguredEntries": [
        {
            "properties": {
                "appId": "{{app-id}}",
                "authUrl": "https://login.microsoftonline.com/common/oauth2/authorize",
                "resourceUrl": "https://outlook.office.com"
            }
        }
    ]
}

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

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

ISampleApplicationUserProps.ts

export class SampleApplicationUserProps {

    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 からアクセス トークンを取得します。取得が終わったら Outlook Mail REST API を叩いて未読件数を取得します。

SampleApplicationWebPart.ts

public render(): void {
    this.userProperties = new SampleApplicationUserProps(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>`;
            }
        });
}

実行結果

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

おわりに

動的に iframe を作ってフローを処理する方法もありそうです。

Discussion