見出し画像

Firebase AuthenticationとCloud Runを使ってマイクロサービスっぽく認証機能を作り直してみた (1/2)

大城です。

今日は僕が大好きなFirebaseについて書いて見ようと思います。皆さんFirebaseも使っていますか? 一人のエンジニアとしては本当にお世話になっているサービスです。(しかもほぼタダで。。。) 

さて、アプリケーションを構築していれば当然認証と認可の機能は必要になってきます。しかし最近のモダンなアプリケーション開発における認証についてはとても複雑になって来たと思います。

古き良き時代もありました。一つのWebサービスがあって、ユーザのIDとPWはハッシュ化してデータベースにいれておけばよかったわけです。しかし今はユーザはログインする際にいろんなオプションを求める様になりました。

• SNSアカウント(Google, Facebookなど)でログインしたい。
• アプリ内のエクスペリエンス向上のために外部サービスと連携したい。

一方で、アプリ提供側にも長期的にアプリケーションをモダンにしていきたいといった課題があります。モノリシックからマイクロサービスの移行などが代表的なテーマです。

ユーザーの求める利便性を提供しながらアプリケーションをスケーラブルに分割して行くのは、もはや外部の認証サービスなしでは実現するのはなかなかしんどくなってきたのではないでしょうか? 実装方法を考え始めると夜も眠れなくなります。

metrolyではこれまで認証はバックエンドの機能の中のひとつの機能として実装していました。ランダムに作った文字列のトークンをバックエンドでリクエスト毎にDBと検証して認証をしていました。

大きな問題はないのですが、サービスを大きくしていこうと考えていった際に際に以下のような問題に直面しました。

1. ステートフルな認証になってしまっていた。リクエストごとに無条件DBにアクセスする必要があるためにレイテンシーが増える。またスケールする際のボトルネックになりかねない。
2. マイクロサービスアーキテクチャに向いていない。認証サービスと最初に作ったバックエンドが密結合になってしまうので、バックエンドのサービスを増やそうとすると気持ち悪くなってきてしまう。

ということで、今回は僕が開発しているアプリのmetrolyでどのようにこの問題を解決することにしたかご紹介したいと思います。

解決するのに使ったテクノロジー

ステートフルな認証になってしまっている点は JSON Web Token (JWT)を使いました。JWTの優位性として検証する際にDBにアクセスする必要がない点が挙げられます。検証する側は、事前に公開鍵(と次の公開鍵)をキャッシュしておいて、秘密鍵でサイン済みのJWTのSignatureを暗号学的に検証するので、ディスクにアクセスさせる必要がありません。ステートを持たないので、トラフィックが増えてもここがボトルネックになることはありません。

マイクロサービスアーキテクチャに向いていない点の背景について少し補足をします。metrolyのユーザーには複数の種類があります。無料のユーザー、有料のユーザー、そしてエンタープライズ契約をしているユーザー。色々あるサービスに対してユーザーの契約の状態に応じて、認可の制御を行う必要があります。

画像2

これに関してもJWTが解決してくれます。Custom Claimという枠組みの中でユーザーが行使できる権限を制御してあげることが出来ます。

ID TokenはJWTの技術の上に成り立っているので、Firebase AuthenticationのID Tokenでも当然上記のメリットを教授できます。

実現したかった事

いろんな認証の実装方法があると思います。metroly では以下のような要件があります。

1. ソーシャルアカウントは2種類。 Google と Microsoft。
2. それぞれのサービスでAPIコールをしたいので、Access Tokenと Refresh Tokenを取得してDBに保存する
3. 複数のサービスで認証のステートを共有出来るようにしたい

課題

Firebase Authenticationでなんとか実装しようとも思いましたが複数の課題がありました。

Firebase AuthだとRefresh Token取れない

Firebase AuthenticationだけだとIdPからのAccess Tokenは取得出来るが、Refresh Tokenは取得出来ない。余談ですが、Access Tokenは以下の要領で取得出来る。でもRefresh TokenはFirebase Authenticationだと取れない。(取り方あるなら教えて下さい。)多分 Implicit Login Flowしか対応していないのが問題なんだと思います。response_type=code と指定することが出来ない。

provider = new auth.GoogleAuthProvider();
for (const scope of ['profile', 'email', 'https://www.googleapis.com/auth/calendar.events.readonly']) {
  provider.addScope(scope);
}

const authResult = await this.auth.auth.signInWithPopup(provider);
this.accessToken = authResult.credential.toJSON()['oauthAccessToken']; 

論理的な構成

ということで、上記のFirebaseの弱点を補うために自前でIdentity Serviceを立ち上げて Access TokenとRefresh TokenをDBで保存する方法を取りました。

Identity ServiceではIdPからAccess TokenとRefresh Tokenを取得して、Firebase の Custom Tokenを発行し、SPAに戻す・・・というところまでを担います。以下のようなイメージです。

// なにか入れたいCustom Claimを入れてあげる
const customClaims = {
  key: 'value',
  membership: 'premium'
}

const customToken = await admin.auth().createCustomToken(user.uid, customClaims);
return customToken;
// customTokenは何らかの方法でSPAに戻してあげる

論理的には以下の様な相関図になります。

画像1

SPAではCustom TokenをIdentity Serviceから受け取って、Custom Tokenを使ってログインします。SPAのコードのイメージは以下です。

// login or logout時にコールバックされる
this.auth.auth.onAuthStateChanged(async (user) => {
 if (user) {
   this.idToken = await user.getIdToken();
   // ログイン成功! idTokenを取得! 次のリクエストからこれを含める  

 } else {
   // ログアウトした
 }
})


const customToken // custom tokenをidentity service から受け取る
this.auth.auth.signInWithCustomToken(customToken)

ID Tokenを取得したらこれをブラウザ内で保存しておきます。バックエンドのリクエストの認証ヘッダに次から入れておきます。

長くなってしまったので、次回は Proxy の実装方法について説明をしたいと思います。

追記: 
執筆しました。もしよければこちらもどうぞ!



この記事が気に入ったらサポートをしてみませんか?