新しいことにはウェルカム

技術 | 電子工作 | ガジェット | ゲーム のメモ書き

Microsoft Graph (Office365) API のトークンを取得して更新する方法

Web版のOffice365を操作する方法の1つに、Microsoft Graph (Office365) APIというものがあります。

例えば、Microsoft Graph (Office365) APIを使えば、Office365の外部から

  • OutLookからメール送信
  • Excel Onlineのファイル編集
  • SharePointのファイル取得

など、ユーザーに代わって、Web版のOffice365を使った様々な操作ができます。

APIを使うには、アクセストークンをOAuth2.0で取得する必要があるのですが、このOAuth2.0トークン関連の作業は結構煩雑です。

ここではその、Microsoft Graph (Office365) APIのトークン取得方法と、有効期限の切れたトークンの更新方法について記載しました。

方針

OAuth2.0周りのやりとりを自前でやるのは大変だったので、マイクロソフトのチュートリアルを参考に、マイクロソフトが作成している、Passport.jsのAzureADストラテジーpassport-azure-adを利用することにしました。

また、トークンの更新も、チュートリアルを参考にsimple-oauth2を利用することにしました。

予備知識

アプリ登録準備

Microsoft Graph (Office365) APIに限らず、OAuth2.0全般に当てはまることなのですが、トークンを取得するには、事前にトークン発行者に、トークンを取得するアプリを登録しておく必要があります。

Microsoft Graph (Office365) APIの場合、アプリは、AzureのAzure Active Directoryに登録します。

なので、アプリを登録するには、Azureのアカウントを持っている必要があり、無い場合は事前にアカウントを作成しておきます。

Azure Active Directoryには無料版があり、アプリの登録も無料でできるので、トークン取得のために使用する分には、Azureの料金は発生しません。

OAuth2.0フローとアプリに必要な情報

ざっくりですが、OAuth2.0では下記のようなやり取りがされます。

  1. アプリにログインリンクがあり、そこからユーザーがトークンを発行するサイトのログイン画面に遷移します
  2. そこでユーザーがログインし、トークン付与の同意画面が出るので同意します
  3. 同意すると、トークン発行依頼コードが発行され、その情報と共に、元のアプリへリダイレクトされます
  4. アプリが、受け取ったトークン発行依頼コードを、トークンを発行するサイトに送り、そのレスポンスとしてトークンを受け取ります

ログインリンクから遷移時には

  • どのアプリかを識別する 「アプリID」
  • そのアプリが正しいものであることを証明する 「シークレット」
  • 同意画面からアプリに戻る先の 「リダイレクトURL」

の情報を送るため、アプリを作成するにあたり、それら3つの情報が必要になります。

アプリ登録

Microsoft Graph (Office365) API のアプリ登録は、AzureポータルのAzure Active Directoryから行います。

  • [Azure]-[Azure Active Directory]-[アプリの登録]-[+新規登録]

Microsoft Graph (Office365) API のトークンを取得して更新する方法

登録画面で「リダイレクトURI」をhttp://localhost:3000/auth/callbackに設定します。

Microsoft Graph (Office365) API のトークンを取得して更新する方法

サポートされるアカウントの種類について

ここで、サポートされるアカウントの種類を選択するのですが、ちょっとややこしいので補足説明します。

まず、Office365は、個人向けと企業向けがあります。

企業向けOffice365は、企業毎に専用のドメインが割り当てられて、企業が独自に自社のOffice365ユーザーを追加して管理しています。

一方、個人向けOffice365は、マイクロソフトがドメインを管理していて、個人がマイクロソフトにアカウントを追加してもらって使用します。 いわゆるMicrosoftアカウントと呼ばれるものです。

アプリで設定するアカウントの種類は

  • シングルテナント
  • マルチテナント
  • マルチテナント+Microsoftアカウント

の3種類あります。

例えば、企業がOffice365を使っている場合、Azure Active Directoryには、自社で管理しているOffice365のユーザーが登録されています。

そして、そのAzure Active Directoryにアプリを登録する際、アプリのサポートするアカウントの種類を「シングルテナント」とすると、 アプリが取得できるユーザーは、そのAzure Active Directoryのユーザー(Office365ユーザー)のみに限定できます。

一方、アカウントの種類を「マルチテナント」とすると、自社で管理しているAzure Active Directoryのユーザーを超えて、 他企業のOffice365ユーザーのトークンも取得できるアプリになります。

また、「マルチテナント+Microsoftアカウント」とすると、察しの通り、個人のMicrosoftアカウントのトークンも取得できるアプリになります。

例えば、Office365の個人ユーザーが、自分で利用するためにトークンを取得したい場合は、適当にAzureアカウントを取得し、アプリを登録し、 そのアプリのアカウントの種類を「マルチテナント+Microsoftアカウント」にすることで、個人のOffice365のトークンを取得できるアプリを作ることができます。

