Flutter + Firebase(Auth + Cloud Functions) でInstagramログインを実装

## 2019/11/21更新
- 2019/10/15以降のInstagram APIの新規利用申込みは停止したようです。 (https://www.instagram.com/developer/ )
- 代替となる[Instagram Basic Display API](https://developers.facebook.com/docs/instagram-basic-display-api/) は認証(ログイン)には使えず、ログインに使いたい場合は[Facebook login](https://developers.facebook.com/docs/facebook-login)を使ってくださいとのことでした  
@dameee さんありがとうございました！

# 概要
- Flutter+FirebaseでInstagramログインを実装しました
    - FirebaseAuthのドキュメントは[こちら(カスタムトークンを作成するパターン)](https://firebase.google.com/docs/auth/admin/create-custom-tokens?hl=ja)です
      - FacebookやTwitterと違い、InstagramについてはFirebase側で連携機構が用意されてない(2019/6/15現在)のでサーバを用意する必要があり、今回はCloud Functionsを使っています。
    - Instagramのドキュメントは[こちら(Server-side (Explicit) Flow)](https://www.instagram.com/developer/authentication/)です
- InstagramへのログインはWebViewを使います (端末にInstagramのアプリがインストールされていても使われません）


### 処理の流れ

1. アプリ内にWebViewでInstagraのログインページを表示してユーザにログインしてもらう
2. ログイン後にCloud Functionsのエンドポイントにcodeリクエストパラメータ付きでリダイレクトされる
3. Functions内でcodeを検証(Instagramと通信)してInstagramのユーザID等を取得
4. 3で取得した情報を元にFirebaseAuthのカスタムトークンを生成する
5. 4で生成したトークンを、Functionsのレスポンスとしてアプリに返す
6. 5で受け取ったトークンを使ってFirebaseAuthにログインする


# 前提
- FlutterにFirebaseを導入済み
    - see. https://flutter.dev/docs/development/data-and-backend/firebase
- Firebase Cloud Functionsの環境準備済み (今回はTypeScriptを使っています）
    - see. https://firebase.google.com/docs/functions/get-started?hl=ja
    - ダッシュボードの設定 > サービスアカウント から秘密鍵ファイルをDLしてfunctionsディレクトリ内に設置するのを忘れずに..

# 準備
- [InstagramのDeveloperページ](https://www.instagram.com/developer/)でClient登録し、Client ID, Client Secret を控えておく
  - Cloud Functionのendpointは Manage Client > Security の `Valid redirect URIs` に登録する
  - 検証に使うアカウントは、Manage Client > Sandbox から `Sandbox Users`としてアカウント名を登録する

# 実装

### 1. 必要なライブラリを追加

pubspec.yamlに以下を追加

```yaml
webview_flutter: ^0.3.9+1
```

### 2. ログインページ

1,6の処理です。
**JavaScriptChannelでカスタムトークンを受け取る部分がポイントです。**

```dart
import 'dart:async';
import 'dart:convert';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

// Instagram　Developerで発行されたID
const clientId = 'xxxxx';
// Instagram login後にredirectされるURI 今回はCloud Functionsのendpoint
const redirectUri = 'https://xxxxxx.cloudfunctions.net/instagram_auth';
// WebViewで開くInstagramのloginページURL
const loginUrl =
    'https://api.instagram.com/oauth/authorize/?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code';

class LoginPage extends StatelessWidget {
  final Completer<WebViewController> _controller =
      Completer<WebViewController>();
  final FirebaseAuth _auth = FirebaseAuth.instance;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: WebView(
            initialUrl: loginUrl,
            javascriptMode: JavascriptMode.unrestricted,
            onWebViewCreated: (WebViewController webViewController) {
              _controller.complete(webViewController);
            },
            javascriptChannels: [
              // firebaseのtokenを受け取ってログインさせる
              // nameに指定したfunctionがWebView側で呼ばれると、その引数がmessageとして渡ってくる
              JavascriptChannel(
                  name: 'customTokenData',
                  onMessageReceived: (JavascriptMessage message) {
                    final tokenData = jsonDecode(message.message);
                    FirebaseUser fireUser = await _auth.signInWithCustomToken(
                        token: tokenData['token']);
                    // ログイン完了！ 別ページに遷移させるなどする
                  })
            ].toSet()));
  }
}

```

### 3. FirebaseAuthのカスタムトークンを生成するサーバ側処理

2~5の処理です。
渡ってきたInstagramのコードを検証し、Firebaseのカスタムトークンを発行してアプリに返します。

```typescript
import * as crypto from 'crypto'
import * as fireAdmin from 'firebase-admin'
import * as functions from 'firebase-functions'
// 以下requireを使ってる箇所は、本当はimportで行いたかったけどエラーが出たりでrequireにした。
// TypeScript力が足りない。。
const serviceAccount = require('/path/to/service-account-file.json')
fireAdmin.initializeApp({
    credential: admin.credential.cert(serviceAccount)
})
const Instagram = require('node-instagram').default
const print = require('pretty-print');

const instagram = new Instagram({
    clientId: 'xxxxxx',
    clientSecret: 'xxxxx',
});

const idSalt = 'aaaaaa' // 適当な乱数
const _instaIdToAppId = (instaUserId: string) => {
    return crypto.createHash('sha1').update(`${idSalt}:${instaUserId}`).digest('base64')
}

const _errorResponse = (response: functions.Response) => {
    const html = `
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width,initial-scale=1">
        <title>example app</title>
    </head>
    <body>
        <p>エラーが発生しました。お手数ですがもう一度やり直してください。</p>
    </body>
</html>
`
    response.send(html)
}

// instagramログイン後にredirectされて来る
//   instagramのcodeを検証し、firebaseのカスタムトークンを作成して返す
export const instagram_auth = functions.region('asia-northeast1').https.onRequest(async (request, response) => {
    const code: String = request.query.code
    // 以下のURIはInstagram Developer側にも登録する
    const redirectUri = 'https://xxxxxx.cloudfunctions.net/instagram_auth'
    let messageToApp: String
    try {
        const res = await instagram.authorizeUser(code, redirectUri)
        // アプリ側のユーザID 
        // InstagramのユーザID+乱数をハッシュ化したものをアプリの(FireAuthの)IDとする
        // (InstagramのユーザIDをそのまま使うのはアプリにユーザ同士の交流機能があったりでユーザIDが露出する場合によろしくないので)
        const appUserId = _instaIdToAppId(res.user.id)
        const token = await fireAdmin.auth().createCustomToken(appUserId)
        messageToApp = JSON.stringify({
            token: token,
            // 以下は無くても良いけどアプリの必要に応じて返す
            name: res.user.username,
            avatarURL: res.user.profile_picture,
        })
    } catch (e) {
        console.log(`[ERROR] instagram auth:`)
        print(e)
        _errorResponse(response)
        return
    }
    // CustomTokenDataというJavascriptChannelを通してアプリ側にデータを渡す
    const html = `
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width,initial-scale=1">
        <script>CustomTokenData.postMessage('${messageToApp}');</script>
        <title>example app</title>
    </head>
    <body>
        <p>ログインしました。しばらくお待ち下さい...</p>
    </body>
</html>
`
    response.send(html);
})

```

### 感想
今回はじめてCloud Functionsを使いましたが、とてもお手軽でいいですね！