Firebase

Firebase Authentication Remix SSR (React)

原本以為 Firebase SDK 應該很直覺可以上手,但因為真是有些太豐富了,所以我研究了兩天終於處理好使用者驗證的部分,小小做個筆記,也希望可以幫助到需要的人類。

Reference: https://firebase.google.com/docs/auth/web/starthttps://firebase.google.com/docs/auth/admin

結論

先說,因為 Firebase SDKs 真的很豐富,所以如果你是使用 SSR 如 Remix,真的需要點時間上手,我光是思考流程跟研究如何使用就兩天了,而且文件雖然有互相參照但還是不太清楚,以及網路上資訊相對比較少,感覺這個時間拿去學 Passport + JWT 都超夠用。

因為我是製作 SPA,主要會用 Firebase Web SDK 以及 Admin SDK,以及 Remix 提供的 Session 工具。

環境

  • VSCode 1.92.1
  • Node 20
  • Remix 2.11.1
  • Firebase 10.12.5
  • Macbook Air M2, 2022

目標:完成使用者驗證流程

會使用 Firebase 主要是看上他算是 Google Cloud 的一環,我本身 App 是寫 Dockerfile CICD 在 Cloud Run 上,而且他整合了 Auth (沒有 LINE Login)、Storage、Analytics、AB Test 什麼的,雖然還沒有深入研究,但未來拓展都已經擺在那了。

分享一下原本在選擇的三條路線:

  1. Firebase
    • 這個 pros & cons 如上。
  2. Supabase auth / Supabase storage / whatever Analytics / whatever AB Test
    • Pros:Supabase 把後端的 SDK 寫得很讚,在臺灣討論度很高,成長也很迅速介面很美, per GB storage 更便宜($0.021 Firebase $0.026)。另外 Supabase 也可以自己 Host。
    • Cons:相較 Firebase Pay as you go 貴了不少,另外 storage 只有 1GB,分析跟其他工具要額外找,
  3. Passport / Cloudflare R2 / whatever Analytics / whatever AB Test
    • Pros:Passport 支援幾乎所有你想得到的 Auth,R2 的 storage 有 10GB 免費方案,而且之後非常便宜($0.015/GB)而且 CDN 超強。因為本來就有用 Cloudflare,我真的是滿想試試這個的。
    • Cons:全部都要自己研究,但感覺其實時間不會差多少。

Firebase SDKs

  1. Web SDK
    • 主要是用在前端,可以直接和 Firebase backend 溝通,但是也可以在伺服器使用。
    • 有登入、註冊、修改資料的 API,主要是一堆登入取得使用者資料的 API。
  2. Admin SDK
    • 專門給伺服器使用的 SDK,可以取得 Cookie Session 以及管理(刪除、讀取、修改)使用者資料庫。
    • 如果要自定義 Email 也可以使用 Admin SDK generateEmailVerificationLink()

Firebase Cookies 流程

  1. 自前端取得 idToken
  2. 傳送 token 到後端
  3. 後端驗證 token 之後 commit session
  4. 驗證 session cookie

(1)Firebase Client Auth

登入就是這麼簡單,把資訊丟給 Firebase,回來就是登入的使用者以及資料。

如前面所述,可以直接在前端調用,然後使用 onAuthStateChanged() 取得使用者登入狀態,直接管理頁面的呈現,也可以在伺服器調用。

// Firebase Auth
const auth = getAuth(app);
const userCredential = await signInWithEmailAndPassword(auth, email, password);
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
const user = userCredential.user
const token = await user.getIdToken(true) // forceRefresh: true

以上在前端取得 token

但是呢,如果這個是在伺服器端執行的話,前端就不會登入,而且無法控制 SDK 中的登入資料(可能突然就被登出了),所以需要額外的方式維持登入狀態。這時就需要 Server 的工具以及 Session 了!

(3)Firebase Server Auth

Server side auth with Firebase

為了解決在頁面之間登入狀態不同步的問題,在 Web Application 的各種方法中最理想的方式就是用 Cookie 儲存 Session 資訊,而 Firebase 也有提供建立 Session Cookie 的 function,非常方便!

可以參考:https://firebase.google.com/docs/auth/admin/manage-cookies

