OAuthによる認可が必要な外部サービスとの連携Pieceを作る

目次

OAuthによるユーザーの認可が必要な外部サービスと連携するPieceを作成する方法について説明します。


OAuthの対応状況

RFC6749The OAuth 2.0 Authorization Framework)で定義されている全4フローのうち、現在、Riiiverでは認可コードフローにのみ対応しています。また、OAuth1.0はサポートしていません。

各種認可フローへの対応状況

フロー名対応備考
認可コード
インプリシット-
リソースオーナー・パスワード・クレデンシャルズ-
クライアント・クレデンシャルズ-


利用したい外部サービスの認可サーバーのトークンエンドポイントへリクエストする際のクライアント認証については、client_secret_postおよび、client_secret_basicに対応しています。[参考]
その他、Google APIでは、access_tokenをリクエストする際の方式がRFCに完全準拠ではありませんが、サポートしています。


各種クライアント認証への対応状況

リクエスト方式対応post-typeの指定備考
client_secret_postform_urlencoded
client_secret_basicbase64
client_secret_jwt-
private_key_jwt-
tls_client_auth-
self_signed_tls_client_auth-
その他(Google API)json


Googleカレンダーと連携するPieceを作成する場合

OAuthによる認可が必要な外部サービスと連携するPieceを作成するには以下が必要です。ここでは、OAuthによる認可フローが必要な外部サービスとして、Googleカレンダーと連携するPieceを作成する場合を例に、必要となるファイルについて、順に説明していきます。

  1. OAuthJSONファイル(oauth.json
  2. PieceJSONファイル(piece.json
  3. node_modules ( request, request-promise をインストール)
  4. PieceCorejsファイル(PieceCore.js
OAuthJSONファイルについて

まず、oauth.jsonファイルの中身を見てみましょう。

{
  "authType": ["oauth2"],
  "postType": "json",
  "oauth2": {
    "authorizedUrl": "https://accounts.google.com/o/oauth2/auth",
    "tokenUrl": "https://accounts.google.com/o/oauth2/token",
    "redirectUri": "https://builder.developer.riiiver.com/service_oauth/",
    "clientId": "〜.apps.googleusercontent.com",
    "clientSecret": "XXXXXXXXXXXXXXXXXXXXXXX",
    "scope": "https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.readonly"
  }
}

このファイルには、外部サービスの認可サーバーにリクエストをする際に必要となる情報を記述します。

  • "authType"OAuth2.0を利用する場合、"oauth2"を指定してください。ただし、前述のとおり、現在Riiiverで利用できる"authType""oauth2"のみです(参照ページ: PieceJSONKeys)。
  • "postType"で、認可サーバーへのデータ送信の形式を指定します。利用する外部サービスのドキュメントを確認し、こちらの表の中から適切な値を指定してください。今回はGoogle APIを使うので"json"を指定しています。
  • "oauth2"の中に、認可フローに必要な情報を記述します。

oauth2の中で指定するパラメータ

keysdescription
authorizedUrl外部サービスが指定する認可用URL
tokenUrl外部サービスが指定するトークン発行用URL
redirectUri認可サーバーからのレスポンスの受け取り口(変更不要)
clientId外部サービスに発行してもらうID
clientSecret外部サービスに発行してもらうPW
scope権限の付与を許可する範囲
state連携するサービスによっては必須な場合があります。ドキュメントを確認してください。


PieceBuilderにてPieceJSONをアップロードすると、PieceCorejsファイル(正確にはzip形式)、icon.pngなどの他に「OAuthファイルの追加」という項目がありますので、そちらからアップロードしてください。Pieceのアップロード方法は「つくったPieceをアップロードする」をご覧ください。



PieceJSONファイルについて

次に、piece.jsonファイルについて説明します。
"ServiceProxy"に以下のように、"authType"の項目を追加し、"oauth2"を指定します。OAuth連携のために必要な記述はこれだけです。前項で説明したOAuthJSONファイルとの紐付けは、サーバ側で処理していますので、piece.jsonのどこかでoauth.jsonのファイル名を指定する必要はありません。

"serviceProxy": {
  "service": "googleCalendarWatch",
  "authType": [
    "oauth2"
  ]
}

piece.jsonファイル全体のソースコードは下記のようになります。

{
  "title": {
    "ja": "Google カレンダー",
    "en": "Google calendar"
  },
  "version": "0.0.1",
  "sdkVersion": "0.0.1",
       ・・・中略・・・,
  "blockType": "service",
  "executor": "RBCCommonWebServiceExecutor",
  "serviceProxy": {
    "service": "googleCalendarWatch",
    "authType": [
      "oauth2"
    ]
  },
  "output" : {
    "type" : "object",
    "properties": {
       ・・・中略・・・
      }
    }
  },
  "categoryIds": [
    "cat_0007"
  ]
}

node_modulesの準備
request-promiseのインストール

request-promiseは、HTTP 通信による情報取得を簡単に行えるパッケージ です。 request パッケージに依存しているため、request パッケージも一緒にインストールしておく必要があります。
既に、APIを使ったS Pieceを作ったことのある方は、requestパッケージはインストール済みのはずなので2行目のコマンドのみ実行してください。

npm install request
npm install request-promise
request-promiseの使い方

Googlehtmlを取得する場合は以下のような構成にします。 .then に成功したときの処理、.catchに失敗したときの処理を記述します。

var rp = require('request-promise');
rp('http://www.google.com')
    .then(function (htmlString) {
        // Process html...
    })
    .catch(function (err) {
        // Crawling failed...
    });

request-promiseの基本的な使い方はこちらを参考にしてください。



PieceCore.jsについて

最後に、PieceCore.jsについて説明します。Googleカレンダーからイベントを最大3つまで取得して、開始時間順にソートするサンプルコードを以下にご紹介します。

const requestPromise = require('request-promise');

exports.handler = async (event) => {
  if (event.call === 'lambda') {
    console.log('CALLED:LAMBDA');

    /* 外部モジュールを使う場合に記入してくだい。
       ex : if ( xxxx !== null ){} // xxxx : 生成したインスタンス */    if (requestPromise !== null) { }
    return;
  }
  console.log(`googleCalendarWatch request data: ${ JSON.stringify(event) }`);

  let date = event.userData.date;
  let responseData;
  

  return requestPromise({
    'method': 'GET',
    'uri': 'https://app.riiiver.com/api/proxyBlockId',
    'headers': {
      'User-Agent': event.userData.userAgent,
      'Authorization': event.userData.credential,
    },
    'qs': {
      'serviceProxy': event.serviceProxy,
    },
    'resolveWithFullResponse': true,
  }).then(data => {
    if (data.statusCode !== 200) {
      responseData = { status: data.statusCode, body: { message: data.body } };
      return responseData;
    }
    console.log(`GET proxyBlockId responseData: ${data.body}`);
    const blockId = JSON.parse(data.body).blockId;
    return requestPromise({
      'method': 'GET',
      'uri': 'https://app.riiiver.com/api/clientAuthInfo',
      'headers': {
        'User-Agent': event.userData.userAgent,
        'Authorization': event.userData.credential,
      },
      'qs': {
        'blockId': blockId,
        'uuid': event.userData.uuid
      },
      'resolveWithFullResponse': true,
    }).then(data => {
      if (data.statusCode !== 200) {
        console.log(`clientAuthInfo`);
        responseData = { status: data.statusCode, body: { message: data.body } };
        return responseData;
      }
      const body = JSON.parse(data.body);
      const expires_in = body.oauth2.expires_in;
      const now = Math.floor(new Date().getTime() / 1000);
      if (expires_in <= (now - (60 * 5))) {
        return requestPromise({
          'method': 'PUT',
          'uri': 'https://app.riiiver.com/api/updateOAuthToken',
          'headers': {
            'User-Agent': event.userData.userAgent,
            'Authorization': event.userData.credential,
            'Content-Type': 'application/json',
          },
          'qs': {
            'blockId': blockId,
            'uuid': event.userData.uuid
          },
          'body': JSON.stringify({
            'refresh_token': body.oauth2.refresh_token
          }),
          'resolveWithFullResponse': true,
        }).then(data => {
          if (data.statusCode !== 201) {
            responseData = { status: data.statusCode, body: { message: data.body } };
            return responseData;
          }
          const access_token = JSON.parse(data.body).access_token;
          return getEvents(access_token, event, date).then(response => {
            responseData = response;
            return responseData;
          });
        });
      } else {
        const access_token = body.oauth2.access_token;
        return getEvents(access_token, event, date).then(response => {
          responseData = response;
          return responseData;
        });
      }
    })
    .catch(error => {
      responseData = { status: error.statusCode, body: { message: error.message } };
      return responseData;
    });
  }).catch(error => {
    responseData = { status: error.statusCode, body: { message: error.message } };
    return responseData;
  }).finally(() => {
    console.log(`getGoogleCalendarEvents response data: ${ JSON.stringify(responseData) }`);
  });
};

const getEvents = async (access_token, event, today) => {
  // dateのフォーマット "yyyy-MM-dd'T'HH:mm:ssZZZ"
  return new Promise(resolve => {
    return requestPromise({
      'method': 'GET',
      'uri': 'https://www.googleapis.com/calendar/v3/calendars/primary/events',
      'headers': {
        'User-Agent': event.userData.userAgent,
        'Authorization': `Bearer ${access_token}`,
      },
      'qs': {
//        'maxResults': 3, // 最大3件
        'timeMax': getDateInfo(today) + "T23:59:59" + getTimeZoneInfo(today), 
        'timeMin': today
      },
      'resolveWithFullResponse': true,
    }).then(data => {
      console.log(`API response data: ${ data.body }`);
      const items = JSON.parse(data.body).items;
      const date = new Date(today).getTime();
      let plans = [];
      let cnt = 0;
      items.forEach((v) => {
        if (v.status === 'confirmed') {
          const startDate = new Date(v.start.dateTime);
          const startTime = getTimeInfo(v.start.dateTime);
          let endTime = getTimeInfo(v.end.dateTime);
          if (cnt < 3 && date < startDate.getTime()) {
            cnt++;
            console.log(`Events: ${JSON.stringify(v)}`);
            //スケジュールの終わりが日をまたぐ場合は24:00とする
            let startDay = getDayInfo(v.start.dateTime);
            let endDay = getDayInfo(v.end.dateTime);
            if(startDay != endDay){
              endTime = "24:00";
            }
            plans.push({"startDateTime": startTime, "endDateTime": endTime});
          }
        }
      });
      // スケジュールを開始時間でソートする
      plans.sort(function(a, b) {
        // 比較するためDate型を生成する。(時間を比較したいだけなので年月日は適当で良い)
        let start1 = new Date("2019/01/01 " + a.startDateTime);
        let start2 = new Date("2019/01/01 " + b.startDateTime);
        if (start1.getTime() > start2.getTime()) {
          return 1;
        } else {
          return -1;
        }
      });
      return resolve({ status: 200, body: { "plans": plans } });
    }).catch(error => {
      return resolve({ status: error.statusCode, body: { message: error.message } });
    });
  });
};

// date: 日付文字列(yyyy-mm-ddTHH:mm:ssZZZ)
function getTimeInfo (date) {
  var index = date.indexOf('T') + 1;
  // HH:mmを抽出して返却
  return date.substring( index, index + 5 );
}

// date: 日付文字列(yyyy-mm-ddTHH:mm:ssZZZ)
function getDateInfo (date) {
  var index = date.indexOf('T');
  // yyyy-mm-ddを抽出して返却
  return date.substring( 0, index );
}

// date: 日付文字列(yyyy-mm-ddTHH:mm:ssZZZ)
function getDayInfo (date) {
  var index = date.indexOf('T');
  // yyyy-mm-ddを抽出して返却
  return date.substring( index -2, index );
}

// date: 日付文字列(yyyy-mm-ddTHH:mm:ssZZZ)
function getTimeZoneInfo (date) {
  // ZZZを抽出して返却
  return date.substring( date.length - 5, date.length );
}
サンプルコードの説明

このサンプルコードでは、OAuth認可によって取得したaccess_tokenを用いて、APIを叩き、そのレスポンスから次のPieceへ送るoutputを生成しています。
以下、サンプルコードの構成を分けて、順番に説明します。

1. ブロックIDPiece ID)を取得します。`https://app.riiiver.com/api/proxyBlockId`

return requestPromise({
  'method': 'GET',
  'uri': 'https://app.riiiver.com/api/proxyBlockId',
  'headers': {
    'User-Agent': event.userData.userAgent,
    'Authorization': event.userData.credential,
  },
  'qs': {
    'serviceProxy': event.serviceProxy,
  },
  'resolveWithFullResponse': true,
  }).then(data => {
    if (data.statusCode !== 200) {
      responseData = { status: data.statusCode, body: { message: data.body } };
      return responseData;
    }
    console.log(`GET proxyBlockId responseData: ${data.body}`);
    const blockId = JSON.parse(data.body).blockId;
    …
  /**
   * 次項以降で説明する処理が続きます
   */  }).catch(error => {
  /**
   * ブロックID(Piece ID)を正常に取得できなかった場合の処理
   */    responseData = { status: error.statusCode, body: { message: error.message } };
    return responseData;
  }).finally(() => {
    console.log(`getGoogleCalendarEvents response data: ${ JSON.stringify(responseData) }`);
  });
};

