Post

Rails編 - Rails + Next.js + Firebase V9 Authentication で認証付きのCRUDアプリを作る

ソースコード:Backend: Rails APIFrontend: Next.js

デモページ:next-firebase-auth-sample-app

利用技術

フロントエンド

  • Next.js
  • TypeScript
  • Tailwind CSS

バックエンド

  • Rails 7.0.4(API モード)

認証部分

  • Firebase Authentication(V9) - Google ログインのみ

背景

最近、個人 PF で使う認証方式を検討している。最初は Auth0 を使おうと思っていたけど、実装で躓いて、代替案を探していたとき、Firebase Authentication が目に留まった。

電話認証以外は基本的に無料で使えるし、利用できる認証方法も非常に豊富で、特に Auth0 が対応していない匿名認証もできる点に惹かれた。

特徴的なのは匿名認証で、一時的にユニークな ID を付与したユーザーとして扱いますが、その後他の認証方式に昇格することができます。

参照:ちょっとでもセキュリティに自信がないなら、 Firebase Authentication を検討しよう

認証機能全体の流れ

  1. 画面上のログインボタン押して、Google ログイン画面に遷移する。遷移形式は popup か redirect。
  2. Firebase 側は Google から送られてきた ID Token を検証する。成功すれば、Firebase 側で user_id を生成し、ID Token を発行する。
  3. Next.js 側は Firebase が発行する ID Token を取得して、Rails 側に送る。
  4. Rails 側で Firebase ID Token を検証する。Firebase が発行した user_id を利用して、ユーザーを新規登録か既存ユーザーを特定する。
  • ユーザー情報の利用について

    Firebase 側で自動登録するユーザー個人情報はデフォルトで、email, displayName, photoUrl のみ

    追加情報必要なら、下記のいずれの処理が必要

    • Google アカウント関連の情報なら Google Access Token を使って取得する
    • Firebase Admin SDK 使って独自の情報コラムを追加する。
    • ユーザー個人情報を自前の DB で保存するなら、Rails 側で独自処理する

Firebase Authentication の初期設定

Firebase のアカウント取得やプロジェクト作成などの初期設定は他の記事を参照できるので、ここで省略。

今回はユーザー利便性を考え、メール・パスワード形式を使わず、ソーシャルログインと匿名認証のみを実装予定で、まずは Google ログインを実装する。

参照:Firebase の初期設定

Rails API 側の実装

Rails でやることは Next.js から送られてきた ID Token を検証すること。

本来 Firebase が提供する Admin SDK を使えば簡単になるけど、SDK がサポートしているバックエンド言語は Node.js, Java, Python, Go と C# のみで、残念ながら Ruby がサポートされていない。

公式説明の通り、第三者の JWT ライブラリを使って token を検証する必要がある。

JWT ライブラリは、ruby では ruby-jwtがあるので、それを使って、token を検証することができる。

※ 検証ロジックは自前で実装以外に、gem を使うなど他の選択肢もある。関連記事もあるので、詳細はここで省略。

  1. firebase-admin-sdk-ruby

  2. firebase_id_token

  3. firebase-auth-rails

検証の流れについて

公式説明の通り、Firebase: Verify ID tokens using a third-party JWT library

検証内容は三つある。 トークン のヘッダー、ペイロードと署名。

検証内容一覧

  • ID Token Header

    • alg(Algorithm): 署名作成のアルゴリズムは “RS256”であること
    • kid(Key ID ): Key ID は Google 公開鍵証明書リストの key の一つと一致すること
  • ID Token Payload

    • exp(Expiration time ): token の有効期限は過ぎていないこと。
    • iat(Issued-at time ): token の発行日時は過去であること。
    • aud(Audience): token の想定利用者識別子は project_ID と一致すること。
    • iss(Issuer): token の発行者識別子は”https://securetoken.google.com/"と一致すること
    • sub(Subject): uid となるユニークな値は空でない文字列であること。
  • ID Token Signature

    • 最後に、Google 公開鍵証明書サイトから、kidと関連する証明書を取得し、公開鍵を生成して、署名の有効性を検証する

ここで特に注意必要なのは、token を2回 decode する必要があること。

token 署名を検証するための公開鍵を特定するには、token ヘッダー内のkid(Key ID)を使う必要がある。

