🗂

Firebase Authentication を Next.js ✕ Rails ✕ GraphQL で試す

2022/12/01に公開

Firebase Authentication を Next.js ✕ Rails ✕ GraphQL で試す

業務で Rails で マイクロサービス の SSO(シングルサインオン) での認証を作っています。
しかし、これが Rails の Device で実現しているのでかなり辛い部分がありました。
とくに、ソーシャル認証の扱いに大苦戦。
ソーシャルのAPIキーをそれぞれ設定しなければいけないし、Rails OmniAuth がうまく動かなかったりなどなど。
なので、Firebase Authentication をつかってこの辺をなんとかスッキリできないか、個人的に試してみました。
Firebase のことだけではなく、Cookieやそれに関連するセキュリティ周りに詳しくなれたので、収穫はあり!

今回は、苦労した箇所や、ポイントのみ紹介させていただきます。

バックエンド(Rails)

Rubyには標準でFirebaseのSDKライブラリがありませんでした。
Firebaseのgemもあり、試しましたがうまく動かなかった。。。
なので、自作してみました。
いい勉強になりました。(自分の不甲斐なさを更に実感..)

環境

  • Ruby 3.1.2
  • Rails 7(Apiモード)
  • Redis
  • docker-compose
  • cors は設定済み
  • GraphQL

Firebaseの公開鍵でIDトークンを検証

セキュリティ上、フロントエンドから送られてきたJWTのIDトークンが正規のものか公開鍵で検証してからデコードする必要があります。

検証に必要な定数

  CERTS_URI = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'
  CERTS_CACHE_KEY = 'firebase_auth_certificates'
  OPTIONS = {
    algorithm: 'RS256',
    iss: "https://securetoken.google.com/#{ENV.fetch('FIREBASE_PROJECT_ID', nil)}",
    verify_iss: true,
    aud: ENV.fetch('FIREBASE_PROJECT_ID', nil),
    verify_aud: true,
    verify_iat: true
  }.freeze  

ペイロード
IDトークンをデコードして公開鍵で検証します。

  def _payload!
    return @_payload if @_payload

    @_payload, = JWT.decode(id_token, nil, true, OPTIONS) do |header|
      cert = _fetch_certificates[header['kid']]
      OpenSSL::X509::Certificate.new(cert).public_key if cert.present?
    end

    _verify!

    @_payload
  end

Firebaseの公開鍵を取得処理
公開鍵の有効期限が切れているときのみ、再度ダウンロードして、キャッシュに保存します。

  def _fetch_certificates
    return _certificates_cache if _certificates_cache.present?

    res = Net::HTTP.get_response(URI(CERTS_URI))
    body = JSON.parse(res.body)
    expires_at = Time.zone.parse(res.header['expires'])
    Rails.cache.write(CERTS_CACHE_KEY, body, expires_in: expires_at - Time.current)
    body
  end

  def _certificates_cache
    @_certificates_cache ||= Rails.cache.read(CERTS_CACHE_KEY)
  end  

デコードした結果を別の観点から検証(なくても良いかも)

  def _verify!
    raise StandardError, 'Invalid auth_time' if Time.zone.at(@_payload['auth_time']).future?
    raise StandardError, 'Invalid sub' if @_payload['sub'].empty?
  end

FirebaseのIDトークンは1時間で有効期限切れになるので、有効期限が切れたら リフレッシュトークンであたらしくします。

  def _refresh_token!
    responce = Net::HTTP.post_form(
      URI.parse("https://securetoken.googleapis.com/v1/token?key=#{ENV.fetch('FIREBASE_APY_KEY')}"),
      grant_type: 'refresh_token', refresh_token:
    )
    body = JSON.parse(responce.body)
    raise StandardError, body['error']['message'] unless responce.code == '200'

    body
  end

全体のソースは こちら

トークンはどこで保存するのか

ここに中々苦労しました。
トークンをCookieに保存するのに抵抗があり色々調べ回ってこの結論にいたりました。
特にAuth0のドキュメントが参考になりました。
また、こちらの記事のソースをほとんど参考にさせていただいてます。

まず、Railsのsessionにトークンを保存するために、Redis環境を準備します。

---------------省略
  app:
---------------省略  
    environment:
      REDIS_URL: redis://redis:6379/0
    depends_on:
      - db
      - redis
  redis:
    image: redis
    command: redis-server --appendonly yes
    ports:
      - '6379:6379'
    volumes:
      - redis:/var/lib/redis/data

ローカル環境でブラウザのCookieの保存がうまく行かなくて苦労しました。
rails_same_site_cookie を使用して解決。
これは、CookieのSameSite、Secureの値をブラウザごとにいい感じに設定してくれるみたいです。
もしかすると、開発環境のみ使えば良いかもしれません。

