Firebase Cloud Functionsを使ってFacebook AccountKitをFirebase Authと連携する

# Facebook AccountKitをFirebase Authと連携する

## Why use AccountKit and Firebase Auth?

### 結論

Facebook AccountKitとFirebase Authを利用することで、__汎用的__かつ__スケーラブル__なモバイルアプリを、__最小の実装工数__で構築できる。

### AccountKitについて

AccountKitは、Facebookが提供している電話番号認証を簡単に実装できるサービスです。
電話番号認証は、現在モバイルの中で最も多くのユーザーをカバーできるログイン方法です。
少し手間がかかりますがこの認証形式は世界中で利用されています。事実[WhatsApp](https://www.whatsapp.com/)では初期からこの方法が利用されており、世界で多くのユーザーを獲得しています。
つまり、AccountKitを実装することはモバイルアプリにおいて非常に有利であると言えます。

- [AccountKit](https://developers.facebook.com/docs/accountkit)

| 認証方法 | メリット | デメリット　|
| --- | --- | --- |
| Facebook login | ボタンを2回押すだけで認証が可能 | Facebookを利用していなければ利用できない。世界中で若年層のFacebookユーザーが年々減少している。 |
| Google login | ボタンを2回押すだけで認証が可能 | Googleを利用していなければ利用できない。 iPhoneユーザーはGoolgeアカウントでの認証はしない。|
| Twitter login | ボタンを2回押すだけで認証が可能 | Twitterを利用していなければ利用できない。ダミーアカウントを量産される。提供しようとしているサービスのユーザーのセグメント |
| LINE login | ボタンを2回押すだけで認証が可能 | LINEを利用していなければ利用できない。アジア圏でしか利用されていない。 |
※ 主観が入っておりますが、だいたい認識してもらえるかと思います。

### Firebase Authについて

Firebase Authは、Goolgeが提供するFirebaseの中の一つのサービスです。
多くのサービスプロバイダーの認証をサポートしており、その中でも注目すべきは、[Custom Login](https://firebase.google.com/docs/auth/server/create-custom-tokens)です。
すでにサービスを提供している場合でも、既存の`User ID`と連携できるよう設計されており、サーバー数行のコードを追加するだけで、Firebaseの機能を利用することが可能となっています。

<img width="960" alt="スクリーンショット 2017-03-22 17.25.49.png" src="https://qiita-image-store.s3.amazonaws.com/0/80287/3f47c8da-6380-be3b-8ccb-45b84bca25c4.png">

- [Firebase Auth](https://firebase.google.com/docs/auth/)

#### Cloud Functionsについて
2017月3月に`Cloud Functions`と呼ばれる新機能がリリースされたことでさらにモバイルプラットフォームの可能性は広がりました。
[Cloud Functions](https://firebase.google.com/docs/functions)は、AWSのLambdaのような機能を提供しています。Firebaseのサービスのイベントをトリガーに動作し、様々なタスクをこなすことができます。

##### Cloud Functionsのイベントトリガー
- Realtime Database Triggers
- Firebase Authentication Triggers
- Firebase Analytics Triggers
- Cloud Storage Triggers
- Cloud Pub/Sub Triggers
- HTTP Triggers




## How to use AccountKit and Firebase Auth?

### アーキテクチャ

概念図を示します。これを見ると複雑に見ますが一つ一つをおって行けば複雑な処理はしていません。
AccountKitから発行される`短期認証コード`を`Cloud Functions`にPostし`CustomToken`を取得します。
`CustomToken`をFirebase渡すことで、ログインが完了します。

<img width="1001" alt="スクリーンショット 2017-03-22 18.36.07.png" src="https://qiita-image-store.s3.amazonaws.com/0/80287/6011118b-0c00-0a73-a380-d7abbfa43a94.png">

### AccountKitの利用
#### AccountKitの認証方法

[AccountKit認証方法](https://developers.facebook.com/docs/accountkit/accesstokens)は以下の2つが用意されています。

- アクセストークン
- 認証コード（AuthorizationCode）

この二つの大きな違いは、セキュリティの鍵となるアクセストークンをどう取り扱うかっているかです。
__アクセストークンの認証はネイティブアプリケーションにしか許可されていません。__Webアプリケーションでは、アクセストークンの漏洩の恐れがあるため認証コードを利用するよう制限されています。ネイティブアプリで取得したアクセストークンの取り扱いは開発者に委ねられています。アクセストークンを利用する際は漏洩させないよう最新の注意が必要です。

※アクセストークンを持ってAccountKitのAPIにリクエストすると`ユーザーID` `電話番号` `メールアドレス`が取得できます。

<img width="660" alt="スクリーンショット 2017-03-22 17.57.37.png" src="https://qiita-image-store.s3.amazonaws.com/0/80287/21f9f99c-2966-3cb0-0783-038035f33d5e.png">


認証コードで認証を行う場合は、認証コードをサーバーに渡してアクセストークンをサーバー間通信で発行してもらう必要があります。

<img width="944" alt="スクリーンショット 2017-03-22 18.58.13.png" src="https://qiita-image-store.s3.amazonaws.com/0/80287/979be144-5a6f-4e1a-4664-6d417fcda311.png">


今回連携では、認証コードを利用します。
Firebase AuthのCustom Loginを利用するのでAccessTokenをクライアントを保持する理由がないためです。
認証コードを発行し、後の処理はCloud Functions（サーバー間通信）に任せます。

あとはクライアントで行うタスクは、レスポンスとして`CustomToken`を受け取りFirebaseAuthに渡すだけです。

Swiftのサンプルコードです。

```swift

// AccoutKitの初期化
let accountKit: AKFAccountKit = AKFAccountKit(responseType: .authorizationCode)

// AccountKitを起動して電話番号認証を行う
func login() {
    let inputStatus: String = UUID().uuidString
    let viewController: AKFViewController = self.accountKit.viewControllerForPhoneLogin(with: nil, state: inputStatus) as! AKFViewController
    prepareLoginViewController(viewController: viewController)
    self.present(viewController as! UIViewController, animated: true, completion: nil)
}

// AccountKitの準備
func prepareLoginViewController(viewController: AKFViewController) {
    viewController.delegate = self
    viewController.enableSendToFacebook = true    
    let locale: NSLocale = NSLocale.current as NSLocale
    if let countryCode: String = locale.countryCode {
        viewController.defaultCountryCode = countryCode
    }    
}

// MARK: - 

// responseを受け取ってログインする
func customLogin(_ response: AccountKitLoginResponse, block: ((FIRUser?, Error?) -> Void)?) {
    
    FIRAuth.auth()?.signIn(withCustomToken: response.customToken) { (firUser, error) in
        if let error = error {
            debugPrint(error)
            block?(nil, error)
            return
        }
        block?(firUser, error)
    }
}

```


### Firebase Authの利用

Firebase Authは複数のログイン方法を提供しています。
今回はCustom Loginを利用します。Custom Loginはすでに運用している既存サービスとFirebaseを結びつけることができるログインの方法です。Firebaseに`UserID`を渡すことで同じUserIDをFirebaseでも利用できるようになります。

<img width="961" alt="スクリーンショット 2017-03-22 19.07.34.png" src="https://qiita-image-store.s3.amazonaws.com/0/80287/e31df7da-3427-d5ac-936e-a7696b643441.png">

今回は既存サービスのDBを`AccountKit`、サーバーを`Cloud Functions`と見立てて[CustomTokenを発行](https://firebase.google.com/docs/auth/server/create-custom-tokens)します。

#### Cloud Functions

Cloud Functionsを利用できるようにしましょう。Cloud Functionsはnodejsで動作しています。必要なモジュールはnpmを使ってインストールすることができます。
まず、npmを使ってFirebaseのコマンドラインツールをインストールします。

```Shell
sudo npm install -g firebase-tools
```

Firebaseにログイン

```Shell
firebase login
```

Firebaseプロジェクトを作る

```Shell
firebase init functions
```


Firebaseプロジェクトをデプロイする

```Shell
firebase deploy
```

### firebase command

```

    auth:import [options] [dataFile]           import users into your Firebase project from a data file(.csv or .json)
    auth:export [options] [dataFile]           Export accounts from your Firebase project into a data file
    database:get [options] <path>              fetch and print JSON data at the specified path
    database:push [options] <path> [infile]    add a new JSON object to a list of data in your Firebase
    database:set [options] <path> [infile]     store JSON data at the specified path via STDIN, arg, or file
    database:remove [options] <path>           remove data from your Firebase at the specified path
    database:update [options] <path> [infile]  update some of the keys for the defined path in your Firebase
    database:profile [options]                 profile the Realtime Database and generate a usage report
    deploy [options]                           deploy code and assets to your Firebase project
    hosting:disable [options]                  stop serving web traffic to your Firebase Hosting site
    functions:log [options]                    read logs from deployed functions
    functions:config:clone [options]           clone environment config from another project
    functions:config:get [path]                fetch environment config stored at the given path
    functions:config:set [values...]           set environment config with key=value syntax
    functions:config:unset [keys...]           unset environment config at the specified path(s)
    help [command]                             display help information
    init [feature]                             setup a Firebase project in the current directory
    list                                       list the Firebases to which you have access
    login [options]                            log the CLI into Firebase
    login:ci [options]                         generate an access token for use in non-interactive environments
    logout                                     log the CLI out of Firebase
    open [link]                                quickly open a browser to relevant project resources
    serve [options]                            start a local server for your static assets
    tools:migrate [options]                    ensure your firebase.json format is up to date
    use [options] [alias_or_project_id]        set an active Firebase project for your working directory
```

環境変数をセットする

``` zsh
firebase functions:config:set my.debug=true
```

環境変数を取得する

``` zsh
firebase functions:config:get

// 結果
{
  "my": {
    "debug": "true"
  }
}
```

環境変数の削除

``` zsh
firebase functions:config:unset my
```


詳細は[Get started with Cloud Functions](https://firebase.google.com/docs/functions/get-started)を参考にしてください。

HelloWorldなどを参考にすると十分に理解できる内容です。[サンプルコード](https://github.com/firebase/functions-samples)も充実しているので、ほとんどのことはコピペで可能です。


#### Cloud Functionsのエミュレート

Cloud Functionsのデバッグはエミュレータを使います。まだアルファ版のため[問題](https://github.com/GoogleCloudPlatform/cloud-functions-emulator/issues)もありますが、基本動作の確認ではこれで十分です。
Goolgeから[cloud-functions-emulator](https://github.com/GoogleCloudPlatform/cloud-functions-emulator/)が提供されています。

エミュレータのインストール

``` Shell
sudo npm install -g @google-cloud/functions-emulator
```

※2017年3月22日現在で環境変数をセットしてもエミュレータから参照できませんでした。[すでにIssueがありました](https://github.com/GoogleCloudPlatform/cloud-functions-emulator/issues/74)

エミュレータの起動

``` zsh
functions start
```

エミュレータへデプロイ

``` zsh
functions deploy helloWorld --trigger-http
```

エミュレータのログを見る

``` zsh
functions logs read
```
※zshを利用の方は`functions`コマンドが競合して動作しない可能性があります。`functions-emulator`コマンドで代替えできるのこちらを利用するといいでしょう。

```javascript

'use strict';

const functions = require('firebase-functions');
const admin = require('firebase-admin');
const cors = require('cors')({origin: true});
const Request = require('request');
const Querystring = require('querystring');

// Config
let config = functions.config();

// Firebase account key
let serviceAccount;

let projectID = process.env.GCLOUD_PROJECT;
if (projectID == config.project.development) {
  serviceAccount = require('./firebase_projects/development.json');
} else if (projectID == config.project.production) {
  serviceAccount = require('./firebase_projects/production.json');
}

// Facebook AccountKit
let accountKit;

if (projectID == config.project.development) {
  accountKit = require('./accountkit/development.json');
} else if (projectID == config.project.production) {
  accountKit = require('./accountkit/production.json');
}

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  databaseURL: 'https://' + projectID + '.firebaseio.com'
});

exports.login = functions.https.onRequest((req, res) => {

  // parse request
  cors(req, res, () => {

    let appAccessToken = ['AA', accountKit.app_id, accountKit.app_secret].join('|');
    let params = {
      grant_type: 'authorization_code',
      code: req.body.code,
      access_token: appAccessToken
    };

    let tokenExchangeURL = accountKit.token_exchange_base_url + '?' + Querystring.stringify(params);

    // Get access token & account id
    Request.get({url: tokenExchangeURL, json: true}, (error, response, body) => {

      if (error) {
          res.status(500).json({
            errors: [{
              message: 'Could not get access token.'
            }]
          });
        return
      }
      let accessToken = body.access_token
      let id = body.id

      var meEndpointURL = accountKit.me_endpoint_base_url + '?access_token=' + accessToken;

      // Get account infomation
      Request.get({url: meEndpointURL, json: true }, (error, response, body) => {

        if (error) {
            res.status(500).json({
              errors: [{
                message: 'Could not get user information.'
              }]
            });
          return
        }

        let countryPrefix = body.phone.country_prefix
        let nationalNumber = body.phone.national_number
        let number = body.phone.number

        let additionalClaims = {
          country_prefix: countryPrefix,
          national_number: nationalNumber,
          number: number
        };

        // Get custom token for Firebase custom login
        admin.auth().createCustomToken(id, additionalClaims)
        .then(function(customToken) {
          // Send token back to client
          res.json({
            custom_token: customToken,
            account_id: id,
            access_token: accessToken,
            country_code: countryPrefix,
            national_number: nationalNumber,
            phone_number: number
          });
        })
        .catch(function(error) {
          console.log("Error creating custom token:", error);
          res.status(500).json({
            errors: [{
              message: 'Failed to get custom token.'
            }]
          });
        });
      });
    });
  });
})

```