そのため、token を検証する前に、まず検証なしで token を decode し、kid属性を取得する。

そして、取得した公開鍵を使って、再度 JWT.decodeメソッド で token を検証する。

詳細やり方

検証ロジックのコードは/app/lib/firebase_auth.rbファイルに置いている。

全体コードはこちら:

※. rails デフォルトの/libを使わず、/app下に別途/libを作るのは、/app配下のファイルは自動的にロードされるから。

基本設定

  1. rails new my-app —api
  2. gem を追加する。 jwtrack-corsdotenv-rails
  3. config/initializers/cors.rb設定
1
2
3
4
5
6
7
8
9
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "http://localhost:3000"

    resource "*",
             headers: :any,
             methods: %i[get post put patch delete options head]
  end
end
  1. config/puma.rbで rails のサーバポートを 3001 に設定。今回は Next.js 側は 3000 にしたので。
1
port ENV.fetch("PORT") { 3001 }
  1. project id を環境変数に設定する project id はaudissの検証に使うので、まず環境変数に設定する。
  • .envファイルを作成し、FIREBASE_PROJECT_ID="XXXXXXXX"を追加する。
    ※. .envファイルを.gitignoreに追加するのを忘れなく。
  • FIREBASE_PROJECT_ID = ENV["FIREBASE_PROJECT_ID"]で project id を使う。

ついでに、いくつかの定数を定義する。

1
2
3
4
5
6
7
8
9
ALGORITHM = "RS256".freeze

# "iss"は "https://securetoken.google.com/<FIREBASE_PROJECT_ID>"
ISSUER_PREFIX = "https://securetoken.google.com/".freeze
FIREBASE_PROJECT_ID = ENV["FIREBASE_PROJECT_ID"]

# 下記のURLからGoogle公開鍵証明書リストを取得する
CERT_URI =
  "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com".freeze

検証メソッドラッパー

このラッパーメソッドのロジックは:

  • token を decode して、中身を取得する
  • 取得した header を使って、公開鍵を取得する
  • 公開鍵を使って、token を検証する
  • token 検証失敗したら、error 情報を返す
  • token 検証成功したら、ユーザー uid を返す
1
2
3
4
5
6
7
8
9
10
11
12
def verify_id_token(id_token)
  payload, header = decode_unverified(id_token)
  public_key = get_public_key(header)

  error = verify(id_token, public_key)

  if errors.empty?
    return { uid: payload["user_id"] }
  else
    return { errors: errors.join(" / ") }
  end
end

最後に返す payload 中身はこんな感じ

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
  name: "<username>",
  picture: "<user_profile_picture>",
  iss: "https://securetoken.google.com/<FIREBASE_PROJECT_ID>",
  aud: "<firebase_project_id>",
  auth_time: 1_668_430_866,
  user_id: "<user_id>(same as sub)",
  sub: "<subject>",
  iat: 1_668_488_296,
  exp: 1_668_491_896,
  email: "<user email>",
  email_verified: true,
  firebase: {
    identities: {
      "google.com": ["<google_user_id>"],
      email: ["<user_gmail>"],
    },
    sign_in_provider: "google.com",
  },
}

Step 1: 検証なしで token を decode する

まず、token を検証なしで decode する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def decode_unverified(token)
  decode_token(
    token: token,
    key: nil,
    verify: false,
    options: {
      algorithm: ALGORITHM,
    },
  )
end

# Returns:
#    Array: decoded data of ID token =>
#     [
#      {"data"=>"data"}, # payload
#      {"typ"=>"JWT", "alg"=>"alg", "kid"=>"kid"} # header
#     ]
def decode_token(token:, key:, verify:, options:)
  JWT.decode(token, key, verify, options)
end

ruby-jwtの使い方は github から参照できる。
参照: ruby-jwt

decodeメソッド引数の中身はこんな感じ。
JWT.decode(token, key=nil, verify=false, option={algorithm: ALGORITHM})

ruby-jwtの保守管理者からの返答によると、 ここのverifyfalseにすることで、JWT.decodeは token データの抽出のみを行い、検証プロセスを飛ばすので、処理速度が速く、全体のパフォーマンスに影響がないはず。