gem 'redis'
gem 'redis-actionpack'

gem 'rails_same_site_cookie'
# config/initializers/session_store.rb

Rails.application.config.middleware.insert_before Rack::Head, ActionDispatch::Cookies
Rails.application.config.middleware.insert_after ActionDispatch::Cookies, ActionDispatch::Session::RedisStore,
  servers: ['redis://redis:6379/0'],
  expire_after: 2.weeks,
  key: "_app_session"

sessionにIDトークン、Refreshトークンを保存します。

    session[:id_token] = id_token
    session[:refresh_token] = refresh_token

ブラウザを確認するとこんな感じでCookieが設定されます。

このCookieはサーバーで確認できるので、フロントエンドからは何も意識しなくても大丈夫です。

新規登録

GraphQLを使います。
普通はRESTかもしれませんが、GraphQLが便利すぎて GraphQLしか使えなくなってしまった。
Refreshトークンはデータベースにも保存します。

module Mutations
  class SignUpUser < BaseMutation
    field :user, Types::UserType, null: false

    argument :id_token, String, required: true
    argument :refresh_token, String, required: true

    def resolve(id_token:, refresh_token:)
      setting_session(id_token, refresh_token)
      { user: create_form_id_token! }
    end
  end
end

ログイン

sessionにトークンを設定します。

module Mutations
  class SignInUser < BaseMutation
    field :user, Types::UserType, null: false

    argument :id_token, String, required: true
    argument :refresh_token, String, required: true

    def resolve(id_token:, refresh_token:)
      setting_session(id_token, refresh_token)
      { user: find_form_id_token! }
    end
  end
end

ログイン中のユーザーを取得

ApplicationControlllerでcurrent_userを設定して、contextに設定します。

  def current_user
    @current_user ||= find_form_id_token!
  end
---省略  
    context = {
      current_user:,
      session:
    } 
---    

トークンからユーザーを取得
有効期限切れならトークンをリフレッシュ

  def find_form_id_token!
    return unless session[:id_token]

    User.find_by(uid: _uid)
  rescue JWT::ExpiredSignature
    _find_and_refresh_token!
  end

GraphQLで取得

module Resolvers
  class User < GraphQL::Schema::Resolver
    type Types::UserType, null: false

    def resolve
      context[:current_user]
    end
  end
end

ソースは GitHubにUPしてます。

フロントエンド(Next.js)

  • Next.js 13.0.1
  • React 18.2.0
  • Typescript 4.8.4
  • apollo/client 3.7.1
  • graphql 16.6.0
  • mui/material 5.10.12

GraphQLの型、Query、Mutationの自動生成はとても便利です。
詳細については、今回は割愛させていただきます。

Firebase設定

Firebaseを登録して、Google、GitHub認証を追加します。

import { initializeApp } from 'firebase/app'
import { getAuth, GithubAuthProvider, GoogleAuthProvider } from 'firebase/auth'

const firebaseConfig = {
  apiKey: process.env.FIREBASE_APIKEY,
  authDomain: process.env.FIREBASE_AUTH_DOMAIN,
  projectId: process.env.FIREBASE_PROJECT_ID,
  storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.FIREBASE_APP_ID,
  measurementId: process.env.FIREBASE_MEASUREMENT_ID
}
export const app = initializeApp(firebaseConfig)

export const googleAuthprovider = new GoogleAuthProvider()
export const githubAuthProvider = new GithubAuthProvider()
export const auth = getAuth(app)

ソーシャル認証

hooks

ソーシャル認証のポップアップを出して、結果を受け取る処理です。

注意点として、GoogleとGitHubで以下の違いがありました。

  • 同じメールアドレスの、Googleで登録した後にGitHubで登録 => すでに登録済みなのでエラー
  • 同じメールアドレスの、GitHubで登録した後にGoogleで登録 => Googleで上書きされる
import { AuthErrorCodes, AuthProvider, getAdditionalUserInfo, signInWithPopup } from 'firebase/auth'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { auth, githubAuthProvider, googleAuthprovider } from '../features/const/firebase'

type SuccessState = {
  isNewUser?: boolean
  idToken?: string
  refreshToken?: string
}

