freeeとkintoneの連携を作りながら、OAuthについて考えてみた！

# 前書き
この記事は、kintoneのJavaScriptカスタマイズを使って、freeeのAPIを呼び出すときにまず最初にぶつかるであろうOAuth認証について実際のコードを交えながら考えてみた記事です。あまり難解なコードを使わず、読みやすい書き方で作ってみました。途中、OAuthについての説明もあるので、初心者の人にも伝わると信じてます！

また、[kintone2 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/kintone2)の12日目の記事でもあります。

# さっさと開発手順を見たい人はこちら
=> [準備編](https://qiita.com/m_ando_japan/private/77c96d88a1ab33e980df#%E6%BA%96%E5%82%99%E7%B7%A8)

# 全体像
この記事では、freeeから取得した認証情報をkintoneアプリに保存する設計としています。freeeへのOAuth認証が成功した場合、認証時に取得した情報をアプリに保存していきます。

## なぜアプリに保存するか？
ここで、kintoneでAPI連携を開発された経験のある方は、APIキーを1回発行すれば良いのでは？と思われるかもしれません。しかし、freeeのAPI連携ではAPIキーを使った外部からのAPI呼び出しが許可されていません。
この理由は、想像ですが会計情報というデータの性質上「どの利用者が更新したか」という情報を記録する必要があるからではないかと考えています。

ということで、freeeのAPI連携では「OAuth」という仕組みで接続する事が必須となります。OAuthの詳しい仕組みは、初心者には結構難解で、説明も長くなるため詳細は省きます。ここで理解していただきたい点は、2つです。

* OAuthの認証には有効期限がある
* 有効期限の更新には、認証された時に受け取った情報が必要

2つ目がアプリに保存する理由です。APIキーと違い、有効期限の更新処理が必要となります。そして、更新には認証成功時に受け取った情報を使って、更新依頼を送信する必要があります。そのために、認証成功時の情報をどこかに保存することが必須となり、この記事の構成ではkintoneアプリに保存することにしています。

（参考）
[一番分かりやすい OAuth の説明](https://qiita.com/TakahikoKawasaki/items/e37caf50776e00e733be)
[OAuth2の解説サイトを漁る前に](https://qiita.com/kojisaiki/items/48adf59d5d634fd330af)

## 認証に関するデータのやりとり（初回認証）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125488/247bdf3f-9997-db47-8bf1-1673b0d0d70a.png)
## 認証の処理フロー
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125488/44859edd-af05-3260-0d81-52bf71cda0f7.png)

# 開発する内容
ということで、kintoneからfreeeへのOAuthを開発するためには大きく3つの処理が必要となります。

1. 初回の認証を実行し、結果を保存する
2. 認証情報の有効期限更新が必要な場合更新する
3. 保存された認証情報でAPIを実行する

# 準備編
## 保存用のkintoneアプリを作成する
|フィールド名|フィールドコード|タイプ|必須|保存するデータ|
|---|---|---|---|---|
|Client ID| clientId |文字列1行|*|OAuth用のClient ID|
|Client Secret| clientSecret |文字列1行|*|OAuth用のClient Secret|
|アクセストークン| accessToken |文字列1行||取得したOAuthのアクセストークン|
|リフレッシュトークン| refreshToken |文字列1行||取得したOAuthの更新トークン|
|トークン有効期限| expiresDateTime |日時||更新トークンの有効期限|
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125488/e6e1cc17-7389-1fd2-2ab5-aaa3e7cd0108.png)

## 保存用のkintoneアプリの権限設定
これらの情報が漏洩すると、許可されたfreeeの操作が全て実行できてしまうため、権限の管理には注意が必要です。この記事では、kintoneの1ユーザにつきfreeeのユーザが1つあることを前提にしています。
freeeのOAuth情報が漏洩し、freeeの情報が更新・変更されたとしてもその場合のユーザが特定できるため責任範囲を限定する、という考え方です。

そのため、作成するkintoneアプリでは、管理者を含めて**自分で作成したレコードしか見えない**ようにします。
#### アプリのアクセス権
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125488/313170e8-ab56-bf79-c9ce-66637b9e3822.png)