2. Riiiverのサーバーに保存されているユーザーのAuth情報を取得します。`https://app.riiiver.com/api/clientAuthInfo`

return requestPromise({
  'method': 'GET',
  'uri': 'https://app.riiiver.com/api/clientAuthInfo',
  'headers': {
    'User-Agent': event.userData.userAgent,
    'Authorization': event.userData.credential,
  },
  'qs': {
    'blockId': blockId,
    'uuid': event.userData.uuid
  },
  'resolveWithFullResponse': true,
}).then(data => {
  if (data.statusCode !== 200) {
    console.log(`clientAuthInfo`);
    responseData = { status: data.statusCode, body: { message: data.body } };
    return responseData;
  }
  …
  /**
   * 次項以降で説明する処理が続きます
   */}).catch(error => {
  /**
   * サーバーにAuth情報が保存されていないなど、正常に取得できなかった場合の処理
   */  responseData = { status: error.statusCode, body: { message: error.message } };
  return responseData;
});
…

3. `access_token`を用いて実際にAPIを叩いていきます。`access_token`の有効期限が切れているかどうかを判断し、切れている場合は、`refresh_token`を用いて`access_token`を取得しなおすよう分岐を入れています。`https://app.riiiver.com/api/updateOAuthToken`

{
…
const body = JSON.parse(data.body);
const expires_in = body.oauth2.expires_in;
const now = Math.floor(new Date().getTime() / 1000);

/**
 * access_tokenの有効期限が切れていないか判断する
 * expires_in(tokenの有効期限)が今と比較して小さい
 * すなわち、期限が切れているなら、access_tokenを再取得する
 */if (expires_in <= (now - (60 * 5))) {
  return requestPromise({
    'method': 'PUT',
    'uri': 'https://app.riiiver.com/api/updateOAuthToken',
    'headers': {
      'User-Agent': event.userData.userAgent,
      'Authorization': event.userData.credential,
      'Content-Type': 'application/json',
    },
    'qs': {
      'blockId': blockId,
      'uuid': event.userData.uuid
    },
    'body': JSON.stringify({ 'refresh_token': body.oauth2.refresh_token }),
    'resolveWithFullResponse': true,
  }).then(data => {
    if (data.statusCode !== 201) {
      responseData = { status: data.statusCode, body: { message: data.body } };
      return responseData;
    }
    const access_token = JSON.parse(data.body).access_token;
    /**
     * (★1)再取得したaccess_tokenで処理を行う
     * 実際に自分でPieceを作る際はここと(★2)の処理を変更する
     */    return getEvents(access_token, event, date).then(response => {  
      responseData = response;
      return responseData;
    });
  });
} else {
  const access_token = body.oauth2.access_token;
  /**
   *  (★2)access_tokenの有効期限が切れていなかった場合の処理
   */  return getEvents(access_token, event, date).then(response => {  
    responseData = response;
    return responseData;
  });
}
…

4. Googleカレンダーからイベントを最大3つまで取得したのち、開始時間順にソートしてoutputを生成します。

/**
 * (★1, 2)で呼んでいる処理
 */const getEvents = async (access_token, event, today) => {
  // dateのフォーマット "yyyy-MM-dd'T'HH:mm:ssZZZ"
  return new Promise(resolve => {
    return requestPromise({
      'method': 'GET',
      'uri': 'https://www.googleapis.com/calendar/v3/calendars/primary/events',
      'headers': {
        'User-Agent': event.userData.userAgent,
        'Authorization': `Bearer ${access_token}`,
      },
      'qs': {
        //'maxResults': 3, // 最大3件
      'timeMax': getDateInfo(today) + "T23:59:59" + getTimeZoneInfo(today),
      'timeMin': today
      },
      'resolveWithFullResponse': true,
    }).then(data => {
      console.log(`API response data: ${data.body}`);
      const items = JSON.parse(data.body).items;
      const date = new Date(today).getTime();
      let plans = [];
      let cnt = 0;
      items.forEach((v) => {
        if (v.status === 'confirmed') {
          const startDate = new Date(v.start.dateTime);
          const startTime = getTimeInfo(v.start.dateTime);
          let endTime = getTimeInfo(v.end.dateTime);
          if (cnt < 3 && date < startDate.getTime()) {
            cnt++;
            console.log(`Events: ${JSON.stringify(v)}`);
            //スケジュールの終わりが日をまたぐ場合は24:00とする
            let startDay = getDayInfo(v.start.dateTime);
            let endDay = getDayInfo(v.end.dateTime);
            if (startDay != endDay) {
              endTime = "24:00";
            }
            plans.push({ "startDateTime": startTime, "endDateTime": endTime });
          }
        }
      });
      // スケジュールを開始時間でソートする
      plans.sort(function (a, b) {
        // 比較するためDate型を生成する。(時間を比較したいだけなので年月日は適当で良い)
        let start1 = new Date("2019/01/01 " + a.startDateTime);
        let start2 = new Date("2019/01/01 " + b.startDateTime);
        if (start1.getTime() > start2.getTime()) {
          return 1;
        } else {
          return -1;
        }
      });
      return resolve({ status: 200, body: { "plans": plans } });
    }).catch(error => {
      return resolve({ status: error.statusCode, body: { message: error.message } });
    });
  });
};

// date: 日付文字列(yyyy-mm-ddTHH:mm:ssZZZ)
function getTimeInfo(date) {
  var index = date.indexOf('T') + 1;
  // HH:mmを抽出して返却
  return date.substring(index, index + 5);
}

// date: 日付文字列(yyyy-mm-ddTHH:mm:ssZZZ)
function getDateInfo(date) {
  var index = date.indexOf('T');
  // yyyy-mm-ddを抽出して返却
  return date.substring(0, index);
}

// date: 日付文字列(yyyy-mm-ddTHH:mm:ssZZZ)
function getDayInfo(date) {
  var index = date.indexOf('T');
  // yyyy-mm-ddを抽出して返却
  return date.substring(index - 2, index);
}

// date: 日付文字列(yyyy-mm-ddTHH:mm:ssZZZ)
function getTimeZoneInfo(date) {
  // ZZZを抽出して返却
  return date.substring(date.length - 5, date.length);
}

付録:Googleスプレッドシートへ書き込むPiece

ここまで、Googleカレンダーからデータを取得するPieceを例に説明しました。応用例として、Googleスプレッドシートにデータを書き込むPieceについて見ていきましょう。サンプルコードの説明13で説明した項目はほぼそのまま流用できます。access_tokenを用いて実際にAPIを叩く処理(3(1, 2)4)を書き換えれば作成できます。
今回は、preferenceでスプレッドシートのID(sheetId)と入力内容(input)をユーザーに設定してもらうことを想定した場合のコードを下記に示します。

const appendCellsRequest = async (access_token, event, sheetId, input) => {
  return new Promise( resolve => 
    requestPromise({
      'method': 'POST',
      'uri': `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}:batchUpdate`,
      'headers': {
        'User-Agent': event.userData.userAgent,
        'Authorization': `Bearer ${access_token}`,
        'Content-Type': 'application/json'
      },
      'body': {
        "requests": [{
          "appendCells": {
          "sheetId": 0,
          "rows":[{
            "values":[
              {"userEnteredValue": {"numberValue": input}}
            ]
          }],
          "fields": "userEnteredValue"
          }
        }]
      },
      'resolveWithFullResponse': true,
      'json': true
    }).then(response => { 
      console.log(response); 
      return resolve({ status: 200, body: {} });
    }).catch(error => {
      return resolve({ status: error.statusCode, body: { message: error.message } });
    });
  );
};

また、スプレッドシートへの書き込み権限を許可するため、oauth.json内のscopeを下記のように変更する必要があります。加えて、事前にSheets APIを有効化しておく必要があります。

"scope": "https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/spreadsheets",