komeの備忘録

東大院卒外資ITエンジニアの技術ブログ

iOS・Web間でFirebase Authを共有する方法

はじめに

ネイティブアプリ開発をしていて、部分的にwebview実装にする場合は多い。一方でその際にセッション共有など、ログイン状態をネイティブアプリとウェブの間で引き継ぐ必要が出てくる場合がある。

webview session share などでググると、そこそこ記事は出てくる↓

qiita.com

qiita.com

qiita.com

しかし、Firebase Authを使ってユーザ認証をしている場合にどうやるのかというのがなかなかなかったので、今回はその部分をやってみた。

手順としては、以下の記事とほぼ同じになる。

stackoverflow.com

概要

f:id:kaz02251994:20181107152018p:plain

構成

  • iOS: Swift 4.2
  • Web: Vue.js 2.5
  • Backend: Node Express 4.16
  • BaaS: Firebase admin SDK 6.0, Firebase iOS SDK 4.13, Firebase web SDK 5.2

目的

iOS側でFirebase Authを使ってログイン済みの状態で、同じくFirebase Authを使って認証を行うweb app (Vue.js)を、iOS側と同じアカウントでログインした状態でwebviewしたい

セッション共有をするわけではなく、Firebase Authのcustom token loginをweb側クライアントサイドで行うというもの。 Swift側での認証方法がなんであれ(Firebase Authはパスワード認証のほかSMS認証やOAuthに対応している)web側でのログインには影響がない。

手順

1. idTokenの発行

最初のステップは、サーバサイドでFirebase AuthのCustomTokenを発行することだが、その発行にはFirebaseでのuid (user id)が必要となる。そのままuidをサーバサイドに送るようにすると、uidさえわかれば誰でもCustomTokenが発行できる状態になってしまう。uidが、Firebase Authを使ってログインしているユーザから送信されていることを確認するため、uidをトークン化(idToken)する機能がFirebase Auth には実装されている。

このidTokenをサーバへ送り、サーバサイドでこのトークンが正しいものか検証をした上で、ログインのためのCustomTokenを発行する。まずはそのidTokenの発行の部分の仕組みを実装する。

Verify ID Tokens  |  Firebase

iOS側で以下の実装をすると、idTokenが発行される。

