Firebase AuthenticationでNotionログインを実装してみる

# はじめに

Notionでログインするサイトを作ろうと思いました。

できるだけ運用コストを抑えるために、静的サイト＋サーバーレス関数で組みたかったので、Firebaseを選択しました。

自分の作業用に記録していますので、要点だけ記載しています。

リポジトリはこちらにあります。

https://github.com/planethouki/hello-world-firebase-notion


# Firebaseセットアップ

```
$ firebase init
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/c5a68f69-cb37-a5af-6b99-28b3f21a84f1.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/343e1cd8-ceec-b682-af0d-80c73b29212b.png)

Firebase Hosting用のGitHub Actionsのファイルとシークレットが自動的に作成されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/7e9149a2-eab7-841d-284a-4bbcfca79c29.png)


またサービスアカウントも自動的に作成してくれます。どういうロールを付けたらいいのか参考になりますね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/b83766cf-4768-7866-8650-ee95e8034b36.png)

GitHub Actionsのファイルも自動的に生成してくれます。Hostingへのデプロイはこれで設定完了ですね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/971dc65f-6ec6-d51d-7817-6e9bb1bd1698.png)


# Notionでインテグレーションを作成

クライアントIDとクライアントシークレットを取得します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/c21a9488-5236-6903-a63c-02fbe926a258.png)

メールアドレスを取得したいので、こちらのオプションもチェックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/6ead8a5e-05ef-9f14-0761-8db80f67d87a.png)

リダイレクトURIを設定します。このパスに対応するFirebase Functionsを後で作ります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/9930016f-15e6-a47d-c794-3daed9011e8f.png)

# Firebase FunctionsでとりあえずNotionのアクセストークンを取得するOAuthフローを実装

いったん以下の流れを実装します

```plantuml
@startuml
actor User
participant Notion
participant Functions as "Firebase Functions"

User -> Notion: NotionログインURL直接入力
Notion -> User: ログイン画面表示
User -> Notion: ログイン情報入力
Notion -> Functions: リダイレクト(with code)
Functions -> Notion: アクセストークン取得リクエスト
Notion -> Functions: アクセストークンなど返却
Functions -> User: Notionから返却された情報を全部表示
@enduml
```

## Firebase Functionsの環境変数

Functions側の環境変数については、シークレットマネージャーを使用します。

```
$ firebase functions:secrets:set NOTION_CLIENT_ID
$ firebase functions:secrets:set NOTION_CLIENT_SECRET
$ firebase functions:secrets:set NOTION_REDIRECT_URI
```

するとシークレットが設定されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/319b287e-4735-1ed1-a7fd-cc6f74e97af5.png)



参考情報

https://firebase.google.com/docs/functions/config-env?hl=ja&gen=1st#env-variables

## Functionsのコード

Notionログイン後、Notionアクセストークンを取得してから、それを画面に表示するテストコードです。

```typescript:functinos/src/index.ts
import functions = require('firebase-functions');
import express = require('express');
const app = express();
const router = express.Router();

router.get('/oauth/callback', async (req, res) => {

  const NOTION_CLIENT_ID = process.env.NOTION_CLIENT_ID;
  const NOTION_CLIENT_SECRET = process.env.NOTION_CLIENT_SECRET;
  const NOTION_REDIRECT_URI = process.env.NOTION_REDIRECT_URI;

  const code = req.query.code as string;

  try {
    const basic = Buffer.from(`${NOTION_CLIENT_ID}:${NOTION_CLIENT_SECRET}`).toString('base64');
    const tokenResponse = await fetch('https://api.notion.com/v1/oauth/token', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Basic ${basic}`
      },
      body: JSON.stringify({
        code: code,
        grant_type: 'authorization_code',
        redirect_uri: NOTION_REDIRECT_URI,
      })
    }).then((res) => res.json())

    return res
      .send(tokenResponse)
      .header('Content-Type', 'application/json');
  } catch (error) {
    functions.logger.error(error);
    return res
      .send('error')
      .status(500);
  }

});

app.use('/api/notion', router);

exports.api = functions
  .region('asia-northeast1')
  .runWith({ secrets: [
      "NOTION_CLIENT_ID",
      "NOTION_CLIENT_SECRET",
      "NOTION_REDIRECT_URI"
    ] })
  .https
  .onRequest(app);

```

## 動かす

Notionインテグレーションのここにある認証URLをコピーして、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/4ca82649-144e-bb93-2736-a8da500c140e.png)

ブラウザにペーストすると、Notionログイン画面が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/feb673b5-381b-b76b-e2dd-f32b60821ba0.png)

次にページのアクセス許可の設定があります。今はまだテストなので何も選択しません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/ff747462-7e41-1bf0-9ca5-9a55b11ea5c1.png)

うまくいくと、このような画面になります

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/5aa73a4b-e6f0-3b30-ea75-b647f0d51473.png)


# Firebase Authenticationにカスタムトークンでログインする

## カスタムトークン作成者のロールを付与する

サービスアカウントトークン作成者、というロールをFirebase Functionsに付与します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/7f730eee-63ec-b3ce-7f34-36081cf38b6e.png)

https://firebase.google.com/docs/auth/admin/create-custom-tokens?hl=ja#service_account_does_not_have_required_permissions

このロール付与、反映に数分くらいかかるみたいですね。

## Functionsを編集

ちょっと最後の方に、NotionのユーザーIDを指定して、Firebase Authenticationにカスタムトークンを発行してもらって、それを表示するようにします。

