Firebase Authentication を Next.js ✕ Rails ✕ GraphQL で試す
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