🐫

Google OAuth2.0を使ってGoogle Tasks APIを呼び出すReact SPAを作ってみた

2021/10/18に公開

Reactで作成したSPAクライアントからGoogleのOAuth2.0を利用してアクセストークンを取得し、GoogleのTasks APIを呼び出してみました。

Google Tasks API ってなに?

Google Tasks APIは、Googleから提供されているタスク管理ツール「Google Tasks(グーグルタスク)」を呼び出せるAPIです。

「Google Tasks(グーグルタスク)」は簡単に言えばTODOリストの機能を提供してくれます。

「Google Tasks(グーグルタスク)」自体はユーザーに作成されたタスク情報を管理しており、アクセストークンさえあればモバイルアプリウェブアプリなど様々なクライアントからタスク情報を呼び出すことが可能です。

どうやってアクセストークンを取得するのか?

Googleから提供されているOAuth2.0フローに沿ってGoogleのユーザー認証を行えばアクセストークンが取得できます。

フローは、クライアントの種別によって複数提供されています。

クライアントの種別に応じた適切なフローを利用しないと、セキュリティリスクがあるので注意が必要です。

今回は、React SPAのパブリッククライアントということで、client_idのみで利用できるOAuth 2.0 for Client-side Web Applicationsに沿って実施していきます。

フローはOAuth2.0のImplicit Grant Flowになります。

やってみた

Google Cloudの設定

まずGoogle Cloud側で2点ほどやることがあります。

1.クライアントアプリの登録

認証情報ページに遷移します

  • 「認証情報作成」 -> 「OAuth クライアント ID」 をクリックします

  • Webアプリケーションのアプリケーションタイプを選択し必要な項目を入力します

ClientIdは後で必要になるのでメモしておきます。

2.Google Tasks APIの有効化

あとでこのAPIを使うので有効化しておきます。

コンソールの検索Boxで「tasks api」と入力すれば候補に表示されるはずです。

React SPAのセットアップ

  • プロジェクトの作成

npx create-react-app my-google-tasks-spa --template typescript

  • ライブラリのインストール

npm i react-google-login @mui/material @emotion/react @emotion/styled axios

あとから使うやつをまとめてインストールしておきます。

Googleでのユーザー認証の実装

公式ドキュメントによると、

  1. JS Client Library
  2. OAuth 2.0 Endpoints

の2つのパターンが用意されています。

1はGoogleが提供している公式のJSライブラリGoogle API Client Library for JavaScriptを使う方法、2はImplict Flowで必要となるサーバーとのやり取りを自前で実装する方法です。

1の方法でやろうと思ったのですが、このライブラリがCDNで提供されており、Reactで利用するには少し手間だったので、今回は3rd PartyライブラリReact Google Loginを使うことにします。

  • .envファイル
REACT_APP_CLIENT_ID=<your-client-id>
  • サンプルコード APP.tsx
APP.tsx
import React, { useState } from 'react';
import './App.css';
import { GoogleLogin } from 'react-google-login';

const ClientId = process.env.REACT_APP_CLIENT_ID!;

function App() {
  const [accessToken, setAccessToken] = useState<string>('');

  const onSuccess = (
    res: ReactGoogleLogin.GoogleLoginResponse | ReactGoogleLogin.GoogleLoginResponseOffline
  ) => {
    if ('accessToken' in res) {
      console.log(res.accessToken);
      setAccessToken(res.accessToken);
    }
  };
  const onFailure = (res: any) => {
    alert(JSON.stringify(res));
  };

  return (
    <div className="App">
      {accessToken === '' ? (
        <GoogleLogin
          clientId={ClientId}
          buttonText="Login"
          onSuccess={onSuccess}
          onFailure={onFailure}
          scope="https://www.googleapis.com/auth/tasks"
          cookiePolicy={'single_host_origin'}
        />
      ) : (
        <>{accessToken}</>
      )}
    </div>
  )
}

export default App;
  • 確認

npm start で確認してみると分かると思いますが、シンプルなログインボタンが追加されます。

ログインボタンをクリックするとOAuth2.0のフローが開始されます。

アクセス許可を求められると思いますが、承認します。

すべて完了すれば画面にはアクセストークンが表示されるはずです。

Google Tasks APIの呼び出し

無事アクセストークンが取得できたので、このトークンを使ってGoogle Tasks APIを呼び出してみたいと思います。

API Referenceを見るといろいろ昨日はありそうですが、今回はシンプルにタスクのCreateDeleteのAPIを呼び出す実装をします。

また、Google Tasksではタスクリスト(TaskList)という単位で複数のタスクを1つにまとめているのですが、今回はデフォルトで作成されている最初のタスクリストのみを利用します。

  • サンプルコード APP.tsx

※あまりTypeScriptに慣れていないので、あくまでサンプル程度に考えて頂けたらと思います。

APP.tsx
-import React, { useState } from 'react';
+import React, { useEffect, useState } from 'react';
 import './App.css';
+import Button from '@mui/material/Button';
+import Stack from '@mui/material/Stack';
+import List from '@mui/material/List';
+import ListItem from '@mui/material/ListItem';
+import axios from 'axios';
 import { GoogleLogin } from 'react-google-login';
 