#### レコードのアクセス権
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125488/58cc46f8-b40d-e82a-16bb-dd113b4ece94.png)


# JavaScriptコード編
以下のカスタマイズを実装することで、次のような動作のアプリが出来上がります。

1. 保存済みのfreee認証がない場合、レコードの新規作成に移動する
2. 新規作成画面のリンクからOAuth画面を開く
3. 保存済みの認証情報がある場合は事業所の内容が表示される

この先、本来必要な取引先や仕訳の連携については、freeeのAPIを参考に実装してください。認証が通っていればあとは同じパターンで実装できます。

（参考）
[freee 会計API リファレンス](https://developer.freee.co.jp/docs/accounting/reference)

（注意）
不正な読み取りを防止するためのstateの実装は[後半](https://qiita.com/m_ando_japan/items/77c96d88a1ab33e980df#state%E3%81%AE%E5%AE%9F%E8%A3%85)に記載しています。

## 保存済みのfreee認証がない場合、レコードの新規作成に移動する
ここは、この記事の本質とはあまり関係ないので説明は省略します。

```sample.js
    kintone.events.on('app.record.index.show', function(event){
        kintoneUtility.rest.getRecords({
            app: kintone.app.getId(),
            query: '作成者 in (LOGINUSER())' 
        }).then(function(response){
            if (response.records.length !== 1) return;
            return 'success';
        }).then(function(auth){
            if (!auth) location.href = location.pathname + 'edit';
        });
        return event;
    });
```

## 新規作成画面のリンクからOAuth画面を開く
まず、新規レコード作成の画面が起動されたタイミングで、freeeの連携設定を開く画面のリンクを埋め込みます。

```sample.js
    kintone.events.on('app.record.create.show', function(event) {
        var header = $(kintone.app.record.getHeaderMenuSpaceElement());
        console.log(header);
        header.append($(
            '<div style="padding: 15px 30px">' +
                '<a href="https://app.secure.freee.co.jp/developers/applications" target="_blank">freee連携アプリ設定を開く</a>' +
            '</div>'
        ));
    });
```
こうなります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125488/9c4fdb44-48d2-226e-7334-b9e4e9ae4d17.png)


## 初回認証を実施するためのコードを追加
「Client ID」と「Client Secret」を使って初回認証を実施するためのJavaScriptコードを記載します。レコードを保存した後、自動的にfreeeにリダイレクトするという動作を実装します。

```sample.js
    kintone.events.on([
        "app.record.create.submit.success",
        "app.record.edit.submit.success"
    ], function(event) {
        location.href = 'https://accounts.secure.freee.co.jp/public_api/authorize?' + 
            'client_id=' + event.record.clientId.value + 
            '&redirect_uri=' + encodeURIComponent('https://' + location.host + '/k/' + kintone.app.getId() + '/') +
            '&response_type=code';
        return event;
    });
```

## 途中経過
ここまでのコードで、下記のことができるようになっています。

* 認証情報が存在しない場合にレコード追加画面を開く
* レコード追加画面にfreeeの連携設定を開くリンクが追加される
* レコードを保存するとfreeeの認証画面に遷移する

## ここまでの内容で、動作確認とfreee側の設定を行う
追加された「freee連携アプリ設定を開く」をクリックすると、freeeのログイン画面の後、アプリ連携設定画面が開きます。複数の事業所に所属しているfreee利用者の場合は、事業所の選択を行ってください。
アプリ連携設定の画面はこんな感じです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125488/a32f6be5-5f86-e48d-8eb1-e85adbe29a19.png)

「新規追加」をクリックし、適当な名前で設定を作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125488/7cceede0-f101-f08d-adae-4698ab73b5bd.png)

## コールバックURLの設定
「コールバックURL」を編集します**（重要なポイントです）**。コールバックURLというのは、freeeのOAuth認証が成功した場合に開く画面です。間違えるとこの後の処理がうまくいきません。

```コールバックURL.初期値
urn:ietf:wg:oauth:2.0:oob
```

