在我用来起项目的 boilerplate 里,集成第一家认证服务商的提交,和把它换成第二家的提交,相隔两天。两天。第一个加了保护路由的中间件和一个登录界面;第二个把它整个拔掉,换上另一个 SDK 和它自带的 sign-in、sign-up 页面。从那以后,同一个模板又经历了两家服务商,并在将近一年之后,以一个手写的中间件收场。
这不是运气不好。这是一个模式,而且我在至少四个仓库里重复过。
我总是从托管服务商开始,原因也总是一样
项目第一天我不想写密码哈希、邮箱验证,也不想写 Google 的 OAuth 流程。装一个 SDK,配两个环境变量,一个下午就有登录了。那天这是正确的决定:项目自己还不知道一个月后是否还存在。
问题是这个 SDK 不会老实待在应用的边界上。它钻进 layout、钻进用户上下文、钻进每个需要知道调用者是谁的 server action,还钻进数据库 schema,因为它那张表的 user_id 最后会成为你所有表的外键。
四次迁移,以及各自的触发点
- 一号到二号,两天。 第一家能用,但定价模式和它对自家后台的耦合不合适。没有一行认证代码活过这次更换。
- 二号,共存六个月。 这里什么都有:README 里关于它和 Suspense 之间诡异行为的笔记、把受保护域名从配置文件里读出来的中间件重构,以及从
useProfile改成useUser,为了不再在它之上维护我自己的一层。 - 二号到三号。 一整个 PR,名字就叫 "migrate from" 加上一家的名字。动了三十个文件。删掉
stack.ts和 catch-all 路由app/handler/[...stack]/page.tsx,出现了客户端的auth.ts和服务端的app/api/auth/[...path]/route.ts。 - 三号到自写中间件。 提交信息字面写着 "replace neonAuth with custom auth middleware and update session handling"。托管 SDK 我只留下客户端;其余我自己控制。
永远会坏的东西:URL
第二次迁移里,同一天有两个前后相连的提交。第一个把配置文件留成了这样:
export const stackServerApp = new StackServerApp({
tokenStore: "nextjs-cookie",
urls: {
signIn: "/ass",
signUp: "/asdds",
},
});
这是你手工测试每个东西往哪儿跳转、然后忘了删掉乱敲的测试值时会留下的东西。第二个提交把两个都改回 /auth。这很可笑,而且正是每次认证迁移你都会吃到的那个问题:服务商不知道你的登录界面住在哪里,所以它的配置里有一个声明出来的 URL 必须和路由里真实存在的路径对上,而没有任何类型系统会检查这件事。它跳到 404,而症状不是"URL 写错了",而是"登录不好使"。
另一半会坏的是会话的形状。在切到自写中间件时,getSession 从返回 SDK 的对象变成返回一个显式的东西:
type SessionData = NonNullable<Awaited<ReturnType<typeof auth.getSession>>["data"]>;
export type Session = SessionData | { session: null; user: null };
那个 | { session: null; user: null } 是必须做出的让步。之前的 SDK 返回的是一个裸的 nullable,一半代码在写 session?.user。规范化之后,所有假定会话可能是 undefined 的地方都编译不过了。这才是迁移里昂贵的部分,不是登录。
自己写,我得到什么、失去什么
得到的很具体:对会话对象的控制、对 cookie 存在哪里的控制,以及对没有会话时到底发生什么的控制。还有一件我直到需要它才意识到价值的事:可以把授权规则放在同一个地方。
在给一家大公司做的 BI 工具里,授权最后成了中间件里的一个邮箱列表:
const authorizedEmails = [ /* ... */ ];
const hasFullAccess = userEmail && authorizedEmails.includes(userEmail);
配两种不同的跳转:未认证的去登录,已认证但没权限的去首页。用托管服务商做这个,就得跟它的 claims 或它的后台较劲。而在这里我用十行写完了。我还留了一个 console.log("User email:", userEmail),后来在一个提交里不得不删掉,而那个应用的认证在一个月里被重写了三次。
失去的是服务商做过而你看不见的一切:token 轮换、会话失效、找回密码邮件、登录的限流。这些都不是做不到,但它们变成了我的代码,而且没有不是我自己写的测试。
我现在的判断标准
不是"自己写认证"。而是更无聊的一条:从第一天起就假定你会换服务商。 我现在在装任何东西之前会做的:
- 我的代码里只有一个
getSession()函数。 没有任何组件 import 那个 SDK。返回类型由我定义,不从服务商那里推断。这是迁移三十个文件和迁移一个文件的区别。 - 我数据库里的
user_id是我的。 服务商的那个放在单独一列。否则迁移就不再是换个库,而变成了数据迁移。 - 登录 URL 放常量里,不放服务商配置里。 让
/ass无处藏身。 - 授权永远不放在服务商里面。 谁能进来是服务商的事;他能做什么是我代码的事。
不久前我起了个新项目,又一次从托管服务商开始。第一天进不去:认证的 base URL 指向 boilerplate 里的占位值。循环重新开始,而这次我确切知道哪里会疼。
