The Hardest Part of a White-Label SaaS Was One Login Form

The Hardest Part of a White-Label SaaS Was One Login Form

构建白标 SaaS 最难的部分:一个登录表单

The hardest part of building a white-label SaaS was not the AI, the custom domains, or the billing. It was one login form. I build VoiceDash, a white-label platform where agencies resell AI voice agents to their own clients under their own brand. On paper it is a voice product. In practice, the thing that kept me up at night was authentication, because a white-label product has a structural problem most apps never face: one login form has to serve two completely different kinds of person, and neither one is ever allowed to become the other. This is the story of how I solved that with a single auth provider and one small discriminator field, and the edge-runtime gotcha that nearly broke the whole thing.

构建白标(White-label)SaaS 最难的部分不是 AI、自定义域名或计费系统,而是一个登录表单。我开发了 VoiceDash,这是一个白标平台,代理商可以在该平台上以自己的品牌向客户转售 AI 语音代理。从纸面上看,它是一个语音产品。但在实践中,让我彻夜难眠的是身份验证,因为白标产品面临着大多数应用从未遇到的结构性问题:同一个登录表单必须服务于两种完全不同的人,且两者绝对不能越界。这就是我如何通过单一身份验证提供商和一个小的判别字段来解决这一问题的过程,以及那个差点毁掉整个项目的边缘运行时(Edge-runtime)陷阱。

Two users who must never overlap

绝不能重叠的两类用户

The hierarchy looks like this. An agency signs up. That agency is a Workspace. Inside the workspace, the agency’s own staff are WorkspaceMembers. The agency then creates Clients, and each Client has its own ClientMembers, the actual end users logging into a portal that is branded to look like the agency built it themselves. So there are two kinds of human hitting the login screen:

层级结构如下:代理商注册后成为一个“工作区”(Workspace)。在工作区内,代理商的员工是“工作区成员”(WorkspaceMembers)。代理商随后创建“客户”(Clients),每个客户拥有自己的“客户成员”(ClientMembers),即登录到品牌化门户网站的最终用户,这些门户看起来就像是代理商自己开发的一样。因此,登录界面会面对两类人:

  • Agency users. My paying customers. They log in with email and password, and they administer everything: clients, agents, billing, branding.

  • Client members. My customers’ customers. They log into a workspace-branded portal, they only ever see their own client’s data, and they have no idea VoiceDash exists.

  • 代理商用户:我的付费客户。他们通过邮箱和密码登录,管理一切:客户、代理、计费和品牌。

  • 客户成员:我客户的客户。他们登录到带有工作区品牌的门户,只能看到自己客户的数据,且完全不知道 VoiceDash 的存在。

The invariant that matters more than anything else: a client member must never be able to reach an agency route, and must never see another client’s data. If that breaks, one agency’s customer can read another agency’s customers. That is not a bug, that is the end of the business.

最核心的不变原则是:客户成员绝不能访问代理商路由,也绝不能看到其他客户的数据。如果这一点被破坏,意味着一家代理商的客户可以读取另一家代理商客户的数据。这不仅仅是一个 Bug,而是业务的终结。

The tempting move is to build two auth systems. Two providers, two session shapes, two sets of middleware, two of everything. I did not want to maintain two of everything. So I built one.

最诱人的做法是构建两套身份验证系统。两个提供商、两种会话结构、两套中间件,所有东西都搞两份。但我不想维护两套系统,所以我只构建了一套。

One provider, one discriminator

一个提供商,一个判别字段

VoiceDash runs on NextAuth with a single CredentialsProvider. The trick is a type field on the credentials, either “agency” or “client”, and the authorize function branches on it:

VoiceDash 基于 NextAuth 运行,并使用单一的 CredentialsProvider。诀窍在于凭据中包含一个 type 字段,值为 “agency” 或 “client”,authorize 函数会根据该字段进行分支处理:

async authorize(credentials) {
  if (!credentials) return null;
  const type = (credentials.type as string) || "agency";
  
  if (type === "client") {
    // client members log in by loginId OR email, password optional
    const loginId = credentials.loginId as string;
    if (!loginId) return null;
    let clientMember = await prisma.clientMember.findUnique({ /* ... */ });
    return { 
      id: clientMember.user.id, 
      clientId: clientMember.clientId, 
      workspaceId: clientMember.client.workspaceId, 
      type: "client", 
    };
  }
  
  // agency users log in by email + password
  const user = await prisma.user.findUnique({ /* ... */ });
  return { 
    id: user.id, 
    workspaceId: member?.workspaceId || null, 
    type: "agency", 
  };
}

