[GCP] Identity Platform で有効期限付きパスワードの認証機能を実装してみるよ

## やりたいこと
GCPで次のことをできるシステムを作りたいな
* メールとパスワードでログインする
* **パスワードは特定の日数で有効期限切れにする（今回の主題はこれなので、解説はここ以外省いています）**
* GoogleやTwitterなどサードパーティの認証プロバイダは用いない

GCPには認証に関するサービス「Identity Platform」があるから、これを使って上記を実装してみるよ。

## Identity Platform とは

![スクリーンショット 2022-05-25 7.37.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267156/cb639320-4f1d-44ee-cb06-06e95fb954ae.png)
※画像引用元： [Google Cloud](https://cloud.google.com/identity-platform?hl=ja)

ざっくり説明するなら 「Firebase Authentication」 の上位サービスだよ。
* [公式のサービス紹介](https://cloud.google.com/identity-platform?hl=ja)
* [ドキュメント](https://cloud.google.com/identity-platform/docs/concepts-authentication?hl=ja)
* [入門ガイド](https://cloud.google.com/identity-platform/docs/how-to?hl=ja)

Firebase Authentication に無くて、Identity Platform に存在する機能の一つに「ブロッキング関数」があるよ。認証フローを食い止めて同期的に処理を実行できる機能で、この後の説明でブロッキング関数についても紹介するね。

## Identity Platform のイベント駆動関数
Identity Platform では、認証フローに呼応して発火する関数を定義できるよ。

これには、大きく分けてふたつある。

* 非同期関数
* ブロッキング関数（同期関数）

さらに、これらの中に２つずつ定義可能なイベントハンドラがある。

* 非同期関数（Firebase Authenticationでも存在する機能）
    * onCreate
    * onDelete
* ブロッキング関数（同期関数とも。Firebase Authenticationには存在しない機能）
    * beforeCreate
    * beforeSignIn

名前でなんとなく分かるかもしれないけど、軽くサンプルコードを添えた説明をするね。

### Identity Platform の 「非同期関数」 の紹介

Identity Platformが認証フローに呼応して発火できる関数のうち、認証フローを妨げずに非同期的に実行する「非同期関数」には、次のふたつがあるよ。

#### 1. onCreate（ユーザ作成イベントで発火）
```javascript
exports.myFunction = functions.auth.user().onCreate((user) => {
  // TODO.
});
```
#### 2. onDelete（ユーザ削除イベントで発火）
```javascript
exports.myFunction = functions.auth.user().onDelete((user) => {
  // TODO.
});
```

### Identity Platform の 「ブロッキング関数（同期関数）」 の紹介

一方で、上記とは異なり「認証フローを食い止めて処理」できるブロッキング関数（同期関数）というものもあるよ。

これには次のふたつの関数があって、それぞれ Identity Platform の設定メニュー「トリガー」から登録できるよ。

#### 3. beforeCreate（ユーザ作成の直前で発火）
```javascript
gcipCloudFunctions = require('gcip-cloud-functions');

const authClient = new gcipCloudFunctions.Auth();

exports.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  // TODO
});
```

#### 4. beforeSignIn（ユーザログインの直前で発火）
```javascript
gcipCloudFunctions = require('gcip-cloud-functions');

const authClient = new gcipCloudFunctions.Auth();

exports.beforeSignIn = authClient.functions().beforeSignInHandler((user, context) => {
  // TODO
});
```

今回作りたいのは下記の機能だから、必要なのはブロッキング関数だね。
* ログイン時にパスワードの有効期限を検証して、期限切れならアクセスを拒む

Identity Platform の紹介はここまで。さっそく上記の機能を作っていくよ。

## 実装を始めるに当たって、まず Identity Platform の事前準備をする

（GCPのプロジェクトは作成済みの前提で話を進めるよ）

まず Google Cloud のコンソールから、Identity Platform を有効化する。

![スクリーンショット 2022-05-24 7.45.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267156/10778ae5-751d-0dca-9c04-0fac946ebe9c.png)

次に、Identity Platform のコンソールから 「メール / パスワード」 のプロバイダを作成する。
> ※今回作りたいのは、サードパティのプロバイダを使わない認証機能だからこうしているけど、もしサードパティーのプロバイダを使って認証機能を実装するなら、ここでは別のプロバイダを選んでね

![スクリーンショット 2022-05-24 7.52.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267156/6c3b1062-37e7-0c75-7075-8eb9de70cbca.png)

これで Identity Platform をメールとパスワードで利用する準備が整ったね。

後でブロッキング関数を定義するから、そのために Cloud Functions に関係するAPIの有効化もまだの人は求められるかもしれないけど、もし求められたらその都度（あるいは先に）有効化しようね。

さっそく実装に入ろっか。

## 実装の構成
こんな感じで作っていくよ。（ただし前述の通り、今回の記事で解説するのは下記のうち、「パスワード更新日時の検証」だけだよ）

##### サーバサイド
**「パスワードがいつ更新されたか」を示す値は、Identity Platform には存在しない**から、代わりにカスタムクレームを用いて、パスワード更新日時を自ら管理していくよ。

> ちなみに「カスタムクレーム」は、Identity Platform と Firebase Authentication の双方で使える「ユーザに紐づくカスタム属性」のことだよ。Admin SDK でないと設定できない代わりに、これを使えばロールの付与など複雑な認証要件に対応できるよ
[※ Identity Platform ドキュメント内での解説](https://cloud.google.com/identity-platform/docs/how-to-configure-custom-claims?hl=ja)
[※ Firebase Authentication ドキュメント内での解説](https://firebase.google.com/docs/auth/admin/custom-claims?hl=ja)

そのために、次の２つの機能が必要だね。

1. カスタムクレームを更新する Cloud Functions（パスワード更新時にフロントエンドからコールする前提）
2. ユーザログイン時に、「カスタムクレームに保存されたパスワード更新日時」で有効期限の検証を行う Identity Platform ブロッキング関数（beforeSignInトリガー）

##### フロントエンド 
セキュリティ的に、パスワードを自分で Cloud Functions とやりとりしたくはないから、パスワードの更新は Clien SDK に任せて、「パスワードを更新したこと」だけを伝えるために、上述の「１」の Cloud Functions をコールしようかな。

他には、ログイン機能があればいいかな。

## さっそく実装に入ろう
> 注釈：
> ただ、今回の記事の主題はあくまで 「Identity Platform のブロッキング関数の紹介」だから、ブロッキング関数以外の実装の紹介は省くよ。

もっと知りたいって人がもしいたら、コメントとかでリクエストしてね。

### というわけで Identity Platform のブロッキング関数で、 【カスタムクレームに保存されたパスワード更新日時を用いた有効期限の検証】 を実装する

ブロッキング関数として後で登録するCloud Functionsを実装するよ。

まず必要なパッケージを導入しようね。

```package.json
{
  "name": "sample-http",
  "version": "0.0.1",
  "dependencies": {
    "gcip-cloud-functions": "^0.0.1"
  }
}
```

それから、コードの本体を書くよ。

いくつか前提があるから、それを列挙するね
* **前提１：** 
    ユーザには常にカスタムクレームの「passwordUpdatedAt」にパスワード最終更新日時が含まれている

* **前提２：**
    パスワードの有効期限は30日で、それを過ぎるとログインできなくなる

前提はこれだけだね。前提1は、こことは別の Cloud Functionsで実装しておく前提だから、今回は解説しないよ。

そういうわけで、上記の前提をもとにパスワード有効期限を検証するブロッキング関数を実装すると、次のようになるよ。

```javascript
const gcipCloudFunctions = require('gcip-cloud-functions');
const authClient = new gcipCloudFunctions.Auth();

// Identity Platform のブロッキング関数「beforeSignInトリガー」に対応するハンドラを定義
exports.beforeSignIn = authClient.functions().beforeSignInHandler((user, context) => {

  // カスタムクレームからパスワード更新日時を取得して、現在日時とのミリ秒の差分をとる
  const diff = new Date().getTime() - new Date(user.customClaims.passwordUpdatedAt.getTime();

  // 有効期限を30日とするなら
  const isExpired = diff > 1000*60*60*24*30;

  // もし有効期限切れなら、例外をスローしてクライアントに有効期限切れを通知する
  if (isExpired) {
    throw new gcipCloudFunctions.https.HttpsError('unauthenticated');
  }
});
```

> ちなみに上記コードの user は、「[UserRecordクラス](https://firebase.google.com/docs/reference/admin/node/firebase-admin.auth.userrecord?hl=ja)」のインスタンスで、色々と役立つプロパティも持っているよ。今回はいらなかったけど、必要に応じて使ってみようね。

上記コードのポイントはここだね。

```javascript
  if (isExpired) {
    throw new gcipCloudFunctions.https.HttpsError('unauthenticated');
  }
```

ブロッキング関数では、処理の途中で例外「HttpsError」を投げると、認証フローを拒むことができるんだ。

だからこうして、「有効期限切れならHttpsErrorをスローする」よう実装すると、実際にこの条件を満たした時に、サインインを拒絶することができる。[※参考](https://cloud.google.com/identity-platform/docs/blocking-functions?hl=ja#blocking_registration_or_sign-in)

今回のブロッキング関数は beforeSignIn トリガーとして実装しているから、ここで拒むのは「サインイン」の認証フローだけど、そうではなくて、beforeCreate トリガーのブロッキング関数で例外を投げたなら、「ユーザのサインアップ」を拒むことができるからね。

> ※他にも、ブロッキング関数にはできることがあって、例えばユーザの情報を一部書き換えたり、カスタムクレームを更新したりできるよ。興味があれば[ドキュメント](https://cloud.google.com/identity-platform/docs/blocking-functions?hl=ja)を読んでみてね

とりあえず、これでパスワードの検証（実質的にカスタムクレームの検証）をするブロッキング関数（beforeSignInトリガー）は完成だね！

## 完成したら...

Cloud Functions を実装したから、今度はこれをIdentity Platform のブロッキング関数として登録しないといけないよ。

Identity Platform の「設定 >> トリガー」メニューに、下記キャプチャのようにbeforeSignInトリガーを設定するプルダウンがあるから、そこから今回実装した関数を選んで保存してね。

![スクリーンショット 2022-05-25 8.18.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267156/93941b02-cf81-7470-d9db-88d63edf5c2e.png)

これで無事に、ユーザログイン時に認証フローを食い止めて実行できるブロッキング関数の登録ができたよ!

実際にパスワード期限切れの状態にカスタムクレームをセットした上で、ログインを試みると、こんな風にエラーが返されるから、あとはフロントエンドの方で好きに扱ってね。

```json:有効期限切れでログイン処理を試行した時の応答
{
  "error": {
    "code": 400,
    "message": "BLOCKING_FUNCTION_ERROR_RESPONSE : HTTP Cloud Function returned an error: {\"error\":{\"code\":401,\"message\":\"Request not authenticated due to missing, invalid, or expired OAuth token\",\"status\":\"UNAUTHENTICATED\"}}",
    "errors": [
      {
        "message": "BLOCKING_FUNCTION_ERROR_RESPONSE : HTTP Cloud Function returned an error: {\"error\":{\"code\":401,\"message\":\"Request not authenticated due to missing, invalid, or expired OAuth token\",\"status\":\"UNAUTHENTICATED\"}}",
        "domain": "global",
        "reason": "invalid"
      }
    ]
  }
}
```

## まとめ
* Google Cloud の認証サービス「Identity Platform」はFirebase Authentication の上位互換だよ
* 認証フローを食い止めて実行するブロッキング関数を使えば、「サインイン時にカスタムクレームを検証して、状況に応じてアクセスを拒む」ことも簡単に実装できるよ
* 今回紹介したブロッキング関数は「beforeSignInトリガー」だったけど、他にもユーザ作成で発火する「beforeCreateトリガー」もあるから、必要に応じて使ってみてね

## 余談

最初、ブロッキング関数のUserRecordオブジェクトの「tokensValidAfterTime」で、「認証情報がいつ更新されたか」を知ることができると思ったんだよね。

でも、Admin SDK 経由でユーザをサインアップさせてみたら、このプロパティの値がnullになっていたから、仕方なく今回の記事では「カスタムクレームでパスワード更新日時を記録する」方針にしたんだ。

ちなみに tokensValidAfterTime の公式での説明はこれだよ。

> ユーザーのトークンが有効になった日付。UTC文字列としてフォーマットされます。これは、ユーザーの更新トークンがBaseAuth.revokeRefreshTokens（） APIから、またはアカウントの大幅な変更（パスワードのリセット、パスワードまたは電子メールの更新など）時にFirebaseAuthバックエンドから取り消されるたびに更新されます。
> https://firebase.google.com/docs/reference/admin/node/firebase-admin.auth.userrecord.md?hl=ja#userrecordtokensvalidaftertime

あと、初回のユーザ作成後、まだパスワードを一度も更新していないケースには、「user.metadata.creationTime」から作成日時を取得してそのdiffを取ろうとしたけど、なぜか年が「54365年」になってしまっていて、狙い通りの機能をさせるのが難しかったから、あえて使わなかったよ。（2022年5月25日にAdmin SDKからユーザを作成したときに、 creationTimeが 'Thu, 03 Jun 54365 21:21:10 GMT' になった）

また何かあれば記事を書くね。

## 著者プロフィール

faable01です。かつては創作仲間と小説を書いたり、製菓業界で楽しくやっていたはずが、紆余曲折を経て、サーバーレス技術を触るのが好きなITエンジニアになっていました。AWSのIaC兼サーバレス爆速開発ツール **「SST」** が好きです。個人ブログでもたまに記事を書いています。

https://signote.link

それから、業務日報SaaS **「RevisNote」** を運営しています。リッチテキストでの日報と、短文SNS感のある分報を書けるのが特徴で、組織に所属する人数での従量課金制です。アカウント開設後すぐ使えて、無料プランもあるから、気軽にお試しください。

https://revisnote.com?1710501811980