Vue.jsとIdentity Platform(IDプラットフォーム)でAPIの認証機能を作る

# はじめに

今、ちょっとしたWebアプリを作ってるのですが、認証システムを自作するのはセキュリティの意味でも生産性の意味でもナンセンスです。
なので何かしらのCIAM(Customer identity access management)を入れようと思ったのですが、ちょうどGCPを使っていたので[Identity Platform(IDプラットフォーム)](https://cloud.google.com/identity-platform?hl=ja)を使ってみることにしました。これはFireabase認証をベースにGoogleがエンタープライズグレードとしてGCPに統合した[IDaaS](https://qiita.com/osak/items/28eda07e5d0183c6f99a)です。

というわけで備忘録としてIdentity PlatformとVue.jsおよびJWT(+Quarkus)を使ったサーバサイドでAPI認証のやり方を解説します。

# システム構成

今回作るシステム構成はクライアント(Vue.js)、API(Quarkus)、認証サーバ(Identity Platform)、IdP(Google/Twitterなどのソーシャルログイン)で構築します。イメージ図は以下の通り。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/54904/215af3d2-142b-2352-8dd1-80fabc97dbe6.png)

JWTなのでAPI側は基本的にトークンのみを見れば良く、実際のIdPと通信をする必要はありません。今回は公開鍵をIdentity Platformから取得するのをリアルタイムでやっていますが負荷などの観点で必要であればオフラインで事前に取得しておくこともできます。

# そもそもIdentity Platformって？

