Firebase Authenticationを使ってFlutter製アプリにYahoo! JAPAN IDでログインしてみる

  • このエントリーをはてなブックマークに追加
Yahoo! JAPAN Tech Advent Calendar 2018の16日目の記事です。一覧はこちら

こんにちは。
IDソリューション本部の都筑(@kazuki229_dev)です。
新卒3年目で普段はYahoo! ID連携のサーバーサイド、iOSのSDKの開発などを担当しています。
今回は個人的に最近興味のあるFlutterと、Firebaseを用いたアプリケーション上で、Yahoo! ID連携を行う方法を考えてみたいと思います。

Flutterとは?

Flutterとは、単一のコードでiOSとAndroidのネーティブアプリを開発できる、クロスプラットフォームのフレームワークです。
ホットリロード機能によって高速な開発が可能になり、独自のレンダー実装により高速な描画、柔軟なデザインを実現可能とし、ネーティブとほぼ変わらないパフォーマンスを出せるそうです。
FlutterはGoogleによって開発されており、開発言語はDart言語となっています。

Firebaseとは?

FirebaseとはGoogleが提供するmBaaS(mobile Backend as a Service)です。
Firebaseにはモバイルアプリ開発を手助けする便利な機能がたくさんあります。
今回はYahoo! ID 連携をFirebase上で行うために、そのたくさんの機能の中のFirebase Authenticationというものを利用します。

Firebase Authentication

Firebase Authenticationはその名の通り、ユーザーの認証に関する機能を提供しています。
メールアドレスとパスワードの組み合わせによる認証、電話番号ログインなどの基本的な認証方法に加えて、GoogleやFacebookなどのID プロバイダーとのID連携も行うことが可能です。
Firebaseが公式にサポートしているID プロバイダーはGoogle、Facebook、Twitter、Githubの4種類です。
Firebase Authenticationを利用することによって、開発者が独自に認証基盤を構築する必要がなく、パスワードや電話番号など取り扱いが難しいサービス利用者の情報を自身で管理せずに済みます。
またID プロバイダーとのID連携を採用することで、自サービスのためのパスワードを新たにユーザーが覚えることなく、多くのユーザーが利用している各プロバイダーのIDを利用することが可能となるため、モバイルアプリのUX向上にもつながります。

しかし、残念なことにYahoo! JAPANはFirebase Authenticationに公式にサポートされているID プロバイダーではありません。

Firebaseにはそのような公式サポート外のID プロバイダーとID連携するための仕組みがあります。
このようなFirebaseが公式サポート外のID プロバイダーのことを今回はカスタム認証システムと本記事では定義します。
その仕組みを利用してYahoo! JAPAN IDとの連携を実装してみたいと思います。

カスタム認証システムを使用したFirebase認証

公式にサポートされているID プロバイダーを利用してFirebase認証を行う場合、各プロバイダーが発行したID TokenやAccess Tokenを直接Firebaseにわたして認証できます。
カスタム認証システムを使用してFirebase認証を行う場合、各カスタム認証システムが発行したID TokenやAccess Tokenを直接Firebaseにわたしての認証はできません。
その代わりにCustom TokenというTokenを利用します。Custom Tokenとはカスタム認証システムや、カスタム認証システムでの認証結果を受け入れたサーバーがFirebaseに対してその認証結果をFirebaseに示すためのTokenで、JSON Web Token(以下JWTと記載します)の形式となっています。
バックエンドサーバーで発行したCustom Tokenをネーティブアプリのクライアントに返却し、クライアントはこのCustom Tokenを使ってFirebaseにログインします。

Custom Tokenの発行方法や、Custom Tokenを用いたログイン方法については後ほど記載します。

実装

ここから実際にYahoo! ID連携を利用したFirebase認証を実装していきます。

実装概要

今回は下記のようなフローで実装を進めます。

  1. FlutterでYahoo! ID連携を行う
  2. Yahoo! JAPANが発行したTokenをバックエンドサーバーに送信し、Custom Tokenを発行する
  3. Custom Tokenを用いてFirebaseにログインする