```コールバックURL.設定する値
https://（ご自身のサブドメイン).cybozu.com/k/(jsを設置するアプリ番号)/
```

## 接続情報のメモと設定の保存
コールバックURLに設定する値を変更したら「Client ID」と「Client Secret」の値をメモします。
メモしたら、「下書き保存」をクリックして、kintoneの開発に戻ります。

## コールバックURLに対応するJavaScriptを作成
コールバックURLには、freeeで発行された許可コードがQueryパラメータとして付加された状態で画面が呼び出されます。先ほどの設定で、アプリの一覧画面をコールバックURLに設定しているので、アプリの一覧画面のURLの後ろにfreee側が指定したコードがついた状態で画面が開きます。

このコードでは、freeeから呼び出されたアプリの一覧画面（= freeeの認可コードが付加されている)の場合、認可コードを読み取って認証処理を行います。コードは、最初に作った[保存済みのfreee認証がない場合、レコードの新規作成に移動する](https://qiita.com/m_ando_japan/private/77c96d88a1ab33e980df#%E4%BF%9D%E5%AD%98%E6%B8%88%E3%81%BF%E3%81%AEfreee%E8%AA%8D%E8%A8%BC%E3%81%8C%E3%81%AA%E3%81%84%E5%A0%B4%E5%90%88%E3%83%AC%E3%82%B3%E3%83%BC%E3%83%89%E3%81%AE%E6%96%B0%E8%A6%8F%E4%BD%9C%E6%88%90%E3%81%AB%E7%A7%BB%E5%8B%95%E3%81%99%E3%82%8B)の後ろに追記します。

```sample.js
    kintone.events.on('app.record.index.show', function(event) {
        var record; // 取得した情報を保持するための変数

        kintoneUtility.rest.getRecords({
            app: kintone.app.getId(),
            query: '作成者 in (LOGINUSER())' 
        }).then(function(response){
            if (response.records.length !== 1) return;
            record = response.records[0];

            // freeeの認可コード付で開かれた場合のみ処理する
            var queryString = location.search;
            if (queryString.substr(0, 6) === '?code=') {
                var authCode = queryString.substr(6);
                if (!authCode) {
                    alert('freeeの認証情報取得に失敗しました。');
                    return;
                }
                // 読み取った認可コードを使って認証する
                var body = 'grant_type=authorization_code' +
                    '&client_id=' + record.clientId.value +
                    '&client_secret=' + record.clientSecret.value +
                    '&redirect_uri=' + encodeURIComponent('https://' + location.host + '/k/' + kintone.app.getId() + '/') +
                    '&code=' + authCode;
                var header = {
                    'Content-Type': 'application/x-www-form-urlencoded'
                };
            
                return kintone.proxy('https://accounts.secure.freee.co.jp/public_api/token', 'POST', header, body);
            }

        }).then(function(response) {
            if (!response) return;
            
            if (response[1] !== 200 && response[1] !== 201) {
                console.log(response);
                alert('認証の呼び出しが失敗しました。');
                return;
            }

            // freeeのOAuth認証が成功した場合
            var credentials = JSON.parse(response[0]);
            // 有効期限を日付に変換
            var expiresDateTime = new Date(credentials.created_at * 1000 + credentials.expires_in * 1000);
            // 認証レコードを更新
            return kintoneUtility.rest.putRecord({
                app: kintone.app.getId(),
                id: record.$id.value,
                record: {
                    accessToken: { value: credentials.access_token },
                    refreshToken: { value: credentials.refresh_token },
                    expiresDateTime: { value: expiresDateTime.toISOString() },
                }
            });

        }).then(function(response){
            if (!response) return;

            // 認証情報レコードの更新に成功している
            alert('認証に成功しました！');
            return 'success';

        }).then(function(auth){
            if (!auth && confirm('認証情報を再取得しますか？')) {
                location.href = location.pathname + 'edit';
            }
        });
        return event;
    });
```

## 再度途中経過
先ほど、freeeの画面でメモした「Cliend ID」と「Client Secret」を、kintoneの新規レコード作成画面に設定して、保存します。保存に成功すると、これまでに作成したJavaScriptのコードでfreeeの認証処理が動作するようになっているはずです。

認証には、freeeにログインできる利用者IDとパスワードが必要です。

## 認証に成功したら、あと少しです！
どうですか、認証に成功していますか？
認証に成功したら、あとは認証情報を使ってAPIを呼び出すだけです。先ほどのコードに認証情報を使って事業所コードを取得する処理を追加します。

```sample.js
                // 認証情報を使ってAPIを呼び出す
                var header = {
                    'Authorization': 'Bearer ' + record.accessToken.value
                };
                
                return kintone.proxy('https://api.freee.co.jp/api/1/companies', 'GET', header, {}).then(function(response) {
                    if (response[1] !== 200 && response[1] !== 201) {
                        console.log(response);
                        alert('APIの呼び出しが失敗しました。');
                        return;
                    }
                    
                    var result = JSON.parse(response[0]);
                    console.log(result);
                    alert(
                        '取得した事業所名\n' +
                        result.companies[0].name
                    );
                    return 'success';
                });
```

うまく取得できた場合、このようなアラート画面が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125488/cdade61e-2d3b-fc70-581b-f50e822a7af2.png)


