Expo で Firebase Authentication を利用して Facebook, Google, Github, Twitter のアカウントでログインする

## あらすじ

Firebase Authentication を WEB で実装した時はとても簡単だったのですが、React Native で実装しようとすると大変だったので記事にします。

## 対象読者

この記事では以下の経験があることを前提としているので分かりづらい箇所があると思います。

* SNS の開発者画面にログインして必要な情報を取得できる
* Firebase Authentication を使った開発経験がある
* Expo を使った開発経験がある

## Server

```
mkdir server && cd $_
yarn init
yarn add express body-parser node-fetch oauth-1.0a qa crypto-js
touch server/auth-server.js
```

create `server/auth-server.js`

```javascript
const express = require('express')
const bodyParser = require('body-parser');
const fetch = require('node-fetch');

const OAuth = require('oauth-1.0a');
const qs = require('qs')
const HmacSHA1 = require('crypto-js/hmac-sha1')
const Base64 = require('crypto-js/enc-base64')

const port = process.env.PORT || 3000

const config = {
  GOOGLE: {
    CLIENT_ID: "",
    CLIENT_SECRET: "",
  },
  GITHUB: {
    CLIENT_ID: "",
    CLIENT_SECRET: "",
  },
  TWITTER: {
    CLIENT_ID: "",
    CLIENT_SECRET: "",
  }
}

const app = express();

app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

app.post('/auth/google', async (req, res) => {

  async function createTokenWithGoogleCode(code, redirect_uri) {

    const url = `https://www.googleapis.com/oauth2/v4/token`
    const res = await fetch(url, {
      method: 'POST',
      body: JSON.stringify({
        code,
        client_id: config.GOOGLE.CLIENT_ID,
        client_secret: config.GOOGLE.CLIENT_SECRET,
        redirect_uri,
        grant_type: 'authorization_code'
      })
    });
    
    return await res.json()
  }

  return res.json(await createTokenWithGoogleCode(req.body.code, req.body.redirect_uri))
});

app.post('/auth/github', async (req, res) => {

  async function createTokenWithGithubCode(code) {
    const url =
      `https://github.com/login/oauth/access_token` +
      `?client_id=${config.GITHUB.CLIENT_ID}` +
      `&client_secret=${config.GITHUB.CLIENT_SECRET}` +
      `&code=${code}`;

    const res = await fetch(url, {
      method: 'POST',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
    });
    return await res.json()
  }

  return res.json(await createTokenWithGithubCode(req.body.code))
});

app.post('/auth/twitter/request_token', async (req, res) => {

  const { redirect_uri } = req.body

  const oauth = OAuth({
    consumer: {
      key: config.TWITTER.CLIENT_ID,
      secret: config.TWITTER.CLIENT_SECRET,
    },
    signature_method: 'HMAC-SHA1',
    hash_function: (baseString, key) => Base64.stringify(HmacSHA1(baseString, key))
  })

  const request_data = {
    url: 'https://api.twitter.com/oauth/request_token',
    method: 'POST',
    data: { 
      oauth_callback: redirect_uri,
    }
  };
 
  const response = await fetch(request_data.url, {
    method: request_data.method,
    headers: oauth.toHeader(oauth.authorize(request_data))
  })

  const text = await response.text();
  return res.json(qs.parse(text))
});

app.post('/auth/twitter/access_token', async (req, res) => {

  const { oauth_token, oauth_token_secret, oauth_verifier } = req.body

  const oauth = OAuth({
    consumer: {
      key: config.TWITTER.CLIENT_ID,
      secret: config.TWITTER.CLIENT_SECRET,
    },
    signature_method: 'HMAC-SHA1',
    hash_function: (baseString, key) => Base64.stringify(HmacSHA1(baseString, key))
  })

  const request_data = {
    url: 'https://api.twitter.com/oauth/access_token',
    method: 'POST',
    data: {
      oauth_verifier,  
    },
  }
  
  const headers = oauth.toHeader(oauth.authorize(request_data, {key: oauth_token, secret: oauth_token_secret}))
 
  const response = await fetch(request_data.url, {
    method: request_data.method,
    data: request_data.data,
    headers
  })
  
  if (response.status !== 200) {
    res.status = response.status
    return res.json({message: "something wrong"})
  }
  const text = await response.text();
  return res.json(qs.parse(text))
})