事前準備

実装に入る前に、必要な準備をまとめます。

Yahoo! JAPAN上での設定

Yahoo! ID連携の機能を利用するためには、Yahoo!デベロッパーネットワークでアプリケーションを登録する必要があります。登録手順はYahoo!デベロッパーネットワークに記載しておりますので、そちらをご参照ください。
今回はネーティブアプリでの実装ですが、Authorization Codeフローを用いてYahoo! JAPANからID Tokenの取得をするために、サーバーサイド用のClient IDを取得します。

結果としてClient IDとシークレットが発行されます。また、この時点でコールバックURLも登録をしておきます。

Firebase上での設定

今回はFirebaseの機能のうちFirebase AuthenticationとCloud Functionsを利用します。
Cloud Functionsの処理内で外部のAPIへのリクエストをする場合には有料版のプランを選択しなければならないのでご注意ください。

先ほどYahoo!デベロッパーネットワークで取得したClient IDとシークレット、コールバックURLをFirebaseの環境変数から取得できるように事前に設定しておきます。

FlutterでYahoo! ID連携を実装

まずはFlutterアプリでYahoo! ID連携を実装していきたいと思います。
Yahoo! ID連携はOpenID Connectの仕様に準拠しています。
したがって、標準仕様に従って実装されているOSSクライアントライブラリを利用することが可能です。
FlutterでもいくつかのOpenID Connectのクライアントライブラリがありましたが、今回はAppAuthというライブラリを利用したいと思います。

ネーティブアプリでのOAuth 2.0実装のベストプラクティスがRFC8252でまとめられています。
このベストプラクティスに沿って実装されたOSSのクライアントライブラリがAppAuthです。
AppAuthにはiOSとAndroidのライブラリが提供されています。

このベストプラクティスでは下記のような実装方法が推奨されています。

  • ASAuthenticationSessionやChrome Custom Tabsなどのアプリ内ブラウザタブや、デフォルトブラウザの利用
  • Proof Key for Code Exchange by OAuth Public Clients(PKCE)の利用
  • Authorization Codeフローの利用
    例えばアプリ内ブラウザタブの利用などは、特にiOSの場合はOSバージョンによって実装方法が異なるため広範囲のバージョンをサポートする場合などに実装が煩雑になります。
    上記のベストプラクティスを簡単に実装するために、iOSとAndroidでOpenID ConnectやOAuthのRelying Party(OpenID ConnectやOAuthの導入先のクライアントのこと。RP)を実装する場合はAppAuthの利用を検討してみると良いかと思います。

今回はFlutterの実装ですが、FlutterにはiOS・Androidのネーティブの処理を呼び出す機能があるためそちらを利用して、このAppAuthの処理を呼び出します。

iOS・Androidのライブラリ呼び出し

FlutterではFlutterのコードから直接iOS・Androidのネーティブの処理を呼び出すplatform channelsという機能があります。
今回はこの機能を利用して、AppAuthライブラリを呼び出し、iOS・AndroidでAuthorization Codeの取得まで実装してみたいと思います。

AuthLibクラスのfetchAuthorizationCodeというメソッドでString型のAuthorization Codeを返却します。
Flutterのクラスとして、まずAuthLibというクラスを定義します。
このクラスにfetchAuthorizationCodeというメソッドを定義し、その中でinvokeMethodにfetchAuthorizationCodeという文字列をわたしてを呼び出します。
この処理により、各プラットフォームのコードが呼び出されます。

import 'dart:async';
import 'package:flutter/services.dart';

class AuthLib {
  static const MethodChannel _channel = const MethodChannel('auth_lib');

  Future<String> fetchAuthorizationCode() async {
    try {
        final authorizationCode = await _channel.invokeMethod('fetchAuthorizationCode');
        return authorizationCode;
    } on PlatformException catch (e) {
        // error handling
    }
  }
}

iOSの場合