let currentUser = Auth.auth().currentUser
currentUser?.getIDTokenForcingRefresh(true) { idToken, error in
  if let error = error {
    // error handling
    return
 }
 // continued ...

このidTokenを、バックエンドに送る。 Alamofireを使ってる場合はこんな感じになる。

let url = // your backend URL
let params = ["idtoken": idToken]
Alamofire.request(url, method: .post, parameters: params)
  .validate(statusCode: 200..<300)
  .responseJSON { response in
  // continued ...

2. idTokenの検証とcustomTokenの発行

ここからはサーバサイドの処理になる。Expressを使う場合は、以下のような実装でiOSからのidTokenを受け付ける。
(※API認証、エラーハンドリング等々余分な部分は省略した。)

const express = require("express");
const app = express();
const bodyParser = require('body-parser');
const admin = require('firebase-admin');

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

const defaultApp = admin.initializeApp({
  credential: admin.credential.cert(/* your firebase admin serviceAccount */)
})

app.post("/", (req, res) => {
  let idToken = req.body.token;

  admin.auth().verifyIdToken(idToken)
    .then(function(decodedToken) {
      let uid = decodedToken.uid;
      const expiresIn = 60 * 60 * 24 * 5 * 1000;

      admin.auth().createCustomToken(uid)
        .then(function(customToken) {
          const options = {maxAge: expiresIn, httpOnly: true, secure: true};
          res.cookie('customtoken', customToken, options);
          res.end(JSON.stringify({status: 'success'}))
        })
        .catch(function(error) {
          // error handling
        });
    });
});

受け取ったidTokenは、admin.auth().verifyIdToken()関数で検証できる。検証が成功すると、返り値はデコードされたuidになっている。

  admin.auth().verifyIdToken(idToken)
    .then(function(decodedToken) {
    ....(処理)
    });

デコードされたuidを使って、customTokenを発行することができる。admin.auth().createCustomToken(uid)関数を使う。そこで返却されたcustomTokenを使って、Firebase Authによるログインが可能となる。これをiOS側に返却する。今回はcookieにcustomTokenを持たせ、返却している。

      admin.auth().createCustomToken(uid)
        .then(function(customToken) {
          const options = {maxAge: expiresIn, httpOnly: true, secure: true};
          res.cookie('customtoken', customToken, options);
          res.end(JSON.stringify({status: 'success'}))
        })
        .catch(function(error) {
          // error handling
        });

3. customTokenをWKWebViewにセットし、初期化する

続いてはiOS側の処理になる。バックエンドから返却されたcustomTokenを、webViewに反映させる。この部分は、以下のリンクをおおいに参考にさせてもらった。
qiita.com

// after fetch customToken
setWkWebView(token)
wkWebViewLoad(url: url, token: token)

func setWkWebView(_ token: String) {
    let userContentController = WKUserContentController()
    let cookieScript = WKUserScript(source: "document.cookie='customtoken=\(token);path=/';",
                                    injectionTime: .atDocumentStart,
                                    forMainFrameOnly: true)
    userContentController.addUserScript(cookieScript)
    let wkWebViewConfig = WKWebViewConfiguration()
    wkWebViewConfig.userContentController = userContentController
    self.webView = WKWebView(frame: self.view.frame, configuration: wkWebViewConfig)
    self.webView.navigationDelegate = self
    self.view.addSubview(self.webView)
}
    
func wkWebViewLoad(url: URL, token: String) {
    let cookieStorage = HTTPCookieStorage.shared
    let cookieHeaderField = ["Set-Cookie": "customtoken="+token]
    let cookies = HTTPCookie.cookies(withResponseHeaderFields: cookieHeaderField, for: url)
    cookieStorage.setCookies(cookies, for: url, mainDocumentURL: url)
        
    let request = URLRequest(url: url, cachePolicy: URLRequest.CachePolicy.useProtocolCachePolicy, timeoutInterval: 10.0)
        
    self.webView.load(request)
}

setWkWebView()関数では、サーバから返却されたcustomTokenを引数にとっている。customTokenをWKWebViewでのページリクエストのcookieに含めて送信したいため、WKUserScript()でjavascriptによってcookieを設定している。

func setWkWebView(_ token: String) {
    let userContentController = WKUserContentController()
    let cookieScript = WKUserScript(source: "document.cookie='customtoken=\(token);path=/';",
                                    injectionTime: .atDocumentStart,
                                    forMainFrameOnly: true)
    userContentController.addUserScript(cookieScript)
    let wkWebViewConfig = WKWebViewConfiguration()
    wkWebViewConfig.userContentController = userContentController
    self.webView = WKWebView(frame: self.view.frame, configuration: wkWebViewConfig)
    self.webView.navigationDelegate = self
    self.view.addSubview(self.webView)
}

続いてwkWebViewLoad()関数では、cookieの永続化のためにcookieStorageへcustomTokenを記録した後、リクエストを実行している。

func wkWebViewLoad(url: URL, token: String) {
    let cookieStorage = HTTPCookieStorage.shared
    let cookieHeaderField = ["Set-Cookie": "customtoken="+token]
    let cookies = HTTPCookie.cookies(withResponseHeaderFields: cookieHeaderField, for: url)
    cookieStorage.setCookies(cookies, for: url, mainDocumentURL: url)
        
    let request = URLRequest(url: url, cachePolicy: URLRequest.CachePolicy.useProtocolCachePolicy, timeoutInterval: 10.0)
        
    self.webView.load(request)
}

4. customTokenでログイン

最後は、web App上での処理になる。 ログイン処理を行う部分で、以下の実装をする。

let user = await firebase.auth().signInWithCustomToken(token).then( user => {
  // Log in !!
}).catch(error => {
 // error handling
})

適宜cookieのパース等が必要である。 今回はVue.jsでVue-routerを使っていたため、routerの処理内でこれを行うように実装した。

import Vue from 'vue'
import Router from 'vue-router'
import vueCookies from 'vue-cookies'
import async from 'async'
import firebase from 'firebase'

Vue.use(Router)
Vue.use(vueCookies)

let router = new Router({
    routes: [
        {
            path: '/',
            name: 'main',
            component: /* your component */,
            meta: { requiresAuth: true }
        },
        {
            path: '/signin',
            name: 'Signin',
            component: /* your signin component */
        }
    ]
})

router.beforeEach(async (to, from, next) => {
  let requiresAuth = to.matched.some(record => record.meta.requiresAuth)
  let currentUser = firebase.auth().currentUser
  if (requiresAuth) {
    if (!currentUser) { // not yet firebase login
      if (Vue.cookies.get('customtoken')) {
        let token = Vue.cookies.get('customtoken')
        await firebase.auth().signInWithCustomToken(token).catch(err => {
          // error handling
          next({path: "/signin"})
        })
        next()
      } else {
        // no token provided
        // continue process ...
        // ...
})

まとめ

Firebase Authの情報をiOS・web間で引き継ぐ方法を紹介した。

Firebase Auth のadmin SDKには、以下のようにセッション管理に関するものも一応ある。しかしこれはあくまで外部連携サービスがセッション管理を行なっている場合に、それに準拠させるためのものらしい。このセッションでFirebase Authの状態を引き継ぐことはできないようであった。
セッション Cookie を管理する  |  Firebase

そのほか、まだベータ版のようだが、Service Workerなる実装もFirebase Authにはあるらしい。今後に期待。
Service Worker によるセッション管理  |  Firebase

参考にしたサイト

(C) komee.org