export default function useFirebaseAuth() {
  const [successState, setSuccessState] = useState<SuccessState>({})
  const snsAuthPopup = (authprovider: AuthProvider) =>
    signInWithPopup(auth, authprovider)
      .then((result) => {
        result.user.getIdToken().then((token) => {
          result.user.displayName
          setSuccessState({
            isNewUser: getAdditionalUserInfo(result)?.isNewUser,
            idToken: token,
            refreshToken: result.user.refreshToken
          })
        })
      })
      .catch((e) => {
        if (e.code === AuthErrorCodes.NEED_CONFIRMATION) {
          console.log('既に他のSNSと連携済みです。')
        }
      })
  const authInfoList = [
    {
      alt: 'Google',
      image: '/asserts/images/sns_auths/google.png',
      method: () => snsAuthPopup(googleAuthprovider)
    },
    {
      name: 'GitHub',
      image: '/asserts/images/sns_auths/git_hub.png',
      method: () => snsAuthPopup(githubAuthProvider)
    }
  ] as {
    name: string
    image: string
    method: () => void
  }[]

  const router = useRouter()
  const successAuth = () => {
    router.push('./my_page')
  }

  return { authInfoList, successState, successAuth }
}

認証画面

新規ユーザーならバックエンドの登録処理を、登録済ならバックエンドのログイン処理をコールしログインします。

import { ImageList, ImageListItem } from '@mui/material'
import { Stack } from '@mui/system'

import dynamic from 'next/dynamic'
import { useEffect } from 'react'
import { useSignInUserMutation } from '../graphql/documents/signInUser.generated'
import { useSignUpUserMutation } from '../graphql/documents/signUpUser.generated'
import useFirebaseAuth from '../hooks/use_firebase_auth'

function SnsAuthButtons() {
  const { authInfoList, successState, successAuth } = useFirebaseAuth()

  const [signUpUserMutation] = useSignUpUserMutation()
  const [signInUserMutation] = useSignInUserMutation()
  useEffect(() => {
    if (successState.idToken && successState.refreshToken) {
      const variables = {
        variables: {
          input: { idToken: successState.idToken, refreshToken: successState.refreshToken }
        }
      }
      if (successState.isNewUser) {
        signUpUserMutation(variables).then(() => successAuth())
      } else {
        signInUserMutation(variables).then(() => successAuth())
      }
    }
  }, [successState])

  return (
    <Stack width={300}>
      <ImageList cols={1} rowHeight={70} gap={25}>
        {authInfoList.map((authInfo, i) => (
          <ImageListItem key={`${i}-auth-image`}>
            <input
              onClick={authInfo.method}
              type="image"
              alt={authInfo.name}
              src={authInfo.image}
              style={{ objectFit: 'contain', height: '100%', width: '100%' }}
            />
          </ImageListItem>
        ))}
      </ImageList>
    </Stack>
  )
}
export default dynamic(async () => SnsAuthButtons, { ssr: false })

Context

Middlewareでやるのが正解かもですが、今回はContextで行います。
ログイン中で 認証画面にアクセスするとマイページへ遷移
未ログインで マイページへアクセスすると 認証画面へ遷移

import { useRouter } from 'next/router'
import { createContext, useEffect, useState } from 'react'
import { useUserLazyQuery } from '../graphql/documents/user.generated'
import { UserQuery } from '../graphql/types'

type UserContextType = UserQuery['user'] | undefined
export const UserContext = createContext<UserContextType>(undefined)

export default function AuthContextProvider({ children }: { children: React.ReactNode }) {
  const [getUser, { data, error }] = useUserLazyQuery()
  const [user, setUser] = useState<UserContextType>()
  const router = useRouter()
  useEffect(() => {
    getUser()
  }, [router])

  useEffect(() => {
    if (data) {
      setUser(data.user)
      if (router.pathname === '/') router.push('/my_page')
    }

    if (error) {
      setUser(undefined)
      if (router.pathname !== '/') router.push('/')
    }
  }, [data, error])

  return <UserContext.Provider value={user}>{children}</UserContext.Provider>
}

マイページ

import { Button, Typography } from '@mui/material'
import { Stack } from '@mui/system'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/router'
import { useContext } from 'react'
import { UserContext } from '../../context/AuthCcontextProvider'
import { useLogoutMutation } from '../../graphql/documents/logout.generated'

function MyPage() {
  const user = useContext(UserContext)
  const router = useRouter()
  const [logoutMutation] = useLogoutMutation()
  const handleLogout = () => {
    logoutMutation({ variables: { input: {} } }).then(() => router.push('/'))
  }
  if (!user) return <></>
  return (
    <Stack spacing={2} alignItems="center" textAlign="center" justifyContent="center" mt={5}>
      <Typography>{`Hello ${user.userName || user.id}`}</Typography>
      <Button variant="contained" color="error" onClick={handleLogout}>
        ログアウト
      </Button>
    </Stack>
  )
}

export default dynamic(async () => MyPage, { ssr: false })

挙動

認証画面
ソーシャル認証ボタンを押すとポップアップが表示されます。

認証するとマイページへ

こんな感じです。

最後に

お読みいただきありがとうございました。
多分あかんところとかたくさんあるので、まともに参考にしないほうがいいかもしれません。
ご指摘等おまちしております。

全ソースはGitHubにUpしてます。
こちら

Discussion