iOSの場合は、すでに存在するAppDelegate.swiftに処理を追加していきます。
Flutter側でinvokeMethodを呼び出すと、application:didFinishLaunchingWithOptions:の中のFlutterMethodChannelのsetMethodCallHandlerのコールバックが呼び出されます。
この中で指定されたメソッド名ごとに処理を分岐させます。今回はfetchAuthorizationCodeが呼び出されたら自身のfetchAuthorizationCodeを呼び出します。

その後はAppAuthのライブラリを利用してAuthorization Codeを取得します。
まずはOpenID Configurationエンドポイントにリクエストし、各エンドポイントのURIやサポート機能を確認します。
その後、Authorization Codeフローでリクエストを行うため、responseTypeの引数にOIDResponseTypeCodeを指定します。
これでリクエストを行い成功するとコールバックでAuthorization Codeが返却されるので、これをFlutterResultの引数にわたして呼び出します。
これをもってFlutterにAuthorization Codeがわたります。

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    var currentAuthorizationFlow: OIDExternalUserAgentSession?

    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?
        ) -> Bool {

        let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
        let authLibChannel = FlutterMethodChannel(name: "auth_lib", binaryMessenger: controller)

        authLibChannel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in

            guard call.method == "fetchAuthorizationCode" else {
                result(FlutterMethodNotImplemented)
                return
            }
            // コールされたメソッド名がfetchAuthorizationCodeだったら下記のメソッドを呼び出す
            self.fetchAuthorizationCode(viewController: controller, result: result)
        }

        GeneratedPluginRegistrant.register(with: self)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }

     override func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool {

        if let authorizationFlow = self.currentAuthorizationFlow, authorizationFlow.resumeExternalUserAgentFlow(with: url) {
            self.currentAuthorizationFlow = nil
            return true
        }

        return false
    }

    private func fetchAuthorizationCode(viewController: UIViewController, result: @escaping FlutterResult) {

        // OpenID Configurationエンドポイントにリクエストし、各エンドポイントのURIやサポート機能を確認
        OIDAuthorizationService.discoverConfiguration(forIssuer: URL(string: "https://auth.login.yahoo.co.jp/yconnect/v2")!) { (config: OIDServiceConfiguration?, error: Error?) in
            guard let config = config else {
                // error handling
                return
            }

            // Authorization Codeフローでのリクエストを生成
            let request = OIDAuthorizationRequest(
                configuration: config,
                clientId: <登録したClient ID>,
                scopes: [OIDScopeOpenID, OIDScopeProfile],
                redirectURL: URL(string: <登録したコールバックURL>)!,
                responseType: OIDResponseTypeCode, // Authorization Codeフローなのでcodeのみを指定
                additionalParameters: [:]
            )

            // リクエストを実行
            self.currentAuthorizationFlow = OIDAuthorizationService.present(request, presenting: viewController, callback: { (response: OIDAuthorizationResponse?, error: Error?) in

                // Authorization Codeを取得できたら返却する
                if let authorizationCode = response?.authorizationCode {
                    result(authorizationCode)
                } else {
                    // error handling
                }
            })
        }
    }
}

Androidの場合

Androidも同様に実装していきます。Androidの場合はMainActivityに実装を行います。
実装方法は異なるものの、iOSと同様の流れでAuthorization Codeを取得しFlutterに処理を戻します。

