--- doc_id: qNAAcNEeLuxS description: "给 Next.js 加 FastAPI 部署在 Vercel 的 App 接 Firebase 的 Google 登录的完整做法: 邮箱密码由 fastapi-users 自己管, Firebase 只接第三方登录, 后端验证 Firebase ID Token 后换发自己的 JWT, 服务账号私钥放在 AWS SSM, 按 email 关联老账号前先确认邮箱归属. 是一篇带 Console 步骤和完整代码的实战记录." tags: [firebase, authentication, oauth, fastapi, nextjs, vercel] created_at: "2026-11-06" updated_at: "2026-11-06" --- # 照着做: 给 Next.js 加 FastAPI 的 App 接上 Firebase 的 Google 登录 这篇和 {ref}`XhBloryvDBJp` 讲的是同一个 App: 前端 Next.js, 后端 Python FastAPI, 都部署在 Vercel 上. 那篇讲为什么用 Firebase, 这篇是我照着配了一遍的记录, 以后要再配一次, 从头照着做就行. 当时的代码在我的一个私有仓库里, 下面的代码都是从那里整理出来的, 只有按 email 关联老账号那一段, 我直接写成了修好之后的版本 (原因见 {ref}`qNAAcNEeLuxS-link-by-email`). 两个关键决定先说在前面. 第一, 邮箱密码登录我自己管, 用的是 fastapi-users 加自己的用户表, Firebase 只负责 Google 这类第三方登录. 第二, 后端验证完 Firebase ID Token 之后, 换发我自己的 JWT, 之后的请求只认自己的 JWT, 不再碰 Firebase 的 token. 这两个决定定了, 后面每一步为什么这么做就都顺了. ## 整体做法: 邮箱密码自己管, Firebase 只接第三方, 后端换发自己的 JWT 接 Google 登录之前, 这个 App 已经有一整套邮箱密码的登录体系: fastapi-users 负责注册, 登录, 发验证邮件, 重置密码; 登录成功后发一个短命的 JWT access token, 再加一个放在 HttpOnly cookie 里的 refresh token. 这套东西已经能用了, 我不想为了加一个 Google 登录把它拆掉重来. 所以 Firebase 在这里只干一件事: 帮我完成 Google 那边的 OAuth, 交给我一个能证明 "这个人是谁" 的 Firebase ID Token. 后端拿到它, 验证, 在自己的用户表里找到或建出这个用户, 然后发一套和密码登录一模一样的 JWT 加 refresh token. 说白了, Firebase ID Token 只用一次, 是一张换票凭证, 换到的是我自己系统的门票: ```mermaid sequenceDiagram participant U as 用户 participant FE as Next.js 前端 participant FA as Firebase Auth participant BE as FastAPI 后端 U->>FE: 点 "Sign in with Google" FE->>FA: signInWithPopup (Google) FA-->>FE: Firebase ID Token FE->>BE: POST /api/auth/firebase {id_token} BE->>BE: 验 Firebase ID Token BE->>BE: 按 firebase_uid 或 email 找用户, 找不到就建 BE-->>FE: 自己的 JWT access token + refresh token cookie FE->>BE: 之后的请求都带自己的 JWT ``` 这么做的好处是, 不管用户是用密码登录还是用 Google 登录, 进了门之后就是同一种身份, 同一套过期和刷新逻辑, 业务接口完全不用区分. 代价也很明确: 同一个人现在可能有两个来源 (密码和 Google), 两者要不要合并成一个账号, 得我的后端自己判断. 这一步做错了就有安全问题, 后面单独一节讲. ## 在 Firebase Console 点五步, 拿到前端配置和服务账号 这五步我都是在 [Firebase Console](https://console.firebase.google.com/) 里亲手点的. 菜单名以 2026-11 时的英文界面为准, 以后界面改了, 照着意思找就行. 第一步, 建项目. 点 "Create a project", 输入项目名. 项目名只是给人看的, 系统会据此生成一个项目 ID, 项目 ID 才是唯一标识, 建好之后不能改, 前后端都要用到它. Google Analytics 开不开都不影响登录. 第二步, 启用 Google 登录. 左边栏 "Build" → "Authentication", 点 "Get started", 进 "Sign-in method", 选 "Google", 打开 "Enable". 这里要填两样: 项目的公开名称 (用户在 Google 授权页上看到的应用名) 和支持邮箱. 保存. 这一步之所以这么简单, 是因为 Firebase 在背后替你建好了 Google 的 OAuth 客户端, 回调地址指向 Firebase 自己的服务器, 你不用去 Google Cloud Console 自己配. 第三步, 注册 Web 应用, 拿前端配置. 回到 "Project Overview", 点 `` 图标加一个 Web 应用, 随便起个昵称. 页面上会出现一个 `firebaseConfig` 对象, 里面是 `apiKey`, `authDomain`, `projectId`, `storageBucket`, `messagingSenderId`, `appId`. 把这几个值记下来, 前端要用. "同时设置 Firebase Hosting" 那个勾我没勾, 因为前端部署在 Vercel. `apiKey` 看着像密钥, 其实不是, 放在前端代码里是正常的. 它只用来标识你的 Firebase 项目, 真正挡住别人冒用的是第五步的授权域名. 第四步, 下载服务账号私钥. 点左上角齿轮进 "Project settings", 切到 "Service accounts", 选中 "Firebase Admin SDK", 点 "Generate new private key", 会下载一个 JSON 文件. 后端用它初始化 Firebase Admin SDK. 这个文件里有私钥, 绝对不能进 Git, 我是怎么存的见下一节. 第五步, 配授权域名. 在 "Authentication" → "Settings" → "Authorized domains" 里, Firebase 默认已经放了 `localhost` 和 `.firebaseapp.com`. 你要把生产域名加进去, 比如 Vercel 给的 `.vercel.app` 和你自己绑的域名, 只填域名, 不带 `https://`. 别人就算抄走了你的 `firebaseConfig`, 只要他的网站域名不在这张表里, Firebase 就拒绝在他的页面上弹登录窗口. ## 服务账号私钥放进 AWS SSM Parameter Store, 启动时取出来 那个 JSON 文件有三种放法: 跟着代码走 (放进仓库或部署包), 放进 Vercel 的环境变量, 或者放进专门的密钥存储. 第一种直接排除, 私钥进了 Git 就等于公开. 我的项目本来就在用 AWS, 其他配置也放在 SSM Parameter Store 里, 所以私钥也放进去, 存成 `SecureString` (加密存储), 参数名是项目配置参数名后面加 `-firebase`. 把 JSON 文件内容整个存进去, 用 AWS CLI 一条命令就行: ```bash aws ssm put-parameter \ --name "-firebase" \ --type SecureString \ --value file://firebase-service-account.json ``` 存好之后, 本地那个 JSON 文件就可以删了. 后端启动时从 SSM 取出来, 解析成 dict, 交给 `credentials.Certificate`. 下面是去掉了项目里配置封装之后的写法: ```python # -*- coding: utf-8 -*- import json import boto3 import firebase_admin from firebase_admin import credentials _firebase_app: firebase_admin.App | None = None def get_firebase_cert(parameter_name: str) -> dict: ssm = boto3.client("ssm") res = ssm.get_parameter(Name=parameter_name, WithDecryption=True) return json.loads(res["Parameter"]["Value"]) def init_firebase(parameter_name: str) -> bool: """幂等: 已经初始化过就直接返回.""" global _firebase_app if _firebase_app is not None: return True try: cred = credentials.Certificate(get_firebase_cert(parameter_name)) _firebase_app = firebase_admin.initialize_app(cred) return True except Exception as e: print(f"Failed to initialize Firebase: {e}") return False def is_firebase_initialized() -> bool: return _firebase_app is not None ``` `init_firebase` 在 FastAPI 的 `lifespan` 里调一次. 写成幂等是因为 `firebase_admin.initialize_app` 对同一个默认 app 调第二次会报错, 而 serverless 环境里启动逻辑跑几次不好说. 初始化失败时我选择只记日志, 不让整个服务起不来: 邮箱密码登录照常能用, 只是 Google 登录接口会返回 503. ```{note} Vercel 上的函数要读 SSM, 得先拿到 AWS 凭证, 这部分是项目里通用的 AWS 会话管理在做, 和 Firebase 无关, 这里不展开. 给它的权限能读这个参数就够了. 如果你的项目没在用 AWS, 把 JSON 整段放进 Vercel 的环境变量 (标成 Sensitive) 也行, 只是 `get_firebase_cert` 改成从环境变量里读. ``` ## 后端: 一个验 token 的模块, 一个换 JWT 的接口 先装依赖. 项目用 uv 管理: ```bash uv add "firebase-admin>=6.0.0,<7.0.0" ``` 我当时锁在 6.x. 到 2026-11, `firebase-admin` 最新已经是 7.x, 新项目直接装最新版就行, 下面用到的 `auth.verify_id_token` 和几个异常类没有变. ### 验 token 的模块 这个模块只做两件事: 验证 Firebase ID Token, 把验证后的声明整理成一个固定结构. 自定义两个异常, 是为了让接口层能按错误类型返回不同的状态码: ```python # -*- coding: utf-8 -*- import dataclasses from firebase_admin import auth from firebase_admin.exceptions import FirebaseError from .firebase_init import is_firebase_initialized class FirebaseTokenInvalidError(Exception): """token 无效, 过期, 或被撤销.""" class FirebaseNotInitializedError(Exception): """启动时 init_firebase 没成功.""" def verify_firebase_token(id_token: str) -> dict: if not is_firebase_initialized(): raise FirebaseNotInitializedError("Firebase is not initialized.") try: return auth.verify_id_token(id_token) except auth.ExpiredIdTokenError: raise FirebaseTokenInvalidError("Firebase token has expired.") except auth.RevokedIdTokenError: raise FirebaseTokenInvalidError("Firebase token has been revoked.") except auth.InvalidIdTokenError: raise FirebaseTokenInvalidError("Firebase token is invalid.") except FirebaseError as e: raise FirebaseTokenInvalidError(f"Firebase authentication error: {e}") @dataclasses.dataclass class FirebaseUserInfo: firebase_uid: str email: str | None email_verified: bool name: str | None picture: str | None provider: str # "google.com", "apple.com", ... def get_user_info_from_token(decoded_token: dict) -> FirebaseUserInfo: firebase_claims = decoded_token.get("firebase", {}) return FirebaseUserInfo( firebase_uid=decoded_token["uid"], email=decoded_token.get("email"), email_verified=decoded_token.get("email_verified", False), name=decoded_token.get("name"), picture=decoded_token.get("picture"), provider=firebase_claims.get("sign_in_provider", "unknown"), ) ``` `email`, `name`, `picture` 都可能是 `None`: Google 一般三样都给, Apple 可能把真实邮箱藏起来, 名字和头像也常常没有. `email_verified` 一定要留着, 后面关联老账号全靠它. ### 用户表加一个 firebase_uid 用户模型是 fastapi-users 的 `SQLAlchemyBaseUserTableUUID`, 自带 `email`, `hashed_password`, `is_active`, `is_verified`. 我加了两个字段: ```python # fmt: off firebase_uid: orm.Mapped[str | None] = orm.mapped_column(sa.String(128), unique=True, nullable=True, index=True) has_set_password: orm.Mapped[bool] = orm.mapped_column(sa.Boolean, default=False, server_default=sa.text("false")) # fmt: on ``` `firebase_uid` 可以为空, 因为只用密码登录的用户没有它; 唯一, 因为一个 Firebase 用户只能对应一个本地账号; 加索引, 因为每次 Google 登录都要按它查. `has_set_password` 用来区分 "这个账号的密码是用户自己设的" 和 "只用 Google 注册, 密码是系统随机生成的", 前端据此显示 "修改密码" 还是 "设置密码". 建表用的是 `Base.metadata.create_all`, 项目里没有用 Alembic 做迁移. ### 换 JWT 的接口 请求体只有一个字段 `id_token`. 响应里除了 `access_token`, 还有 `is_new_user` (前端用来显示 "账号已创建" 还是 "登录成功") 和 `email`: ```python class FirebaseLoginRequest(BaseModel): id_token: str = Field(..., description="Firebase ID token from frontend") class FirebaseLoginResponse(BaseModel): access_token: str token_type: str = "bearer" is_new_user: bool = False email: str ``` 接口本身分四步: 验 token, 找或建用户, 发自己的 JWT, 把 refresh token 写进 cookie. 第二步里 "按 email 找到老账号怎么办" 那一段是 `link_existing_user`, 下一节单独讲. 这里的 `fastapi_users`, `get_jwt_strategy`, `create_refresh_token`, `get_refresh_token_cookie_settings` 是项目里密码登录本来就有的东西, Google 登录直接复用: ```python @router.post("/api/auth/firebase", response_model=FirebaseLoginResponse) @limiter.limit(rate_limit_login) async def firebase_login( request: Request, data: FirebaseLoginRequest, session: AsyncSession = Depends(get_async_session), ): if not firebase_enabled: raise HTTPException(status_code=503, detail="FIREBASE_AUTH_DISABLED") # 1. 验 Firebase ID Token try: user_info = get_user_info_from_token(verify_firebase_token(data.id_token)) except FirebaseNotInitializedError: raise HTTPException(status_code=503, detail="FIREBASE_NOT_INITIALIZED") except FirebaseTokenInvalidError: raise HTTPException(status_code=401, detail="FIREBASE_TOKEN_INVALID") if not user_info.email: raise HTTPException(status_code=400, detail="FIREBASE_EMAIL_REQUIRED") # 2. 找或建用户 is_new_user = False result = await session.execute( select(User).where(User.firebase_uid == user_info.firebase_uid) ) user = result.scalar_one_or_none() if user is None: result = await session.execute(select(User).where(User.email == user_info.email)) user = result.scalar_one_or_none() if user is not None: await link_existing_user(session, user, user_info) # 见下一节 else: user = User( email=user_info.email, # 随机密码, 用户不知道也用不到, 以后可以自己 "设置密码" hashed_password=PasswordHelper().hash(secrets.token_urlsafe(32)), is_active=True, is_verified=user_info.email_verified, is_superuser=False, firebase_uid=user_info.firebase_uid, has_set_password=False, ) session.add(user) await session.commit() await session.refresh(user) is_new_user = True # 3. 发自己的 JWT, 和密码登录用同一个策略 access_token = await get_jwt_strategy().write_token(user) # 4. refresh token 写进 HttpOnly cookie refresh_token = await create_refresh_token(session, user.id, refresh_token_lifetime) response = JSONResponse( content={ "access_token": access_token, "token_type": "bearer", "is_new_user": is_new_user, "email": user_info.email, } ) response.set_cookie(value=refresh_token, **get_refresh_token_cookie_settings(refresh_token_lifetime)) return response ``` 这里有几处是故意这么写的: - 先按 `firebase_uid` 找, 再按 `email` 找. `firebase_uid` 是 Firebase 给的, 用户改了 Google 邮箱它也不变, 是最可靠的身份; `email` 只在第一次用 Google 登录, 需要和老的密码账号对上时才用. - 新用户的 `is_verified` 取 token 里的 `email_verified`, 不是无脑设成 `True`. Google 登录的 `email_verified` 基本都是 `True`, 但换成别的登录方式就不一定了, 不要替 Firebase 做担保. - 这个接口和密码登录一样挂了限流 (`@limiter.limit`), 防止有人拿一堆假 token 来刷. - 业务接口用的是 fastapi-users 的 `current_user(active=True, verified=True)`, 只认自己的 JWT, 而且要求邮箱已验证. Google 登录换来的 JWT 和密码登录的完全一样, 业务代码不用关心用户是怎么登进来的. (qNAAcNEeLuxS-link-by-email)= ## 按 email 关联老账号之前, 先确认这个邮箱真是他的 这一节对应上面的 `link_existing_user`. 场景是: 有人先用邮箱密码注册了 `alice@gmail.com`, 后来又用同一个 gmail 点了 Google 登录. 两个来源, 同一个邮箱, 合不合并? 合并是对的, 不然同一个人会有两个账号. 问题是怎么合. 我当时最初的写法很直接: 按 email 找到老账号, 把 `firebase_uid` 挂上去, 完事. 这样写有一个洞, 就是 {ref}`XhBloryvDBJp-pre-account-hijacking` 那一节讲的抢注后劫持, 而且在我这种 "邮箱密码自己管" 的架构下, 洞全在我自己的后端里, Firebase 帮不上忙: ```{warning} 最初的写法有三个条件叠在一起: 密码登录不要求邮箱已验证 (fastapi-users 的登录路由默认不要求), 按 email 关联时不看 token 的 `email_verified`, 关联之后也不改老账号的状态. 于是攻击路径是: 黑客先用 alice 的邮箱加他自己的密码注册一个账号, 没验证邮箱, 但能登录; alice 后来用 Google 登录, 被挂进了这个账号; alice 一旦完成邮箱验证, 黑客就能拿他的密码登进一个已经验证过的, 属于 alice 的账号. 业务接口要求邮箱已验证, 所以在 alice 验证之前黑客拿不到数据, 但验证之后就挡不住了. ``` 修法的思路是: Google 登录这一下, 恰好证明了 "现在点按钮的这个人, 才是这个邮箱的主人". 所以在 token 里 `email_verified` 为真的前提下, 老账号如果还没验证过邮箱, 那它的密码就是一个不知道是谁设的密码, 应该作废; 同时把账号标成已验证, 因为 Google 已经替我们验证过了. ```python async def link_existing_user( session: AsyncSession, user: User, user_info: FirebaseUserInfo, ) -> None: # 1. 邮箱没被登录方 (Google 等) 验证过, 就不能拿 email 认人 if not user_info.email_verified: raise HTTPException(status_code=403, detail="FIREBASE_EMAIL_NOT_VERIFIED") # 2. 老账号自己没验证过邮箱: 它的密码是谁设的不知道, 作废掉 if not user.is_verified: user.hashed_password = PasswordHelper().hash(secrets.token_urlsafe(32)) user.has_set_password = False user.is_verified = True await revoke_all_user_refresh_tokens(session, user.id) # 3. 挂上 firebase_uid user.firebase_uid = user_info.firebase_uid session.add(user) await session.commit() ``` 逐条说一下: - 第 1 条挡的是 "登录方没验证过邮箱" 的情况. Google 给的 gmail 一定是验证过的, 但以后接别的登录方式时, 有的 provider 允许用户填一个没验证的邮箱, 那这个邮箱就不能拿来认人. - 第 2 条是关键. 老账号如果自己已经验证过邮箱, 说明设密码的人能收到这个邮箱的信, 就是本人, 直接关联, 密码保留. 没验证过的, 密码作废, `has_set_password` 改回 `False`, 用户之后想用密码登录, 走 "设置密码" 重新设一个. 顺手把这个账号所有的 refresh token 都撤销掉, 黑客之前登录拿到的会话就续不上了. - 黑客手里已经签发的 JWT access token 撤不掉, 因为 JWT 是无状态的, 要等它自然过期 (我设的是 1 小时). 这段时间里他的账号状态还是未验证, 业务接口本来就进不去. 修完之后, 抢注的人最多是让 alice 的账号多了一个他设过又被作废的密码, 什么也拿不到. ## 前端: lib/firebase.ts 和登录按钮的处理 前端用 npm 上的 `firebase` 包 (模块化 API). 配置全部从 `NEXT_PUBLIC_FIREBASE_*` 环境变量里读, 本地放 `.env.local`, 线上放 Vercel 的环境变量. 前缀必须是 `NEXT_PUBLIC_`, 否则 Next.js 不会把它打进浏览器端的代码: ```typescript // lib/firebase.ts import { getApps, initializeApp, type FirebaseApp } from "firebase/app"; import { getAuth, GoogleAuthProvider, signInWithPopup, type Auth } from "firebase/auth"; const firebaseConfig = { apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, }; let app: FirebaseApp | undefined; let auth: Auth | undefined; function getFirebaseAuth(): Auth { if (!auth) { app = getApps()[0] ?? initializeApp(firebaseConfig); auth = getAuth(app); } return auth; } /** 弹出 Google 登录窗口, 返回要发给后端的 Firebase ID Token. */ export async function signInWithGoogle(): Promise { const provider = new GoogleAuthProvider(); provider.addScope("email"); provider.addScope("profile"); const result = await signInWithPopup(getFirebaseAuth(), provider); return result.user.getIdToken(); } export async function signOutFromFirebase(): Promise { await getFirebaseAuth().signOut(); } ``` 登录页上 "Sign in with Google" 按钮的处理函数. 这里的 `setToken`, `setUserEmail`, `showToast`, `getErrorMessage` 是项目里密码登录本来就有的工具函数: ```typescript const handleGoogleSignIn = async () => { setGoogleLoading(true); try { const idToken = await signInWithGoogle(); const response = await fetch("/api/auth/firebase", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id_token: idToken }), credentials: "include", // 让浏览器收下后端设置的 refresh token cookie }); const data = await response.json(); if (response.ok) { setUserEmail(data.email); setToken(data.access_token); showToast(data.is_new_user ? "Account created successfully!" : "Login successful!", "success"); router.push("/dashboard"); } else { // 后端的 detail 是错误码, 比如 FIREBASE_TOKEN_INVALID showToast(getErrorMessage(data.detail), "error"); } } catch (error: unknown) { const code = (error as { code?: string }).code; if (code === "auth/popup-closed-by-user") { showToast("Sign in cancelled.", "info"); } else { showToast("Google sign in failed. Please try again.", "error"); } } finally { setGoogleLoading(false); } }; ``` 两个要注意的地方. `credentials: "include"` 不能漏, 不然浏览器不会保存后端写的 refresh token cookie, 用户一小时后就会被踢出去. 后端返回的 `detail` 是固定的错误码 (`FIREBASE_AUTH_DISABLED`, `FIREBASE_NOT_INITIALIZED`, `FIREBASE_TOKEN_INVALID`, `FIREBASE_EMAIL_REQUIRED`, `FIREBASE_EMAIL_NOT_VERIFIED`), 前端用一张表把它们翻译成给用户看的话, 这样后端改措辞不用动前端. 我用的是 `signInWithPopup`, 没用 `signInWithRedirect`. 原因在 {ref}`XhBloryvDBJp` 的结尾讲过: 前端在 Vercel 上, 浏览器挡第三方存储之后, redirect 方式默认会坏. ## 怎么确认接通了, 以及接不通时查哪里 我是手动测的, 三个场景, 每次都用无痕窗口: 1. 新用户. 用一个从没在 App 里出现过的 Google 账号点登录. 应该弹出 Google 授权窗口, 授权后跳到 dashboard, 提示 "Account created successfully". 数据库里多一条用户, `firebase_uid` 有值, `is_verified` 为真, `has_set_password` 为假. 2. 老的 Google 用户. 同一个 Google 账号再登录一次. 应该直接登录, 提示 "Login successful", 数据库里没有多出新用户. 3. 先密码后 Google. 先用某个 gmail 走邮箱密码注册, 然后用同一个 gmail 点 Google 登录. 要分两种情况测: 如果先完成了邮箱验证, 应该登录成功, `firebase_uid` 被挂上, 原来的密码照样能用; 如果没验证邮箱就点 Google, 也应该登录成功, `is_verified` 变成真, 但原来的密码失效了, 要走 "设置密码" 重设. 接不通时, 按报错查: | 现象 | 多半是哪里 | 怎么查 | | :--- | :--- | :--- | | 弹窗出现后马上报 `auth/unauthorized-domain` | 当前域名不在授权域名里 | Console 的 "Authorized domains", 把当前访问的域名加进去. 用局域网 IP 访问时也会这样 | | 点按钮没反应, 或报 `auth/popup-blocked` | 浏览器挡了弹窗 | 允许本站弹窗. 登录必须由用户点击直接触发, 不要在 `await` 了别的东西之后再调 `signInWithPopup` | | 后端返回 503 `FIREBASE_NOT_INITIALIZED` | 启动时没取到私钥 | 看启动日志里 `init_firebase` 的报错. 常见的是 Vercel 上的 AWS 凭证没权限读 SSM, 或者参数名拼错 | | 后端返回 401 `FIREBASE_TOKEN_INVALID` | token 和后端不是同一个 Firebase 项目 | 前端的 `projectId` 和服务账号 JSON 里的 `project_id` 必须一致. 服务器时钟偏差太大也会让 token 被判过期 | | 后端返回 400 `FIREBASE_EMAIL_REQUIRED` | token 里没有邮箱 | 检查是否请求了 `email` scope. Apple 登录用户可以拒绝共享邮箱 | | 登录成功, 一小时后被登出 | refresh token cookie 没存下来 | 前端 `fetch` 有没有带 `credentials: "include"`, cookie 的 `secure` 和 `samesite` 设置和当前是不是 HTTPS 对得上 | ## 以后加 Apple 等登录方式: 只改 Console 和前端, 以及还没做的事 这套做法最值的地方, 是以后加登录方式基本不碰后端. 以 Apple 为例: 在 Console 的 "Sign-in method" 里启用 Apple, 按它的引导去 Apple Developer 后台建 Service ID, 上传密钥; 前端加一个按钮, 把 `GoogleAuthProvider` 换成 `OAuthProvider("apple.com")`. 后端的 `/api/auth/firebase` 不用改, 因为 Apple 登录换来的也是同一种 Firebase ID Token. 唯一要留意的是 Apple 用户可能不给邮箱, 后端会返回 `FIREBASE_EMAIL_REQUIRED`, 到时候要决定是放宽这条, 还是提示用户必须共享邮箱. 这部分我还没做过, 具体的 Apple 配置以 [Firebase 官方文档](https://firebase.google.com/docs/auth/web/apple) 为准. 还没做的事, 也记在这里: - 只用了 `signInWithPopup`. 手机浏览器上弹窗体验差, 要换成 redirect 方式, 得先按官方方案处理 Vercel 上的第三方存储问题. - Google 登录接口没有自动化测试, 上面的三个场景都是手动测的. 补测试时, 要 mock 掉 `verify_firebase_token`, 重点测 `link_existing_user` 的三条分支. - 写这篇时 App 还没上生产. 真实用户里同邮箱撞号有多常见, 弹窗被拦的比例有多高, 我现在都不知道.