if (!module.parent) {
  app.listen(3000, () => {
    console.log('Example app listening on port 3000!');
  });
}

module.exports = app;
```

## Expo

```
expo init expo && cd $_
yarn add firebase
```

edit `expo/App.js`

```javascript

import React from 'react'
import { StyleSheet, Text, Button, View } from 'react-native'
import { AuthSession, Facebook } from 'expo';
import firebase from 'firebase';

const AUTH_BASE_URL = "http://localhost:3000"
const REDIRECT_URL = AuthSession.getRedirectUrl();
const FACEBOOK_APP_ID = ""
const GOOGLE_CLIENT_ID = ""
const GITHUB_CLIENT_ID = ""

if (!firebase.apps.length) {
  firebase.initializeApp({
    apiKey: "",
    authDomain: "",
    databaseURL: "",
    projectId: "",
    storageBucket: "",
    messagingSenderId: ""
  })
}

async function createTokenWithCode(provider, code) {
  const url = `${AUTH_BASE_URL}/auth/${provider}/`
  console.log(url)
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      code,
      redirect_uri: REDIRECT_URL // Google で必要
    })
  })
  const json = await res.json();
  console.log(json)
  return json
}

async function getTwitterRequestToken() {
  const url = `${AUTH_BASE_URL}/auth/twitter/request_token`
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      redirect_uri: REDIRECT_URL
    })
  });
  return await res.json();
}

async function getTwitterAccessToken(params) {
  const { oauth_token, oauth_token_secret, oauth_verifier } = params
  const url = `${AUTH_BASE_URL}/auth/twitter/access_token`
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ oauth_token, oauth_token_secret, oauth_verifier })
  });
  return await res.json();
}

export default class App extends React.Component {
  
  constructor(props) {
    super(props)
    this.state = {
      user: null
    }
    firebase.auth().onAuthStateChanged((user) => {
      if (user) {
        this.setState({ user: user.toJSON() })
        console.log(user)
      } else {
        this.setState({ user: null })
      }
    })
  }

  handleLogout() { 
    firebase.auth().signOut()
  }

  async handleFacebookLogin() {

    const { type, token } = await Facebook.logInWithReadPermissionsAsync(
      FACEBOOK_APP_ID,
      { permissions: ['public_profile'] }
    )

    if (type === 'success') {
      const credential = firebase.auth.FacebookAuthProvider.credential(token)
      firebase.auth().signInAndRetrieveDataWithCredential(credential)
    }
  }
  
  async handleGoogleLogin() {
    const result = await AuthSession.startAsync({
      authUrl:
        `https://accounts.google.com/o/oauth2/v2/auth?` +
        `&client_id=${GOOGLE_CLIENT_ID}` +
        `&redirect_uri=${REDIRECT_URL}` +
        `&response_type=code` +
        `&access_type=offline` +
        `&scope=profile`,
    });

    const { id_token } = await createTokenWithCode('google', result.params.code)

    var credential = firebase.auth.GoogleAuthProvider.credential(id_token);
    firebase.auth().signInAndRetrieveDataWithCredential(credential);
  }

  async handleGithubLogin() {

    const { params } = await AuthSession.startAsync({
      authUrl: 
        `https://github.com/login/oauth/authorize` +
        `?client_id=${GITHUB_CLIENT_ID}` +
        `&redirect_uri=${encodeURIComponent(REDIRECT_URL)}` +
        `&scope=user`
    });

    const { access_token } = await createTokenWithCode('github', params.code);

    const credential = firebase.auth.GithubAuthProvider.credential(access_token);
    firebase.auth().signInAndRetrieveDataWithCredential(credential);
  }

  async handleTwitterLogin() {
    const { oauth_token, oauth_token_secret } = await getTwitterRequestToken()

    const { params }  = await AuthSession.startAsync({
      authUrl: `https://api.twitter.com/oauth/authenticate?oauth_token=${oauth_token}`
    });

    const oauth_verifier = params.oauth_verifier
    const result = await getTwitterAccessToken({oauth_token, oauth_token_secret, oauth_verifier})

    const credential = firebase.auth.TwitterAuthProvider.credential(
      result.oauth_token, 
      result.oauth_token_secret
    )
    firebase.auth().signInAndRetrieveDataWithCredential(credential);
  }