class MainActivity: FlutterActivity() {
  private val CHANNEL = "auth_lib"
  private val REQUEST_CODE_AUTH = 100
  private var result: MethodChannel.Result? = null

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    GeneratedPluginRegistrant.registerWith(this)
    MethodChannel(flutterView, CHANNEL).setMethodCallHandler { methodCall, result ->
      if (methodCall.method == "fetchAuthorizationCode") {
        this.result = result
        fetchAuthorizationCode()
      } else {
        result.notImplemented()
      }
    }
  }

  override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (requestCode == REQUEST_CODE_AUTH) {
      if (data == null) {
        // error handling
        return
      }
      val resp = AuthorizationResponse.fromIntent(data)
      if (resp?.authorizationCode != null && this.result != null) {
          this.result!!.success(resp.authorizationCode)
      }
      // error handling
    }
  }

  private fun fetchAuthorizationCode() {
        // OpenID Configurationエンドポイントにリクエストし、各エンドポイントのURIやサポート機能を確認
        AuthorizationServiceConfiguration.fetchFromIssuer(Uri.parse("https://auth.login.yahoo.co.jp/yconnect/v2")) { config, ex ->
          if (config != null) {
            val builder = AuthorizationRequest.Builder(config, <登録したClient ID>, ResponseTypeValues.CODE, <登録したコールバックURL>)
            val req = builder.setScope("openid profile").build()
            val intent = AuthorizationService(this).getAuthorizationRequestIntent(req)
            startActivityForResult(intent, REQUEST_CODE_AUTH)
          } else {
            // error handling
          }
        }
  }
}

ここまでの実装で、Flutterで作ったアプリのiOSとAndroidともにAuthorization Codeを取得することができました。
今の所ほとんどFlutterを書いていません。

Custom Token発行

次にクライアントで取得したAuthorization Codeをサーバーに送信し、サーバー側でCustom Tokenを発行するまでの実装を行います。
今回はNode.jsとTypeScriptを用いて実装を行いました。流れとしては以下です。

  1. クライアントから受け取ったAuthorization Codeを用いてYahoo! ID連携にTokenリクエストを行う
  2. Tokenレスポンスに含まれるID Tokenを検証する
  3. 検証結果が正しければCustom Tokenを発行する

まずはAuthorization Codeを用いてYahoo! ID連携のTokenリクエストを行います。
HTTPClientにはRequest-Promiseを利用します。

import * as rp from 'request-promise'

export const issueCustomToken = functions.https.onRequest(async (request, response) => {
  // OpenID Configurationエンドポイントにリクエストし、各エンドポイントのURIやサポート機能を確認
  const openidConfiguration = await rp({
    url: 'https://auth.login.yahoo.co.jp/yconnect/v2/.well-known/openid-configuration',
    method: 'GET',
    json: true
  }).catch(error => {
    // error handling
  })

  // Tokenリクエストのリクエスト準備
  const options = {
    url: openidConfiguration.token_endpoint,
    method: 'POST',
    form: {
      grant_type: 'authorization_code',
      // Firebase Cloud Functionsに事前登録したコールバックURLを環境変数から取得
      redirect_uri: functions.config().yahoojapan.redirect_uri,
      code: authorizationCode,
    },
    // Firebase Cloud Functionsに事前登録したClient IDとシークレットを環境変数から取得しAuthorizationヘッダーにセット
    headers: {
        "Authorization": "Basic " + new Buffer(functions.config().yahoojapan.client_id + ":" + functions.config().yahoojapan.client_secret, "utf8").toString("base64")
    },
    json: true
  }

  // Tokenリクエスト実行
  const tokenResponse = await rp(options).catch(error => {
    // error handling
  })
  ...
})

以上の処理でYahoo! ID連携が発行したID Tokenを取得できました。
ID Tokenの認証結果を受け入れる際には定められた方法で検証を行います。
ID TokenとJWTについては2017年の私のAdvent Calendarの記事を参照ください。

ID Tokenの検証は下記のように行います。
今回はJWTの検証等についてはOSSのJWTライブラリであるnode-jsonwebtokenを利用します。

import * as rp from 'request-promise'
import * as jwt from 'jsonwebtoken'
import * as jwksClient from 'jwks-rsa'