日本語訳が「IDプラットフォーム」で英語名が「Identity Platform」なのと「Cloud Identity」というまったく別のサービスをGoogleは持っているので非常に分かりづらいですが、これは[Firebase認証](https://firebase.google.com/docs/auth?hl=ja)をベースにGCP側に取り込んで作られたIDaaSです。Firebase Authenticationとの主な違いは下記の通り。

## Functional Features

| Feature	      | Identity Platform | Firebase Authentication |
|:--------------------|------------------:|------------------------:|
|Sign in with email   |                Yes|                      Yes|
|Sign in with OAuth (ソーシャル認証)   |                Yes|                      Yes|
|Sign in with phone   |                Yes|                      Yes|
|Custom authentication|                Yes|                      Yes|
|Sign in with OIDC    |                Yes|                       No|
|Sign in with SAML    |                Yes|                       No|
|Multi-tenancy        |                Yes|                       No|
|IAP integration      |                Yes|                       No|

## Non Functional Features

| Feature	      | Identity Platform | Firebase Authentication |
|:--------------------|------------------:|------------------------:|
|ISO 27001            |                Yes|                      Yes|
|SSAE 18 SOC1         |                Yes|                      Yes|
|SSAE 18 SOC2         |                Yes|                      Yes|
|SSAE 18 SOC3         |                Yes|                      Yes|
|TISAX                |                Yes|                       No|
|BAA coverage         |                Yes|                       No|
|PCI-DDS in scope     |                Yes|                       No|
|Enterprise SLA       |      99.95% uptime|                       No|

## Price

下記は独自のパスワード認証やGoogle/Twitter/Facebook/GitHubなどを使ったソーシャル認証の価格テーブルです。SAMLやOpenID Connectを使う場合は[価格テーブルが異なります](https://cloud.google.com/identity-platform?hl=ja)。1 人の MAU を 1 か月で複数回認証しても追加料金はかからないようです。

| 月間アクティブ ユーザー（MAU）| Identity Platform    | Firebase Authentication |
|:---------------------------|----------------------:|------------------------:|
|0～49,999                   |                     $0|                       $0|
|50,000～99,999              |                $0.0055|                       $0|
|100,000～999,999            |                $0.0046|                       $0|
|1,000,000～9,999,999        |                $0.0032|                       $0|
|10,000,000 以上             |                $0.0025|                       $0|

## Identity Platform まとめ

Identity Platformの主な違いはOIDC/SAMLへの対応、コンプライアンスとSLA準拠、有料か無償かの違いです。
個人的には月間5万ユーザを超えるまでは無料ですし、それ以降も十分安いのでエンタープライズグレードのIdentity PlatformがGCPにも統合されていて便利だと思います。

なお、現時点ではFirebase AuthenticationとIdentity Platformは別のプロダクトですが、将来的にはFireaStoreの時のように同じ製品の別モードという形になるのでは無いかと想像しています。

注意点としては、Identity Platform単体でのドキュメントは非常に少ないです。SDKも含めてFireabaseのものをそのまま利用しているので、基本的にはFirebase側のドキュメントを読むことになります。この辺は成熟度が全然足りて無いところですね。。。

# GCPでIdentity Platformのセットアップ

[こちらのページ](https://cloud.google.com/identity-platform/docs/quickstart-email-password?hl=ja)を参考にIdentity Platformのセットアップをします。まずは[Identity Platformのマーケットプレイス](https://console.cloud.google.com/marketplace/details/google-cloud-platform/customer-identity?hl=ja&_ga=2.261815694.2079634596.1596336869-295127236.1593579137)からAPIを有効にします。

続いて、[Identity Platformのページ](https://console.cloud.google.com/customer-identity/providers)に遷移してプロバイダの追加を行います。
今回は独自のアカウントは不要なのでGoogleとTwitterを追加しておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/54904/9ad58f80-e61b-c06c-3e55-185080362bd4.png)
後から追加や削除は出来るので必要最小限で作ればよいと思います。

次に、「アプリケーション設定の詳細」リンクから「アプリケーションの構成」を開き、apiKeyとauthDomainを確認します。これは後程、Vue.jsで設定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/54904/8da56bcf-4564-7859-af90-02cf8ac14db7.png)

以上で、とりあえずのIdentity Platformのセットアップは完了です。

# Vue.jsでのIdentity Platformによる認証の実装

## セットアップ

まずはVue.jsにIdentity Platformでの認証を組み込みます。Identity Platformは既存のFirebase SDKをそのまま利用します。

```bash
yarn add firebase
```

ライブラリと設定を`main.js`で読み込みます。API Keyは先ほどメモしたものを利用します。誤ってGitHubなどに上げてしまわないように環境変数を取得するようにし`.env.local`あたりに記載しておくのが無難でしょう。

```src/main.js
import firebase from 'firebase';
var config = {
  apiKey: process.env.VUE_APP_AUTH_API_KEY,
  authDomain: process.env.VUE_APP_AUTH_API_DOMAIN,
};
```

```.env.local
VUE_APP_AUTH_API_KEY={YOUR_API_KEY}
VUE_APP_AUTH_API_DOMAIN={YOUR_API_DOMAIN}
```

## サインインページの作成

サインインページの作成をします。今回はソーシャルログインを使いかつ追加のユーザ情報を入力しないので、ユーザ登録の画面等は不要です。

```src/views/Signin.vue
<template>
  <div class="signin">
    <h1>サインイン</h1>
    <button @click="signIn">Googleで認証</button>
    <p>Token: {{token}}</p>
  </div>
</template>

<script>
import firebase from "firebase";

export default {
  name: "Signin",
  data() {
    return { token: "" };
  },
  methods: {
    signIn: function () {
      const self = this;
      firebase.auth().signInWithPopup(new firebase.auth.GoogleAuthProvider())
        .then((res) => {
          res.user.getIdToken()
            .then((token) => (self.token = token))
            .catch((error) => {
              console.log(error);
              this.errorMessage = error.message;
              this.showError = true;
            });
        });
    },
  },
};
</script>
```

`firebase.auth().signInWithPopup(new firebase.auth.GoogleAuthProvider())`でGoogleの認証画面を呼び出して認証情報を取得し、Identity PlatformのIDトークン(アクセストークン)をJWTで取得します。プロバイダの部分を変更する事でそれ以降のロジックは特に変えずに認証が可能になります。

1点注意したいのは例えば[サンプルコード](https://cloud.google.com/identity-platform/docs/web/google?hl=ja#signing_in_users_with_the)には以下のようにも書いてあります。

```js
firebase.auth().signInWithPopup(provider).then(function(result) {
  // This gives you a Google Access Token. You can use it to access the Google API.
  var token = result.credential.accessToken;
}
```

ここで`credential.accessToken`で取得できるものがFirebase/Identity Platformのアクセストークンだと勘違いしてしまいますが、これはあくまでも **「認証プロバイダのアクセストークンでありIdentity Platformのアクセストークンでは無い」**ということです。なので、ここで取得出来ているのは**「Googleのアクセストークン」**です。Twitterで認証してればTwitterのアクセストークン、GitHubならGitHubのアクセストークンが返ります。そのためこちらのトークンを使ってをAPIの検証等は出来ません。
**「Firebase/Identity Platformのアクセストークンの取得は`getIdToken()`」** という事に注意してください。
	
# トークンを利用したAPIの認証

つづいてFireabaseから取得したトークンを使ってサーバサイドでAPIの認証をする仕組みを作ります。今回もQuarkusを使用しています。

## MicroProfile JWT RBACの実装

MicroProfile にはMicroProfile JWT RBAC (Json Web Token Role-Based Access Control)の仕様があるので、こちらを使います。Firebase SDKを使う事も可能ですがJWTとして処理する方が汎用性があって良い気がします。

まずはライブラリを追加します。

```bash
./mvnw quarkus:add-extension -Dextensions="smallrye-jwt"
```

JAX-RSのコードは以下のように書きます。

```TokenSecuredResource.java
@Path("/secured")
@RequestScoped
public class TokenSecuredResource {
    @Inject
    JsonWebToken jwt;

    @GET
    @Authenticated
    @Produces(MediaType.APPLICATION_JSON)
    public Response get(@Context SecurityContext ctx) throws JsonProcessingException {
        var caller = ctx.getUserPrincipal();
        var result = Map.of(
                "id", caller == null ? "anonymous" : caller.getName(),
                "name", jwt.getClaim("name"),
                "picture", jwt.getClaim("picture"),
                "expiration_time", jwt.getExpirationTime(),
                "has_jwt", jwt.getClaimNames() != null,
                "payload", jwt.getClaimNames().stream()
                        .map(k -> List.of(k, jwt.getClaim(k).toString()))
                        .collect(Collectors.toMap(x -> x.get(0), x -> x.get(1)))
        );

        return Response.ok(new ObjectMapper().writeValueAsString(result))
                .build();
    }
}
```

`@PermitAll`など「[JSR 250に登録されたアノテーション](https://en.wikipedia.org/wiki/JSR_250)」を使用するとこでアクセスコントロールを行えます。
ただ、JSR250は「認証済みのユーザ」というシンプルなものが無く`RolesAllowed`もロールの指定が必要なので`io.quarkus.security.Authenticated`を使うのがお勧めです。

`Authorization: Bearer ...`からのJWTの取得は自動でされ`JsonWebToken`に格納されています。ペイロードは`JsonWebToken#getClaim`で取得できます。

続いて、JWTの複合および検証をするためのPublic Keyの場所等の指定をします。これはFireabase/Identity Platformに対して検証が行われるのでGoogleやTwitterにフェデレーションをしている時でも記述するlocationやissuerは同一です。

```application.properties
# JWT with Firebase/Identity Platform
mp.jwt.verify.publickey.location=https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com
mp.jwt.verify.issuer=https://securetoken.google.com/{YOUR_GCP_PEOJCT_ID}
quarkus.smallrye-jwt.auth-mechanism=MP-JWT
quarkus.smallrye-jwt.enabled=true
```

1点注意が必要なのは`mp.jwt.verify.publickey.location`です。「[Firebaseのドキュメント](https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja#verify_id_tokens_using_a_third-party_jwt_library)」にはサードパーティでの検証に使う公開鍵は`https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com`を使えと記載があります。
しかし、x509は「[Quarkus](https://quarkus.io/guides/security-jwt#supported-public-key-formats)」でサポートされるJWT向けの公開鍵の範囲ではありません。なので、サポートに入っているJWKSに対応するURLである`https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com`を使う必要があります。

これでサーバサイド側の準備は完了です。

## Vue.jsとサーバサイドの連携

`Signin.vue`を拡張して認証情報の取得後にAPIにトークンを付与してリクエストを投げるコードを作成します。

```src/views/Signin.vue
<template>
  <div class="signin">
    <h1>サインイン</h1>
    <button @click="signInWithGoogle">Googleで認証</button>
    <button @click="signInWithTwitter">Twitterで認証</button>
    <div v-show="user.id">
      <p>ID: {{user.id}}</p>
      <p>
        名前:
        <img :src="user.pic" width="24px" height="24px" />
        {{user.name}}
      </p>
      <p>有効期限: {{user.expiration_time}}</p>
    </div>
  </div>
</template>

<script>
import firebase from "firebase";

export default {
  name: "Signin",
  data() {
    return {
      user: {
        id: "",
        name: "",
        expiration_time: "",
        pic: "",
      },
    };
  },
  methods: {
    signInWithGoogle: function () {
      this.signIn(new firebase.auth.GoogleAuthProvider());
    },
    signInWithTwitter: function () {
      this.signIn(new firebase.auth.TwitterAuthProvider());
    },
    signIn: function (provider) {
      const callApi = (token) => {
        const url = "http://localhost:8080/secured";
        const config = {
          headers: {
            Authorization: "Bearer " + token,
          },
        };
        this.axios.get(url, config).then((response) => {
          this.user.id = response.data.id;
          this.user.name = response.data.name;
          this.user.expiration_time = new Date(
            response.data.expiration_time * 1000
          );
          this.user.pic = response.data.picture;
        });
      };

      firebase
        .auth().signInWithPopup(provider).then((res) => {
          res.user
            .getIdToken().then(callApi).catch((error) => {
              console.log(error);
              this.errorMessage = error.message;
              this.showError = true;
            });
        });
    },
  },
};
</script>
```

認証後に`axios`を使ってサーバサイドにリクエストを投げています。その際にトークンを`Authorization`ヘッダーに`Bearer`キーワードと共に投げています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/54904/44f2fdf1-176b-10cd-b0fd-06d9531d5344.png)
これによって認証したトークンをサーバサイドの認証に使えることが分かりました。

# まとめ

今回はGoogleのCIAMであるIdentity Platform(IDプラットフォーム)とVue.jsを組合わせた認証の仕組みを実装しました。
SPAの場合は古き良きWebアプリケーションと違いクライアント側の認証とAPI側の認証が両方必要です。自前で作る事はもちろん可能ですが、CIAMを使う事で独自のアカウントでもソーシャルログインでも簡単に作る事が可能です。

まあ、独自ロジックをベースにする場合はFIDO認証に対応したCIAMを使う方が今後は良いでしょうけど。。。

今回は省きましたが実際にアプリとして作るときにはクライアント側の認証画面の制御とかも必要になるのでVue.jsでの実装方法は[こちらの記事](https://qiita.com/koduki/items/b4b56a27c6b7406a4ddb)をご覧ください。

Identity Platformでアクセストークンの寿命の設定やリフレッシュトークンを無効にする方法はまだ調べきってないので、その辺も今後調査しておきたいと思います。

それではHappy Hacking!

# 参考:
- [Google Cloud: Identity Platform](https://cloud.google.com/identity-platform)
- [QUARKUS - USING JWT RBAC](https://quarkus.io/guides/security-jwt)
- [入門: Vue.jsで認証機能を作る](https://qiita.com/koduki/items/b4b56a27c6b7406a4ddb)
- [ブログなんだよもん - そもそもJWTに関する私の理解は完全に間違っていた！](https://koduki.hatenablog.com/entry/2019/11/03/163014)
- [StackOverflow - Quarkus and Firebase Authentication](https://stackoverflow.com/questions/61662586/quarkus-and-firebase-authentication)