## 最後の仕上げ、有効期限の更新
さて、ここまでの処理で基本的な部分は完成です。しかし、これまでの処理で取得した認証情報は有効期限が切れている場合使えなくなります。有効期限が切れたトークンでアクセスすると、APIのレスポンスがエラーとなります。
認証の有効期限を更新するためには、freeeに対して認証情報の更新リクエストを送る必要があります。更新リクエストには、認証時に取得したリフレッシュトークンを使用します。

```sample.js
            // 有効期限判定
            var valid = false;
            if (record.expiresDateTime.value) {
                var expiresDateTime = new Date(record.expiresDateTime.value);
                if (new Date() < expiresDateTime && record.accessToken.value) {
                    valid = true;
                }
            }

            // 有効期限切れの場合
            if (!valid && record.refreshToken.value) {
                var body = 'grant_type=refresh_token' +
                    '&client_id=' + record.clientId.value +
                    '&client_secret=' + record.clientSecret.value +
                    '&redirect_uri=' + encodeURIComponent('https://' + location.host + '/k/' + kintone.app.getId() + '/') +
                    '&refresh_token=' + record.refreshToken.value;
                var header = {
                    'Content-Type': 'application/x-www-form-urlencoded'
                };
            
                return kintone.proxy('https://accounts.secure.freee.co.jp/public_api/token', 'POST', header, body);
            }
```

更新に成功した後の認証情報の処理は、初回の認証と同様ですので省略します。

# stateの実装
ここまでのコードでAPI連携は可能となりますが、これではstateが設定されていないのでセキュリティに問題があります（記事の公開後、Twitterで指摘いただきました！ありがとうございます） 。

（参考）
[OAuthやOpenID Connectで使われるstateパラメーターについて](https://tech-lab.sios.jp/archives/8492)

## stateを一時的に保存するフィールドを追加します。
|フィールド名|フィールドコード|タイプ|必須|保存するデータ|
|---|---|---|---|---|
|state| state |文字列1行||OAuth用のstateを一時保存|
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125488/764daed9-d180-89b3-b235-63b030165c6f.png)

## stateを生成してリクエストに含める
擬似的なUUIDを生成して、認可コード取得の処理に含めます。

```sample.js
    // UUIDを生成する
    function generateUuid() {
        // https://github.com/GoogleChrome/chrome-platform-analytics/blob/master/src/internal/identifier.js
        // const FORMAT: string = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx";
        let chars = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".split("");
        for (let i = 0, len = chars.length; i < len; i++) {
            switch (chars[i]) {
                case "x":
                    chars[i] = Math.floor(Math.random() * 16).toString(16);
                    break;
                case "y":
                    chars[i] = (Math.floor(Math.random() * 4) + 8).toString(16);
                    break;
            }
        }
        return chars.join("");
    }

    kintone.events.on([
        'app.record.create.submit',
        'app.record.edit.submit'
    ], function(event) {
        //UUIDをstateに一時保存する
        event.record.state.value = generateUuid();
        return event;
    });

    kintone.events.on([
        'app.record.create.submit.success',
        'app.record.edit.submit.success'
    ], function(event) {
        location.href = 'https://accounts.secure.freee.co.jp/public_api/authorize?' + 
            'client_id=' + event.record.clientId.value + 
            '&redirect_uri=' + encodeURIComponent('https://' + location.host + '/k/' + kintone.app.getId() + '/') +
            '&response_type=code' +
            '&state=' + event.record.state.value;
        return event;
    });
```