我選擇直接使用 Remix 提供的 createCookieSessionStorage API,直接取得 get、commit、destroy 的 functions。

import { createCookieSessionStorage } from "@remix-run/node";

export const { getSession, commitSession, destroySession } = createCookieSessionStorage({
    cookie: {
        name: '__session',
        secure: process.env.NODE_ENV === 'production',
        secrets: [SESSION_SECRET],
        sameSite: 'lax',
        maxAge: 14 * 24 * 60 * 60, // 14 days
        httpOnly: true,
    },
});

我們有了剛剛的 idToken 後,不管你用什麼方法,在後端就只要取得 token 之後,直接使用 Firebase SDK驗證其是否過期(verifyIdToken()),如果過期的話他會拋出 auth/id-token-expired (error.code),就要使用 catch 取得然後處理。

verify 後會取得使用者資料,但不重要,我們只需要拿熱騰騰的 userIdToken 丟給 createSessionCookie 的 Firebase auth function,取得 Session Cookie 之後跟我們的其他東東放在一起,接著直接 commit session 到 Header。

// Back-end
// 記得是使用 admin-sdk
import { getAuth as getAuthAdmin } from "firebase-admin/auth";
// firebase 是 initialApp 後的東東
import { firebase } from "./_firebase.server";

export const createUserSession = async (
    userIdToken: string,
    redirect_path: string,
    messages: string[],
    errorMessages: string[]
): Promise<TypedResponse<never>> => {
    const session = await getSession();

    try {
        const decodedIdToken = await firebase.auth().verifyIdToken(userIdToken, true)
        if (new Date().getTime() / 1000 - decodedIdToken.auth_time < 5 * 60) {
            // Create session cookie and set it.
            const expiresIn = 60 * 60 * 24 * 5 * 1000;  // 5 days
            const firebaseSessionCookie = await getAuthAdmin().createSessionCookie(userIdToken, { expiresIn });
            session.set('firebaseSessionCookie', firebaseSessionCookie)
            session.flash('messages', messages);
            session.flash('errorMessages', errorMessages);
            return redirect(redirect_path, {
                headers: {
                    'Set-Cookie': await commitSession(session),
                },
            });
        } else {
            throw { message: '登入逾時,請重新登入' }
        }
    } catch (error: any) {
        console.error('Error verifyIdToken() and save user to session: ', error.code, error.message)
        session.flash('errorMessages', ['登入失敗,請重新登入']);
        return redirect('/signin', {
            headers: {
                'Set-Cookie': await commitSession(session),
            },
        });
    }
}

(4)Verify Firebase User idToken

這個步驟要用 Cookie 取得 Session,然後把剛剛存到 Session 的那串東西取出來之後丟給 verifySessionCookie() 就是了!一樣會取得與 verifyIdToken() 的 UserRecord,但因為他不包含 displayName、photoUrl 之類的資料,驗證後要再使用 getUser(),放入 uid 取得 user。

export const getUserSession = async (
    cookie: string | null
): Promise<{
    user: UserRecord
} | null> => {
    const session = await getSession(cookie);
    const firebaseSessionCookie = session.get('firebaseSessionCookie');

    try {
        if (firebaseSessionCookie) {
            const decodedClaims = await getAuthAdmin().verifySessionCookie(firebaseSessionCookie, true /** checkRevoked */)
            // UserRecord type 才是有 photoURL, displayName 等等資料的,而且是駝峰命名法
            const user = await getAuthAdmin().getUser(decodedClaims.uid)
            return { user };
        } else {
            return null;
        }
    } catch (error: any) {
        console.error('Error verifySessionCookie(): ', error.code, error.message)
        return null
    }
}
Remix | Full stack web framework build better web
Firebase | Google's Mobile and Web App Development Platform

複習

  1. 從前端的 Web SDK 不管是哪種方式取得 userIdToken
  2. 傳送 Token 到後端驗證
  3. 驗證後的 Cookie,與其他資訊一起儲存在 Session,然後 commit to cookie header
  4. 在每個 route 驗證使用者
  5. 取得 Cookie 之後使用 Firebase Admin SDK 取得使用者資訊