アプリ情報をメモ

アプリの[概要]に移動して、アプリケーション(クライアント)IDをメモしておきます。

Microsoft Graph (Office365) API のトークンを取得して更新する方法

シークレット作成

[証明書とシークレット]に移動して、[クライアント シークレット]の+新しいクライアント シークレットをクリックします。

Microsoft Graph (Office365) API のトークンを取得して更新する方法

シークレットが生成されるのでメモしておきます。

Microsoft Graph (Office365) API のトークンを取得して更新する方法

アプリ作成

以上で、アプリがAzure Active Directoryに登録されます。次に、実際にアプリ(Webアプリ)を作成します。

今回は、Express.js + Passport.jsで、AzureAD認証でログインできる簡易Webサイト(Webアプリ)を作っています。

そして、「AzureAD認証でログインした時に取得されるトークン」=「Microsoft Graph (Office365) APIのトークン」となります。

下記にプログラム部分だけ記載しました。テンプレートや使い方など全てのソースはこちらにアップしました。

server.ts

import * as dotenv from 'dotenv';
import * as express from 'express';
import * as session from 'express-session';
import * as path from 'path';
import * as cookieParser from 'cookie-parser';
import * as bodyParser from 'body-parser';
import * as passport from 'passport';
import { OIDCStrategy } from 'passport-azure-ad';
dotenv.config();

// ini
const INI = {
    REDIRECT_URI: 'http://localhost:3000/auth/callback',
    AUTHORITY: 'https://login.microsoftonline.com/common',
    ID_METADATA: '/v2.0/.well-known/openid-configuration',
    AUTHORIZE_ENDPOINT: '/oauth2/v2.0/authorize',
    TOKEN_ENDPOINT: '/oauth2/v2.0/token',
    SCOPES: [
        'profile',
        'offline_access',
        'user.read',
        'Files.Read',
        'Sites.Read.All'
    ]
};

// user database
const user_db: any = {};

// user data -> user identifier
passport.serializeUser((user: any, cb) => {
    cb(null, user.profile.oid);
});

// user identifier -> user data
passport.deserializeUser((id: string, cb) => {
    cb(null, user_db[id]);
});

passport.use(new OIDCStrategy(
    {
        identityMetadata: `${INI.AUTHORITY}${INI.ID_METADATA}`,
        clientID: process.env.CLIENT_ID,
        responseType: 'code',
        responseMode: 'form_post',
        redirectUrl: INI.REDIRECT_URI,
        allowHttpForRedirectUrl: true,
        clientSecret: process.env.CLIENT_SECRET,
        validateIssuer: false,
        scope: INI.SCOPES,
        passReqToCallback: false
    },
    (iss, sub, profile, access_token, refresh_token, done) => {
        try {
            if (profile.oid) {

                // create user data
                const user = {
                    iss,
                    sub,
                    profile,
                    access_token,
                    refresh_token
                };

                // register user data in user database
                // user identifier is profile.oid
                user_db[profile.oid] = user;

                return done(null, user);
            }
            return done(null, false);
        } catch (err) {
            return done(null, err);
        }
    }
));

// express
const app = express();
const PORT_NO = 3000;

// session
app.use(session({
    secret: 'secret_value_here',
    resave: false,
    saveUninitialized: false
}));

// template
app.set('views', path.dirname(__dirname) + '/view');
app.set('view engine', 'ejs');
app.engine('html', require('ejs').renderFile);

// etc
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());

// passport
app.use(passport.initialize());
app.use(passport.session());

// route
app.get('/', (req, res) => {
    res.render('index.html', { user: req.user });
});

app.get('/auth/signin',
    passport.authenticate('azuread-openidconnect', { failureRedirect: '/' })
    , (req, res) => {
        res.redirect('/');
    }
);

app.post('/auth/callback',
    passport.authenticate('azuread-openidconnect', { failureRedirect: '/' })
    , (req, res) => {
        res.redirect('/');
    }
);

app.get('/auth/signout',
    (req, res) => {
        // clear session database
        req.session.destroy(() => { });
        res.redirect('/');
    }
);

app.listen(PORT_NO);

補足説明

  • 認証部分はpassport-azure-adに丸投げしています
  • 設定ファイル.envに、メモしておいた「アプリID」「シークレット」「リダイレクトURL」を記載して使用します
  • トークンの権限(スコープ)はSCOPESで変更できます
  • 認証できると、ユーザーのOpenIDを受け取るので、それをキーに、トークンをユーザーデータとして、簡易DBdata_dbに追加しています
  • アプリのアカウントの種類は「マルチテナント+Microsoftアカウント」でテストしました

Passport.jsの使い方は別記事にしました。

www.kwbtblog.com

アプリ実行

アプリを実行すると、ローカルにWebサーバーが立ち上がるので、ブラウザでhttp://localhost:3000を開きます。