## OAuthのコールバックにもstateの判定を追加する
コールバックURLでfreeeから戻ってきた場合の処理にも、stateを判定する処理を追加します。

```sample.js
                var queries = queryString.substr(1).split('&');
                var params = {};
                queries.forEach(function(query){
                    var kv = query.split('=');
                    params[kv[0]] = kv[1];
                });
                if (!params.code || params.state !== record.state.value) {
                    alert('freeeの認証情報取得に失敗しました。');
                    return;
                }
                // 読み取った認可コードを使って認証する
                var body = 'grant_type=authorization_code' +
                    '&client_id=' + record.clientId.value +
                    '&client_secret=' + record.clientSecret.value +
                    '&redirect_uri=' + encodeURIComponent('https://' + location.host + '/k/' + kintone.app.getId() + '/') +
                    '&code=' + params.code +
                    '&state=' + params.state;
                var header = {
                    'Content-Type': 'application/x-www-form-urlencoded'
                };
            
                return kintone.proxy('https://accounts.secure.freee.co.jp/public_api/token', 'POST', header, body);
```

# サンプルコード
最終的なsampleコードはこちらです。

https://gist.github.com/m-ando-japan/bace13d84e9cc2fbc9b591a26810195a


# まとめ

いかがでしたでしょうか・・・
非常に長文となりましたが、OAuthの仕組みやfreee連携の概要がつかめたのではないでしょうか？見た目は非常にめんどくさいですが、仕組みを理解できればやっていることは単純であるということに気が付かれるかと思います。

このプログラムには、難解なポイントが非常に多いので、もしうまくいかないという人はお気軽にTwitterなどでお尋ねください！

もし、認証の処理を実装するのが面倒くさいという方は、サンプルコードを使ってみてください。

# 実際の運用にあたって
運用で使っていくには、この記事の実装ではいくつかの課題があります。

#### Cliend ID, Client Secret をユーザごとに保持してしまっている
これは、認証関係をプラグイン化することで管理者のみが設定し、隠蔽する対応が必要です。
* [kintoneプラグイン開発入門　【Part2: 情報の隠匿方法編】
](https://developer.cybozu.io/hc/ja/articles/360019834671-kintone%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E9%96%8B%E7%99%BA%E5%85%A5%E9%96%80-Part2-%E6%83%85%E5%A0%B1%E3%81%AE%E9%9A%A0%E5%8C%BF%E6%96%B9%E6%B3%95%E7%B7%A8-)* [kintoneプラグイン開発入門　【Part3: 情報の隠匿方法 実践編】
](https://developer.cybozu.io/hc/ja/articles/360020364531-kintone%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E9%96%8B%E7%99%BA%E5%85%A5%E9%96%80-Part3-%E6%83%85%E5%A0%B1%E3%81%AE%E9%9A%A0%E5%8C%BF%E6%96%B9%E6%B3%95-%E5%AE%9F%E8%B7%B5%E7%B7%A8-)

kintone.plugin.proxy() の利用で、ユーザにSecretを隠蔽した形で処理が実行可能となります。

#### OAuthの認証クライアントとしてkintoneを無理やり使っている
実装を見ていただくとお分かりかと思いますが、クライアントのJavaScriptで無理やりコールバックを実装しています。本来この部分は専用サーバーを立てる方がより安全と考えます。

## ちょっと宣伝
[キントバ](https://kintone.bitriber.jp/)を運営するビットリバー株式会社では、このようなkintoneとfreeeを連携させる開発をサービスとして提供しています。kintoneのJavaScript開発にお困りの場合は是非ご相談ください。