02架构5 min

我换过四次认证服务商

一年半里用过四家托管服务商,终点都一样:自己写的中间件。每次过渡会坏掉什么,以及现在我在写任何一行认证代码之前会先看什么。

作者 Salva Castells

封面图: Shixart1985, CC BY 2.0.
本文目录

在我用来起项目的 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 里的占位值。循环重新开始,而这次我确切知道哪里会疼。