Your cart is currently empty!
Firebase Authentication Remix SSR (React)
原本以為 Firebase SDK 應該很直覺可以上手,但因為真是有些太豐富了,所以我研究了兩天終於處理好使用者驗證的部分,小小做個筆記,也希望可以幫助到需要的人類。
Reference: https://firebase.google.com/docs/auth/web/start、https://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 什麼的,雖然還沒有深入研究,但未來拓展都已經擺在那了。
分享一下原本在選擇的三條路線:
- Firebase
- 這個 pros & cons 如上。
- 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,分析跟其他工具要額外找,
- Passport / Cloudflare R2 / whatever Analytics / whatever AB Test
- Pros:Passport 支援幾乎所有你想得到的 Auth,R2 的 storage 有 10GB 免費方案,而且之後非常便宜($0.015/GB)而且 CDN 超強。因為本來就有用 Cloudflare,我真的是滿想試試這個的。
- Cons:全部都要自己研究,但感覺其實時間不會差多少。
Firebase SDKs
- Web SDK
- 主要是用在前端,可以直接和 Firebase backend 溝通,但是也可以在伺服器使用。
- 有登入、註冊、修改資料的 API,主要是一堆登入取得使用者資料的 API。
- Admin SDK
- 專門給伺服器使用的 SDK,可以取得 Cookie Session 以及管理(刪除、讀取、修改)使用者資料庫。
- 如果要自定義 Email 也可以使用 Admin SDK
generateEmailVerificationLink()
。
Firebase Cookies 流程
- 自前端取得 idToken
- 傳送 token 到後端
- 後端驗證 token 之後 commit session
- 驗證 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
為了解決在頁面之間登入狀態不同步的問題,在 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
}
}
複習
- 從前端的 Web SDK 不管是哪種方式取得 userIdToken
- 傳送 Token 到後端驗證
- 驗證後的 Cookie,與其他資訊一起儲存在 Session,然後 commit to cookie header
- 在每個 route 驗證使用者
- 取得 Cookie 之後使用 Firebase Admin SDK 取得使用者資訊