  render() {
    return (
      <View style={styles.container}>
        <Text>Firebase Authentication Exampe</Text>
        <Button onPress={this.handleFacebookLogin} title="Facebook" />
        <Button onPress={this.handleGoogleLogin} title="Google" />
        <Button onPress={this.handleGithubLogin} title="Github" />
        <Button onPress={this.handleTwitterLogin} title="Twitter" />
        <Button onPress={this.handleLogout} title="Logout" />
        <Text>displayName: {this.state.user && this.state.user.displayName}</Text>
        <Text>providerId: {this.state.user && this.state.user.providerData[0].providerId}</Text>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
});
```

## 解説

### Facebook

Facebook で Firebase Authentication を使う場合は Expo 側で Facebook.logInWithReadPermissionsAsync という関数が用意されており、これをつかうとサーバーサイドでの処理を書かなくても良いので容易に実装が可能です。

### Google と Github

handleGoogleLogin も handleGithubLogin はほとんど同じ処理をしています。こんな感じです。

* React Native「ログインするんだったらボタンを押してね。プロバイダーの画面に線維するからその認証画面でログインしてね。」
* User「ボタンを押して、認証画面でログインしました。」
* Provider「認証しました。xxのスコープを認可した code を発行したので redirect_uri に一旦お返しします」
* AuthSession.getRedirectUrl「あ、redirect_uri だけど publish されてなかったから https://auth.expo.io/@your-username/your-app-slug にしておいたよ」
* auth.expo.io「あー、あの端末のことか、 http://127.0.0.1:19000/ でしょ？いってらっしゃい」
* React Native「お、帰ってきたね。Provider から認可コードを受け取ってきましたね。それじゃあアクセストークンに交換しましょう。秘密鍵いるんだけどここで秘密鍵つかうと危ないからサーバーサイドにやってもらいます。」
* Node.js「OK、承認コードをアクセストークンに交換してもらったよ」
* React Native 「じゃ、あとは Firebase にお任せします」
* Firebase 「あいよ」

ちなみに AuthSession.getRedirectUrl は publish されている場合は yourapp:// みたいなスキーマを返します

Google と Github に対して OAuth の認可を求めるのに Browser Base 方式を利用しています。 Expo Doc に書かれている Google のログイン方法とは異なる処理ですが、
今回は色々な Provider でも対応する必要があるケースを想定していたのでこちらの方式にしました。

CLIENT_SECRET を React Native に記述できればサーバーサイドは不要だと思いますが Expo Doc にも `Never put any secret keys inside of your app, there is no secure way to do this!` と書かれていますので止めたほうが良いでしょう。

`AuthSession.startAsync` はとても便利な関数で、WEB Browser で各プロバイダーの認証画面にアクセスして、処理が成功したらその cookie を端末のブラウザにシェアします。この機構の恩恵として、すでに端末のブラウザでログイン済みの場合は、ID, PW の入力が省けます。

そして `AuthSession.getRedirectUrl()` は開発者は Expo をいろんなネットワーク上で動作させる事を前提としているのですが iOS で開発版アプリの配布ができなくなっているのであんまり恩恵がないかもしれません。

### Twitter

Twitter のアクセストークンは Oauth1.0 方式で取得します。この為 Google, Github とは少し処理が煩雑になっています。OAuth1.0 では署名の処理が複雑すぎてバグが発生しやすい問題があるので素直にライブラリを使った方が良いと思います。

認証画面を表示する段階で CLIENT_SECRET が必要になっているのでリクエストトークンとアクセストークンの取得をサーバーサイドで実装しています。

### 注意点

`expo/app.json` の `expo.slag` の設定が `AuthSession.getRedirectUrl` に関与します。その URL を各プロバイダーの設定画面で有効な callback として登録する必要があるので最初に適切なネーミングを決めておくと良いでしょう。

## まとめ

というわけで React Native で主要 SNS をログインする方法を学んだのですが意外と手間がかかる処理でした。もしかしたらもっと良い方法があるのかもしれませんのでぜひともマサカリを投げていただければとお見ます。

それから最後に OAuth　について、一番わかりやすかった記事を紹介します。お陰様で無事完遂することができました。ありがとうございます。

https://murashun.jp/blog/20150920-01.html