+const BaseUrl = 'https://tasks.googleapis.com';
 const ClientId = process.env.REACT_APP_CLIENT_ID!;
 
-function App() {
+type TaskList = {
+  id: string;
+  title: string;
+  tasks: Task[];
+};
+
+type Task = {
+  id: string;
+  name: string;
+};
+
+const App: React.FC = () => {
   const [accessToken, setAccessToken] = useState<string>('');
+  const [taskList, setTaskList] = useState<TaskList>();
+  useEffect(() => {
+    const setFirstTaskList = async (accessToken: string) => {
+      const firstItem = await loadFirstTaskList(accessToken);
+      const tasks = await loadTasks(firstItem.id);
+      setTaskList({ id: firstItem.id, title: firstItem.title, tasks } as TaskList);
+    };
+    if (accessToken) {
+      setFirstTaskList(accessToken);
+    }
+  },[accessToken]);
 
   const onSuccess = (
     res: ReactGoogleLogin.GoogleLoginResponse | ReactGoogleLogin.GoogleLoginResponseOffline
   ) => {
     if ('accessToken' in res) {
       console.log(res.accessToken);
       setAccessToken(res.accessToken);
     }
   };
   const onFailure = (res: any) => {
     alert(JSON.stringify(res));
   };
 
+  const loadFirstTaskList = async (accessToken: string) => {
+    const res = await axios.get<any>(`${BaseUrl}/tasks/v1/users/@me/lists`, {
+      headers: { Authorization: `Bearer ${accessToken}` },
+    });
+    return res.data.items[0];
+  };
+  const loadTasks = async (id: string) => {
+    const res = await axios.get<any>(`${BaseUrl}/tasks/v1/lists/${id}/tasks`, {
+      headers: { Authorization: `Bearer ${accessToken}` },
+    });
+    if (!res.data.items) {
+      return [];
+    }
+    return await res.data.items.map((t: any) => {
+      return { id: t.id, name: t.title } as Task;
+    });
+  };
+
+  const reload = async () => {
+    if (!taskList) {
+      return;
+    }
+    const tasks = await loadTasks(taskList.id);
+    setTaskList({ id: taskList.id, title: taskList.title, tasks } as TaskList);
+  };
+  const create = async () => {
+    if (!taskList) {
+      return;
+    }
+    await axios.post(
+      `${BaseUrl}/tasks/v1/lists/${taskList.id}/tasks`,
+      { title: `task${taskList.tasks.length + 1}` },
+      { headers: { Authorization: `Bearer ${accessToken}` } }
+    );
+    reload();
+  };
+  const remove = async () => {
+    if (!taskList) {
+      return;
+    }
+    await axios.delete(`${BaseUrl}/tasks/v1/lists/${taskList.id}/tasks/${taskList.tasks[0].id}`, {
+      headers: { Authorization: `Bearer ${accessToken}` },
+    });
+    reload();
+  };
+
   return (
     <div className="App">
       {accessToken === '' ? (
         <GoogleLogin
           clientId={ClientId}
           buttonText="Login"
           onSuccess={onSuccess}
           onFailure={onFailure}
           scope="https://www.googleapis.com/auth/tasks"
           cookiePolicy={'single_host_origin'}
         />
       ) : (
-        <>{accessToken}</>
+        <>
+          <h2>{taskList?.title}</h2>
+          <Stack direction="row" spacing={2} alignItems="center" justifyContent="center">
+            <Button variant="contained" onClick={create}>
+              create
+            </Button>
+            <Button variant="contained" onClick={remove}>
+              delete
+            </Button>
+          </Stack>
+          <Stack spacing={2} alignItems="center" justifyContent="center">
+            <List>
+              {taskList?.tasks?.map((e) => (
+                <ListItem key={e.id}>{e.name}</ListItem>
+              ))}
+            </List>
+          </Stack>
+        </>
       )}
     </div>
-  )
-}
+  );
+};
 
 export default App;
  • 確認①

先ほどと同様にnpm startで動作確認しています。

  • 確認②

TasksBoardというウェブアプリケーションに同じGoogleアカウントでログインすると、同じタスクが登録されているのが確認できるはずです。

まとめ

今回はパブリッククライアントから利用できるOAuth2.0に対応しているサービスを探している中で、Googleを試してみました。

GitHubも試してみましたが、こちらにある通り認証フローの中でclient_secretを送信する必要があり断念しました。

Twitterは現在ベータ版でOAuth2.0をリリースしており、ベータプログラムに参加すれば試せるそうです。
こちらの記事を参考にする限りclient_secretの送信は必要なさそうです。リリースされたら試して見ようと思います。

あまり情報がないため、意外とはまりどころの多かったです。

どなたかの役に立てば幸いです。

あとがき

  • Google認証の「OAuth 2.0 クライアント ID」は一度あるポートで使うと、その後で違うポートで利用することはできないらしいので、変更したい場合は新しくクライアントを登録する必要があるとのこと
  • tasks.updateのAPIがうまく動かなかった
    • titleを変更して、200 OKまで返ってくるがtasks.listを実行しても変化がない
    • 一方で、tasks.insertを実行して新しいタスクを作成したら、過去の変更が反映された
GitHubで編集を提案

Discussion