Firebaseを使ったTwitter認証をクライアント(Swift)からサーバ(Rails)までまるっと実装する

この記事は ZOZOテクノロジーズ #1 Advent Calendar 2019 7日目の記事になります。
ZOZOテクノロジーズでバックエンドのエンジニアをしている @calorie です。よろしくお願いします。

昨日の記事は @s_nagasawa さんによる「[vue-composition-apiを使って機能単位のコード分割と合成を試してみた](https://qiita.com/s_nagasawa/items/ef70032f996face318e5)」でした。
ちょうど年末にフロントエンドを勉強しようと思っていたので、
とても興味深い記事でした。こちらもぜひご覧ください。

# この記事について

[Firebase Authentication](https://firebase.google.com/docs/auth?hl=ja) を用いたiOSにおけるTwitter認証を、
クライアントで認証を行い、サーバでユーザを作成するまで通して実装してみたので、
その実装方法と得られた知見を、
サンプルコードを用いながら共有します。

やってみると、意外とうまく行かなかったり、
クライアントからサーバまで実装しようとすると、
ドキュメントが分散してしまっていて、
実装に想像以上に時間がかかってしまったため、
この記事でまとめることで、同じような境遇の方々が、
より、サービスの実装に集中する時間が長くなることを目的にしています。
指摘等ありましたら、ぜひコメントにてお願いします。

以降は、以下のサンプルコードを用いますので、こちらもご覧下さい。

- [クライアント(iOS/Swift)](https://github.com/calorie/firebase-auth-ios)
- [サーバ(Ruby on Rails)](https://github.com/calorie/rails_api_firebase_auth)

筆者がSwiftを初めて触ったのと、短い時間で作ってしまったものなので、
現状では雑な部分があるはご容赦ください。
流用する際にはご注意ください。

# Firebaseを用いた認証フロー

この記事で対象とする、
Firebaseを用いた認証フローを、
以下のシーケンス図に記載します。
これに沿って実装を見ていければと思います。

![firebase_auth.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8523/cacb9f80-a144-d039-6167-ab90a570cc96.jpeg)

# 1. クライアントからFirebaseへ認証

![firebase_auth3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8523/6c97ac54-45e7-71d3-d51d-08f9e523d502.gif)

[公式ドキュメント](https://firebase.google.com/docs/auth/ios/twitter-login?hl=ja)の「はじめに」のあたりは、
ドキュメントも豊富で詰まりにくいので割愛させていただきます。

クライアント上で、ログイン用のViewControllerが作成されて、
何もないログイン画面が表示されている状態を前提に進めていきます。

基本的には[こちら](https://firebase.google.com/docs/auth/ios/twitter-login?hl=ja)の公式ドキュメントに沿って実装していく形になります。
ログインボタンに対して、signIn のメソッドを紐付けます。

[該当コード](https://github.com/calorie/firebase-auth-ios/blob/master/firebase-auth-ios/LoginViewController.swift#L12-L42)

```swift
class LoginViewController: UIViewController {
    var twitterProvider : OAuthProvider?

    override func viewDidLoad() {
        super.viewDidLoad()
        self.twitterProvider = OAuthProvider(providerID:"twitter.com");
    }

    @IBAction func tappedSignInWithTwitter(_ sender: Any) {
        self.twitterProvider?.getCredentialWith(_: nil){ (credential, error) in
            if error != nil {
                // Handle error.
            }
            if let credential = credential {
                Auth.auth().signIn(with: credential) { (authResult, error) in
                    if error != nil {
                        // Handle error.
                    }

                    Auth.auth().currentUser?.getIDToken(completion: { (token, error) in
                        APIClient.sharedInstance.createUser(token: token!, success: { () in
                            let vc = UIStoryboard.main().instantiateInitialViewController()
                            UIApplication.shared.windows.filter { $0.isKeyWindow }.first?.rootViewController = vc
                        }) {
                        }
                    })
                }
            }
        }
    }
}
```

ここで注意すべきなのは、
`self.twitterProvider = OAuthProvider(providerID:"twitter.com");` この処理を、
`viewDidLoad` 内で行わなければならないことです。
筆者はIBAction内でやってしまい、ここで2時間くらい詰まりました。

また、[公式ドキュメント](https://firebase.google.com/docs/auth/ios/twitter-login?hl=ja)のサンプルコードと若干書き方が異なるのにお気づきでしょうか。
公式では `Auth().signIn` のところが、 `Auth.auth().signIn` となっています。
そうなんです。公式のサンプルコード、そのまま用いるとビルドが通りません。
おそらく `Auth()` が `Auth.auth()` を返すコードがどこかにある前提で書かれているのかなと考えていますが、
ここも若干はまりました。。 公式ドキュメントは参考程度に捉えると良いのかもしれません。

# 2. ID tokenの取得

signInに成功すると、 `Twitter OAuth access token` などを返してくれるので、
これによってAPI経由でTwitterの情報を取得できるようになります。

ただ、今必要なのはFirebase Authenticationが管理するID tokenで、
これは返してくれていないようなので、 `getIDToken` で取得します。

[該当コード](https://github.com/calorie/firebase-auth-ios/blob/master/firebase-auth-ios/LoginViewController.swift#L31-L37)

```swift
Auth.auth().currentUser?.getIDToken(completion: { (token, error) in
    ...
})
```

# 3. ID tokenを用いてサーバへリクエスト

2で取得したID tokenをリクエストヘッダーの
`Authorization: Bearer ` ヘッダに埋め込んで送信します。

[該当コード1](https://github.com/calorie/firebase-auth-ios/blob/master/firebase-auth-ios/LoginViewController.swift#L32-L36)

```swift
APIClient.sharedInstance.createUser(token: token!, success: { () in
    ...
}) {
}
```

[該当コード2](https://github.com/calorie/firebase-auth-ios/blob/master/firebase-auth-ios/APIClient.swift#L24-L37)

```swift
func createUser(token: String, success: @escaping () -> Void, failure: @escaping () -> Void) {
    let baseURL = URL(string: API_HOST + "/v1/users")!
    let headers: HTTPHeaders = ["Authorization": String(format: "Bearer %@", token)]
    Alamofire.request(baseURL, method: .post, parameters: nil, headers: headers)
        .validate(statusCode: 200..<300)
        .responseJSON { response in
        switch response.result {
        case .success(_):
            success()
        case .failure(_):
            failure()
        }
    }
}
```

# 4. サーバでID tokenの検証

クライアントから送られてきたJWT形式のID tokenを、
サーバでFirebase Admin SDKを用いてデコードして検証します。
[公式ドキュメント](https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja)

ですが、残念なことに、RubyのFirebase Admin SDKはありません。

よって、[このように](https://qiita.com/otakky/items/b7582202f5cde8f2dd21)自前で実装されている方や、
以下のように、いくつかgemが公開されています。

- [firebase-auth-rails](https://github.com/penguinwokrs/firebase-auth-rails)
  - betaだが、一番高機能
  - RailsとRedisに依存していて、Google APIでのリクエストのキャッシュや、RailsでのModelの作成までカバーしている
- [firebase_id_token](https://github.com/fschuindt/firebase_id_token)
  - pre-releaseだが、一番Starが多い
  - Redisに依存していて、Google APIでのリクエストをキャッシュしてくれる、バッチによるCertificatesの更新に対応している
- [firebase-auth-id_token_keeper](https://github.com/Findy/firebase-auth-id_token_keeper)
  - 一番シンプルだが、キャッシュなどの機構はない

今回は `firebase-auth-id_token_keeper` を使用しました。
シンプルで実装をすべて把握できるのと、
基本的なアプリケーションにおいては、
キャッシュを使わなくても十分に速度を出せることから、
今後において、あまりRedisを使う機会がなく、
このためだけにRedisを導入することに対してネガティブだったためです。

また、実装がシンプルなので、パッチがあてやすく、
認証でキャッシュが必要になったら、
[この部分](https://github.com/Findy/firebase-auth-id_token_keeper/blob/master/lib/firebase/auth/id_token_keeper/id_token.rb)とかに、キャッシュを使うように
パッチをあてることで解決できると考えています。

Railsにおける実装は、
ApplicationController などで、
`authenticate_with_http_token` を用いてID tokenを取り出し、
`Firebase::Auth::IDTokenKeeper::IDToken.new(token).verified_id_token` で検証しています。

[該当コード](https://github.com/calorie/rails_api_firebase_auth/blob/master/app/controllers/application_controller.rb)

```ruby
before_action :authorize

private

def authorize
  access_token = extract_access_token_from_header
  # TODO if access_token.blank?
  @current_user_firebase_uid = access_token.first['sub']
end

def current_user
  return unless @current_user_firebase_uid
  @current_user ||= User.find_by(uuid: @current_user_firebase_uid)
end

def extract_access_token_from_header
  authenticate_with_http_token do |token, _|
    Firebase::Auth::IDTokenKeeper::IDToken.new(token).verified_id_token
  end
  ...
end
```

ID tokenをデコードしたあとに、ペイロードからFirebaseでのuidを取り出し、
ユーザの特定用にサーバ側で保存しています。
JWTのペイロードの詳細は、[公式ドキュメント](https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja#verify_id_tokens_using_a_third-party_jwt_library)に記載があります。

```ruby
def authorize
  # デコードしたID tokenからuidを取得
  access_token = extract_access_token_from_header
  @current_user_firebase_uid = access_token.first['sub']
end

def extract_access_token_from_header
  authenticate_with_http_token do |token, _|
    Firebase::Auth::IDTokenKeeper::IDToken.new(token).verified_id_token
  end
  ...
end
```

# 5. サーバでユーザの保存

ApplicationControllerで取得したuidをDBに保存します。

[該当コード](https://github.com/calorie/rails_api_firebase_auth/blob/master/app/controllers/api/v1/users_controller.rb)

```ruby
def create
  user = User.new(uuid: @current_user_firebase_uid)
  user.save!
  render json: UserSerializer.new(user).serialized_json
end
```

ログイン後は、4と同様にID tokenをデコードしてuidを取り出し、
それに紐づくユーザをselectしてくる流れとなります。

[該当コード](https://github.com/calorie/rails_api_firebase_auth/blob/master/app/controllers/application_controller.rb#L8-L17)

```ruby
def authorize
  access_token = extract_access_token_from_header
  # TODO if access_token.blank?
  @current_user_firebase_uid = access_token.first['sub']
end

def current_user
  return unless @current_user_firebase_uid
  @current_user ||= User.find_by(uuid: @current_user_firebase_uid)
end
```

ここまでで、認証からユーザ作成までを一通り実装しました。

# TODO

今回のサンプルアプリを、
少なくともハッカソンのようなところで
使えるくらいには仕上げたいと思っています。

- Firebase Authorizationが提供している他の認証の実装
    - Sign In with Appleはやりたい
- Docker化

ちょくちょく手を入れていこうと思いますので、
また知見がたまりましたら、続きを書きたいと思います。

# 最後に

Firebase Authenticationを用いたiOSにおけるTwitter認証を、
クライアントで認証を行い、サーバでユーザを作成するまで通して実装した流れをまとめました。
少しでも読まれた方のお役に立てれば幸いです。

明日は、 @hsawada さんが公開予定です。そちらもぜひご覧下さい。