export const issueCustomToken = functions.https.onRequest(async (request, response) => {
  ...  const tokenResponse = await rp(options).catch(error => {
    // error handling
  })

  const idToken: string = tokenResponse.id_token
  const decoded = jwt.decode(idToken, { complete: true })

  // ID Tokenの署名アルゴリズムをチェック
  if (decoded['header']['alg'] !== 'RS256') {
    // error handling
  }

  // JWKエンドポイントから署名の検証に必要な情報を取得
  const jwksClientObj: jwksClient.JwksClient = jwksClient({
    jwksUri: openidConfiguration.jwks_uri
  })

  // JWTのkidに対応した鍵を取得
  const key: string = await getSigningKey(jwksClientObj, decoded['header']['kid']).catch(error => {
    // error handling
  })

  // 検証したいオプションを指定
  const verifyOptions = {
    algorithm: 'RS256',
    audience: functions.config().yahoojapan.client_id,
    issuer: openidConfiguration.issuer,
    maxAge: '600000'
  }

  // ID Tokenの検証
  const jwtDecoded = await verify(idToken, key, verifyOptions).catch(error => {
    // error handling
  })
  ...
})

以上のID Tokenの検証が成功した場合、Yahoo! ID連携で払い出されたID Tokenを受け入れます。
このID Tokenの情報を元にCustom Tokenの発行を行います。

Custom Tokenの発行方法を元に実装を行います。
Custom Tokenの発行方法は下記の3つ紹介されています。

それぞれの実装方法でメリット・デメリットがあるため各々判断の上Custom Token発行手順を実装してください。
今回はAdmin SDKにサービスアカウントを検出させる方法で実装してみたいと思います。

まずはユーザー情報を取得します。Firebaseではuidというユーザー識別子を利用しますが、他の手段で生成したユーザーのuidとの重複を避けるためyahoojapanのprefixをつけています。
この時指定したuidが未登録であればFirebase上に新規登録されます。
取得できたユーザーのCustom Tokenを発行するときはユーザーのuidのみを指定します。これでCustom Tokenの発行が完了です。

import * as admin from 'firebase-admin'

async function getFirebaseUser(uid, openidConfiguration) {

  // ユーザーが存在すればそれを返す
  return admin.auth().getUser(uid).catch(async error => {

    // 存在しなければユーザーを作成し返却する
    if (error.code === 'auth/user-not-found') {
      return admin.auth().createUser({uid: uid})
    }
    // error handling
  })
}

export const issueCustomToken = functions.https.onRequest(async (request, response) => {
  ...
  // uidは重複しないようにprefixにプロバイダー名を入れる
  // ユーザー識別子のsubを取り出しprefixの後に加える
  const uid = 'yahoojapan:' + jwtDecoded['sub']
  const user: admin.auth.UserRecord = await getFirebaseUser(uid, openidConfiguration).catch(error => {
    // error handling
  })

  // Custom Token発行
  const customToken = await admin.auth().createCustomToken(user.uid).catch(error => {
    // error handling
  })

  // レスポンスをJSONで返却
  response.contentType('application/json')
  response.send(JSON.stringify({
    'token': customToken
  }))
})

アプリでのCustom Token受け入れ

最後にアプリで上記のエンドポイントにリクエストを行い、Custom Tokenを取得します。
クライアントはCustom Tokenを受け入れるために検証を行います。
FirebaseをFlutter上で利用する方法はFirebaseの公式ドキュメントで丁寧に説明されているので、そちらをご参照ください。

import 'package:firebase_auth/firebase_auth.dart';

final AuthLib authLib = AuthLib();

// Yahoo! ID連携でAuthorization Codeを取得
var code = await auth.fetchAuthorizationCode();

// 用意したサーバーからCustom Tokenを取得
var customToken = await http.get("https://example.com/?code=" + code);

// Custom TokenでFirebase認証を行う
final FirebaseAuth firebaseAuth = FirebaesAuth.instance;
FirebaseUser user = await firebaseAuth.signInWithCustomToken(token: customToken);

以上でFirebaseにログインできました。

まとめ

今回はFlutterとFirebaseを使ってYahoo! ID連携を実装する方法をご紹介しました。
今後も、みなさんのサービスにYahoo! ID連携を導入してもらいユーザーによりよい体験を提供できるように努めていきますので、Yahoo! ID連携をよろしくお願いします。

Yahoo! JAPANでは情報技術を駆使して人々や社会の課題を一緒に解決していける方を募集しています。詳しくは採用情報をご覧ください。

  • このエントリーをはてなブックマークに追加