ここで decode したデータの形式は

1
2
3
4
5
6
7
8
9
10
[
  {
    aud: "<firebase_project_id>",
    auth_time: 1_668_430_866,
    user_id: "<user_id>(same as sub)",
    sub: "<subject>",
    ...
  }, # payload部分
  { alg: "RS256", kid: "XXXXXXX", typ: "JWT" } # header部分
]

となるので、 payload, header = decode_unverified(id_token)でそれぞれを取得する。

Step 2: 公開鍵を取得する

続いて公開鍵を取得する。

1
2
3
4
5
6
7
8
9
# 公開鍵取得のラッパーメソッド
def get_public_key(header)
  certificate = find_certificate(header["kid"])
  public_key = OpenSSL::X509::Certificate.new(certificate).public_key
rescue OpenSSL::X509::CertificateError => e
  raise "Invalid certificate. #{e.message}"

  return public_key
end

Google 公開鍵証明書リストにアクセスするとわかると思うけど、

ここに載せている証明書は{key: value}のハッシュ形式で、ペアが二つあり、うち一つの key は今回のkidと一致する。

1
{ key_1: "CERTIFICATE_1中身", key_2: "CERTIFICATE_2中身" }

そして、kidを使って、今回に使う公開鍵証明書を特定する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def find_certificate(kid)
  certificates = fetch_certificates
  unless certificates.keys.include?(kid)
    raise "Invalid 'kid', do not correspond to one of valid public keys."
  end

  valid_certificate = certificates[kid]
  return valid_certificate
end

# CERT_URLから証明書リストを取得する
def fetch_certificates
  uri = URI.parse(CERT_URI)
  https = Net::HTTP.new(uri.host, uri.port)
  https.use_ssl = true

  req = Net::HTTP::Get.new(uri.path)
  res = https.request(req)
  unless res.code == "200"
    raise "Error: can't obtain valid public key certificates from Google."
  end

  certificates = JSON.parse(res.body)
  return certificates
end

Step 3: token の有効性を検証する

subalgJWT.decodeで自動検証できないため、追加検証必要。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def verify(token, key)
  errors = []

  begin
    decoded_token =
      decode_token(
        token: token,
        key: key,
        verify: true,
        options: decode_options,
      )
  rescue JWT::ExpiredSignature
    errors << "Firebase ID token has expired. Get a fresh token from your app and try again."
  rescue JWT::InvalidIatError
    errors << "Invalid ID token. 'Issued-at time' (iat) must be in the past."
  rescue JWT::InvalidIssuerError
    errors << "Invalid ID token. 'Issuer' (iss) Must be 'https://securetoken.google.com/<firebase_project_id>'."
  rescue JWT::InvalidAudError
    errors << "Invalid ID token. 'Audience' (aud) must be your Firebase project ID."
  rescue JWT::VerificationError => e
    errors << "Firebase ID token has invalid signature. #{e.message}"
  rescue JWT::DecodeError => e
    errors << "Invalid ID token. #{e.message}"
  end

  sub = decoded_token[0]["sub"]
  alg = decoded_token[1]["alg"]

  unless sub.is_a?(String) && !sub.empty?
    errors << "Invalid ID token. 'Subject' (sub) must be a non-empty string."
  end

  unless alg == ALGORITHM
    errors << "Invalid ID token. 'alg' must be '#{ALGORITHM}', but got #{alg}."
  end

  return errors
end

def decode_options
  {
    iss: ISSUER_PREFIX + FIREBASE_PROJECT_ID,
    aud: FIREBASE_PROJECT_ID,
    algorithm: ALGORITHM,
    verify_iat: true,
    verify_iss: true,
    verify_aud: true,
  }
end

これで、token 検証が完了した。

取得した payload 内のユーザー情報を使って、新規ユーザーの登録などができる。

今後の課題

  • Google 公開鍵証明書を cache する
  • 匿名認証
  • 他のソーシャルログイン(Twitter, line など)
  • フロントエンド側のコードリファクタ、パフォーマンス改善

参考になったリソース

Rails 部分の検証で大変参考になった記事やソースコードは下記となる。

Next.js 側の実装

Next.js 編はこちら -> Next.js 側の実装

This post is licensed under CC BY 4.0 by the author.