The two branches are genuinely different. Agency login is a plain email plus password. Client login accepts a loginId or an email, and the password is optional, because some agencies onboard their clients with no password at all and let them in by login ID. Two flows, two lookups, two shapes of returned user. But one provider, and both return an object carrying type. That type is the entire security model in one word.

这两个分支确实不同。代理商登录是标准的邮箱加密码。客户登录接受 loginId 或邮箱,且密码是可选的,因为有些代理商在引导客户时不需要密码,仅通过 loginId 即可进入。两个流程、两次查询、两种返回的用户结构,但只有一个提供商,且两者都返回一个带有 type 的对象。这个 type 就是整个安全模型的核心。

Bake it into the token, read it everywhere

写入 Token,随处读取

A returned user object is not enough on its own. It has to survive into every future request. VoiceDash uses JWT sessions, so the discriminator gets written into the token once and read back on every request. Now every piece of the app can ask one question, session.type, and know exactly who it is talking to. Data queries scope by workspaceId or clientId from the token, never from anything the client sends in the request. That last part is the whole game: the tenant boundary comes from the signed token, not from a URL parameter or a request body a client could tamper with.

仅返回用户对象是不够的,它必须在后续的每个请求中持续存在。VoiceDash 使用 JWT 会话,因此判别字段会被写入 Token,并在每次请求时读取。现在,应用的任何部分只需询问 session.type,就能准确知道正在与谁交互。数据查询通过 Token 中的 workspaceIdclientId 进行范围限定,绝不依赖客户端发送的任何内容。最后这一点至关重要:租户边界来自签名的 Token,而不是客户端可以篡改的 URL 参数或请求体。

The gate that enforces it

执行访问控制的关卡

All of that would be theory without one place that actually turns people away. In this version of Next.js the middleware file is proxy.ts, and it is the single gate every request passes through:

如果没有一个真正拦截非法访问的地方,上述一切都只是理论。在当前版本的 Next.js 中,中间件文件是 proxy.ts,它是每个请求必须经过的唯一关卡:

// agency routes require an agency session
if (pathname.startsWith("/agency")) {
  if (!session) return NextResponse.redirect(new URL("/login", req.url));
  if ((session as any).type !== "agency") {
    return NextResponse.redirect(new URL("/client/agents", req.url));
  }
  return NextResponse.next();
}

That type !== "agency" check is load bearing. A logged-in client member who types /agency/clients into the address bar does not get a 500 or a blank page, they get bounced back to their own portal. The same file also splits the root path: on the app subdomain the root sends you to your dashboard or the login page, while on the marketing domain the root renders the public landing page. One file, four decisions, all driven off the same token.

那个 type !== "agency" 的检查是至关重要的。如果一个已登录的客户成员在地址栏输入 /agency/clients,他们不会看到 500 错误或空白页,而是会被重定向回他们自己的门户。同一个文件还处理了根路径的分流:在应用子域名上,根路径会将你导向仪表盘或登录页,而在营销域名上,根路径则渲染公共落地页。一个文件,四个决策,全部由同一个 Token 驱动。

The gotcha that cost me an afternoon: edge cannot see your database

让我耗费一下午的陷阱:边缘运行时无法访问数据库

Here is the part I wish someone had told me. Middleware runs on the edge runtime. The edge runtime cannot import bcrypt, and it cannot import Prisma. If you try, your build does not fail politely, it fails at the exact moment the middleware tries to load, which feels like the auth itself is broken. The fix is to split the config in two. There is an edge-safe auth.config.ts that holds only the callbacks, the pages, the session strategy, and an empty providers: [] array. It imports nothing from Node. Then a separate auth.ts

这是我希望有人早点告诉我的部分。中间件运行在边缘运行时(Edge runtime)上。边缘运行时无法导入 bcrypt,也无法导入 Prisma。如果你尝试这样做,构建过程不会友好地报错,而是在中间件尝试加载的那一刻直接失败,这看起来就像是身份验证本身坏了一样。解决方法是将配置一分为二:创建一个边缘安全的 auth.config.ts,仅包含回调、页面、会话策略和一个空的 providers: [] 数组,且不从 Node.js 导入任何内容。然后,再创建一个独立的 auth.ts……