```ts:functions/src/index.ts
router.get('/oauth/callback', async (req, res) => {

  const NOTION_CLIENT_ID = process.env.NOTION_CLIENT_ID;
  const NOTION_CLIENT_SECRET = process.env.NOTION_CLIENT_SECRET;
  const NOTION_REDIRECT_URI = process.env.NOTION_REDIRECT_URI;

  const code = req.query.code as string;

  try {
    const basic = Buffer.from(`${NOTION_CLIENT_ID}:${NOTION_CLIENT_SECRET}`).toString('base64');
    const tokenResponse = await fetch('https://api.notion.com/v1/oauth/token', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Basic ${basic}`
      },
      body: JSON.stringify({
        code: code,
        grant_type: 'authorization_code',
        redirect_uri: NOTION_REDIRECT_URI,
      })
    }).then((res) => res.json());

    const notionUserId = tokenResponse.owner.user.id;
    const customToken = await admin.auth().createCustomToken(notionUserId);

    return res
      .send({ customToken })
      .header('Content-Type', 'application/json');
  } catch (error) {
    functions.logger.error(error);
    return res
      .send('error')
      .status(500);
  }

});
```

## 動かす

このように、カスタムトークンが返ってきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/4fccc25d-eb52-1ca0-5c03-276130c0eb02.png)

あとはこれをフロント側でFirebase Authenticationに入力してログインします。

この段階では、まだユーザーが作成されていません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/02cd2f6d-e0bc-09e0-bf0f-16968603f934.png)

# フロントエンドを含めて処理をする

流れはこんな感じです。

```plantuml
@startuml
actor User
participant Frontend as "Hosting"
participant Notion
participant Backend as "Functions"
participant Firebase as "Firebase Authentication"

User -> Frontend: アクセス
Frontend -> User: ページを表示
User -> Notion: Notionログインリンクをクリック
Notion -> User: ログイン画面表示
User -> Notion: ログイン情報入力
Notion -> Frontend: Callback URLにリダイレクト(code付き)
Frontend -> Backend: codeを送信
Backend -> Notion: アクセストークンを取得(codeを使用)
Notion -> Backend: アクセストークン＋ユーザー情報
Backend -> Firebase: ユーザー情報を元にカスタムトークンを要求
Firebase -> Backend: カスタムトークン返却
Backend -> Frontend: カスタムトークンを返却
Frontend -> Firebase: カスタムトークンを使用してログイン
Firebase -> Frontend: ログイン成功
@enduml
```

フロントエンドは何でもいいですが、使い慣れているVueで作りました。

## Notionログインへ行くためのページ

ログインリンクには、`owner=user`を付けるのを忘れないように。これをつけるのとつけないのでは、アクセストークンを取得したときの返り値が異なります。

```vue
<script setup>
const clientId = import.meta.env.VITE_NOTION_CLIENT_ID
const redirectUri = import.meta.env.VITE_NOTION_REDIRECT_URI
const loginUrl = `https://api.notion.com/v1/oauth/authorize?client_id=${clientId}&response_type=code&owner=user&redirect_uri=${encodeURIComponent(redirectUri)}`
</script>

<template>
  <main>
    <section>
      <h1>Login</h1>
      <a :href="loginUrl">
        Login with Notion
      </a>
    </section>
  </main>
</template>
```

画面

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/2afdfcf4-cf89-722b-4d43-9452e60106e1.png)

## コールバックURL用のページ。

Notionからもらった`code`をバックエンドへ送信します。バックエンドからは、Firebase AuthenticationのJWTが返ってくる（ように作る）ので、それを使って`signInWithCustomToken`します。

で、ログインが成功したら、ユーザー情報を`console.log`します。

```vue
<script setup>
import { onMounted } from 'vue'
import { getAuth, signInWithCustomToken } from 'firebase/auth'

onMounted(async () => {
  const urlParams = new URLSearchParams(window.location.search)
  const code = urlParams.get('code')
  if (!code) {
    console.error('No code provided')
    return
  }

  const response =  await fetch(
    `${import.meta.env.VITE_API_BASE_URL}/notion/token`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ code }),
    }
  ).then((res) => res.json())

  const auth = getAuth()
  signInWithCustomToken(auth, response.token)
    .then((userCredential) => {
      const user = userCredential.user
      console.log(user)
    })
    .catch((error) => {
      const errorCode = error.code;
      const errorMessage = error.message;
      console.error(error)
    })
})
</script>

<template>
  <div>
    <h1>Notion Callback</h1>
  </div>
</template>

<style scoped>

</style>
```

## バックエンド

少しだけ変えます。POSTリクエストを受けるようにします。

```diff_typescript:functions/src/index.ts
- router.get('/oauth/callback', async (req, res) => {
+ router.post('/token', async (req, res) => {


-   const code = req.query.code as string;
+   const code = req.body.code as string;

-   return res
-     .send({ customToken })
-     .header('Content-Type', 'application/json');
+   return res
+     .send({ token: customToken })
+     .header('Content-Type', 'application/json');
```

## 動かす

このように、ログイン情報を表示することができました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/569c21d4-c153-fa5e-5e92-870448021dd9.png)

Firebas Authenticationを見ると、ユーザーが作成されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/a75c79ab-1967-aebd-f285-1b6e5cd8157f.png)

# フロントエンドを整える

ログインページ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/da80e490-3e4b-464f-bf1f-f8db95f80914.png)

ログインしたときだけ表示できるようにする。また、そこにはログアウトボタンを用意、ついでにユーザーIDを表示

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172833/d66a1163-3447-ebc4-dba6-2c653ae04384.png)

コンテンツは何もない