Microsoft Graph (Office365) API のトークンを取得して更新する方法

signinをクリックすると、Office365のログオン画面になるので、ログオンしてトークン取得に同意します。

Microsoft Graph (Office365) API のトークンを取得して更新する方法

リダイレクトでアプリに戻ってくると、トークン(アクセストークン・リフレッシュトークン)およびユーザー情報が表示されます。

Microsoft Graph (Office365) API のトークンを取得して更新する方法

APIを使う時は、ここで取得したリフレッシュトークンを使ってアクセストークンを取得し、そのアクセストークンでAPIを呼びだします。

続いて、トークンの使い方と、有効期限の切れたトークンの更新の方法になります。

有効期限の切れたトークンの更新

Microsoft Graph (Office365) APIのトークンには、下記の仕様があります。

  • アクセストークンの有効期限は1時間です
  • アクセストークンの有効期限が切れた場合は、リフレッシュトークンでアクセストークンを更新します
  • リフレッシュトークンの有効期限は90日です
  • リフレッシュトークンでアクセストークンを更新した際、新しいリフレッシュトークンも発行されます

つまり、 1度取得したリフレッシュトークンを、永続的に使うことができません 。

なので、1度取得したリフレッシュトークンで、アクセストークンを永続的に更新できるようにするには、 リフレッシュトークンを、アクセストークンの更新の度に、同時に取得される新しいリフレッシュトークンで乗り換えていくようにします。

以上を踏まえ、アクセストークンの更新と、アクセストークンを使ったユーザー情報の取得サンプルが下記になります。

import * as dotenv from 'dotenv';
import * as fs from 'fs';
import * as simpleOauth2 from 'simple-oauth2';
import axios from 'axios';
dotenv.config();

const oauth2 = simpleOauth2.create({
    client: {
        id: process.env.CLIENT_ID,
        secret: process.env.CLIENT_SECRET
    },
    auth: {
        tokenHost: "https://login.microsoftonline.com/common",
        authorizePath: "/oauth2/v2.0/authorize",
        tokenPath: "/oauth2/v2.0/token"
    }
});

// トークンを保存しておく
const TOKEN_PATH = './data/token';

async function getToken() {

    let graphToken: any = {
        "access_token": "",
        "refresh_token": "",
        "expires_in": ""
    };

    if (fs.existsSync(TOKEN_PATH)) {
        // トークンが保存されていればそれを使う
        graphToken = JSON.parse(fs.readFileSync(TOKEN_PATH).toString());

        const accessToken = oauth2.accessToken.create(graphToken);
        if (!accessToken.expired()) {
            // トークンの有効期限が切れてなければアクセストークンを返す
            return graphToken.access_token;

        }
    } else {
        // トークンが保存されていないので、最初に設定したリフレッシュトークンを使う
        graphToken.refresh_token = process.env.REFRESH_TOKEN;
    }

    // トークンを更新する
    const accessToken = oauth2.accessToken.create(graphToken);
    const token = await accessToken.refresh();

    // トークンを保存する
    graphToken = token.token;
    fs.writeFileSync(TOKEN_PATH, JSON.stringify(graphToken));

    // アクセストークンを返す
    return graphToken.access_token;
}

// Graph APIテスト
async function testGetMe(token: string) {
    const url = `https://graph.microsoft.com/v1.0/me`;

    const res = await axios.request({
        headers: {
            'authorization': `Bearer ${token}`
        },
        url,
        method: "GET"
    });

    return res.data;
}

(async () => {
    try {

        const access_token = await getToken();

        const user = await testGetMe(access_token);
        console.log(user);

    } catch (err) {

        console.log(err);

    }
})();

補足説明

  • リフレッシュトークンからアクセストークン取得にsimple-oauth2を使っています
  • 初回はWebアプリで取得したリフレッシュトークンからアクセストークンを取得しています
  • トークンを更新すると、アクセストークンとリフレッシュトークンの両方が返ってきます
  • 更新したトークンはdata/tokenにファイルで保存しています
  • 2回目移行は、保存したトークンを利用しています
  • simple-oauth2のバージョンが3以上だとエラーになるため、「2.2.1」を使用しています
  • トークン利用のサンプルとして、testGetMe()で自分の情報を取得しています

感想など

今回取得したトークンは、1Office365ユーザーとしてのトークンなので、権限の範囲は、そのユーザーができることに限ります。つまり、他のユーザーのメールを見たりはできません。

Office365の管理者なら、ユーザーに紐付かない、管理者アプリとしてのトークン取得の方法が別途ありますが、今回は扱っていません。

docs.microsoft.com

Webサーバーを立ち上げているので大げさな感じもしますが、同意後のリダイレクトを受ける必要があるので、Webサーバー立てないとダメなんですよねぇ。

参考記事