OIDC:登录背后的身份边界

从个人产品和 AI 应用的真实工程需求出发,聊聊 OpenID Connect 解决的到底是什么问题、什么时候适合使用、和 OAuth、SAML、CAS、LDAP、WebAuthn 等方案有什么区别。

最近把几个小应用的登录体系重新梳理了一遍,越做越觉得,很多应用真正绕不开的东西其实不多。

第一是登录。用户是谁,怎么进来,进来以后在系统里是什么身份。

第二是付费。用户为什么付钱,付了多少钱,付完以后在产品里能得到什么。

剩下的功能当然重要,甚至决定产品有没有价值。但如果登录和付费没有想清楚,应用就像一间开在路边的小店:门口没人看,账本也没人记。白天也许热闹,晚上盘账时就知道问题来了。

AI 时代以后,这两个问题反而更重要。以前一个网页工具被人多点几次,最多是带宽和服务器压力。现在很多 AI 应用每一次真正使用,后面都有模型调用、额度消耗和成本账单。未登录用户能不能用?免费额度算给谁?付费用户的套餐和权限放在哪里?一个用户从另一个应用过来,算不算同一个人?

这些问题最后都会回到一个很朴素的地方:身份。

OIDC 就是解决这个问题的一套标准方法。

统一身份中心像一个安静的通行证签发处

English version: OIDC: The Identity Boundary Behind Login

先说清楚:OIDC 是什么

OIDC,全称 OpenID Connect。它建立在 OAuth 2.0 之上,用来做身份认证。

一句话说:

OAuth 2.0 主要回答:这个客户端能不能访问某个资源?
OIDC 主要回答:当前登录的这个人是谁?

平时我们说“使用 Google 登录”“使用 GitHub 登录”“公司统一账号登录”,严格讲,很多时候说的都不只是 OAuth,而是 OAuth 之上的身份协议,也就是 OIDC。

OIDC 最关键的产物是 id_token。它通常是一个 JWT,里面有几个重要字段:

{
  "iss": "https://accounts.example.com",
  "sub": "acct_123",
  "aud": "my-web-app",
  "exp": 1780000000,
  "email": "user@example.com",
  "email_verified": true
}

这里最重要的是三个:

  • iss:谁签发的身份。
  • sub:这个人在签发方那里的稳定身份 ID。
  • aud:这个身份结果是签给哪个应用的。

应用拿到 id_token 后,不是看一眼邮箱就算登录成功。它要校验签名、校验 iss、校验 aud、校验过期时间,再用 iss + sub 找到或创建自己的本地用户。

这件事看似麻烦,但它解决了一个非常根本的问题:身份不是靠前端说的,也不是靠 URL 里带的,更不是靠某个应用自己猜的。身份由一个明确的签发方签发,应用按标准验证。

这就是边界。

为什么不是自己写一套登录接口

小应用一开始最容易这么做:

POST /login
  email + code
  -> 返回 token

这当然能用。我也不反对在早期这么做。很多项目不是死在“不够标准”,而是死在还没上线就先给自己造了一座大教堂。

问题在于,应用一多,这套简单接口会慢慢变形。

第一个应用要邮箱登录。第二个应用要 GitHub 登录。第三个应用想接 Google。第四个应用又需要小程序登录。后台也要登录。CLI 也要登录。某个独立应用还想复用同一套账号。

如果每个应用都自己做一遍,很快就会出现这些问题:

  • 用户在 A 应用登录了,到 B 应用又要重新注册。
  • 同一个邮箱在不同应用里变成不同用户。
  • 三方登录的回调、密钥、状态校验散在各处。
  • 邮箱验证码、限流、风控、封禁、审计重复建设。
  • 后面想做统一会员、跨应用权益、账号合并时,发现地基是散的。

登录接口本身不复杂,复杂的是长期维护身份关系。

OIDC 的意义就在这里。它不是让登录按钮变得更漂亮,而是把“谁来证明用户身份”这件事独立出来。

一个中心身份服务负责认证:用户是谁、用什么方式登录、邮箱有没有验证、账号是否被禁用。各个应用负责业务:这个用户在本应用里叫什么、有什么角色、有没有套餐、额度还剩多少、是否欠费。

这两个东西不要揉在一起。

中心身份和本地用户表

做统一登录时,很容易走向另一个极端:既然有统一身份中心,那是不是所有应用都共用一张用户表?

我的结论是,不要。

更稳的结构是:

统一身份中心
  identity: acct_123
  email: user@example.com
  providers: email / google / github

应用 A
  user_id: 1
  auth_issuer: https://accounts.example.com
  auth_subject: acct_123
  role: admin
  plan: pro

应用 B
  user_id: 58
  auth_issuer: https://accounts.example.com
  auth_subject: acct_123
  credits: 1200
  status: active

中心身份回答“这是同一个人”。应用本地用户表回答“这个人在我这里是什么状态”。

这个分工很重要。

登录是全局问题,付费和权限往往是应用问题。一个人在写作工具里是会员,不代表他在图片工具里也有同样额度;一个人在后台里是管理员,不代表他在另一个产品里也该有管理权限;一个账号被中心禁用,所有应用都应该拦住,但某个应用里的业务封禁,也不一定要影响他使用别的应用。

如果把所有东西都塞进 id_token,最后 token 会变成一个小型数据库。它看起来很强,实际很危险。

id_token 适合放身份事实,不适合放频繁变化的业务状态。套餐、额度、积分、订单、角色、封禁原因,这些最好还是在应用自己的系统里查。

一个中心身份映射到多个应用自己的用户账本

一个最小登录流程

OIDC 听起来有很多术语,但最常用的流程可以先按这一条理解:Authorization Code + PKCE。

大概是这样:

1. 用户在应用里点击登录。
2. 应用生成 state、nonce、code_verifier 和 code_challenge。
3. 应用把用户跳转到身份中心 /authorize。
4. 用户在身份中心完成登录。
5. 身份中心带着临时 code 跳回应用。
6. 应用校验 state。
7. 应用用 code + code_verifier 去 /token 换 token。
8. 应用校验 id_token。
9. 应用用 iss + sub 查找或创建本地用户。
10. 应用建立自己的 session。

这里有几个词容易混:

code 是一次性的临时票据,不是登录态。

id_token 是身份结果,用来证明用户是谁。

access_token 是访问资源的凭证,不应该被简单当成“用户是谁”的证明。

refresh_token 是续期凭证,能不能发、发给谁、怎么轮换,要非常谨慎。

对普通 Web 应用来说,最舒服的结构通常是:后端完成回调和 token 交换,后端校验 id_token,然后给浏览器写自己的 HttpOnly Cookie。浏览器不需要长期拿着身份供应商的 token 在前端跑来跑去。

OIDC 登录中 code 回跳和后端 token 交换的简化路径

一个极简的伪代码是:

app.get('/auth/start', (req, res) => {
  const state = randomString();
  const nonce = randomString();
  const verifier = randomString();
  const challenge = sha256base64url(verifier);

  saveTemporaryLoginState(req, { state, nonce, verifier });

  res.redirect(
    'https://accounts.example.com/authorize?' +
      new URLSearchParams({
        response_type: 'code',
        client_id: 'my-web-app',
        redirect_uri: 'https://app.example.com/auth/callback',
        scope: 'openid email profile',
        state,
        nonce,
        code_challenge: challenge,
        code_challenge_method: 'S256',
      }),
  );
});

app.get('/auth/callback', async (req, res) => {
  const saved = readTemporaryLoginState(req);
  if (req.query.state !== saved.state) {
    return res.status(400).send('bad state');
  }

  const tokens = await exchangeCodeForTokens({
    code: req.query.code,
    codeVerifier: saved.verifier,
  });

  const claims = await verifyIdToken(tokens.id_token, {
    issuer: 'https://accounts.example.com',
    audience: 'my-web-app',
    nonce: saved.nonce,
  });

  const user = await findOrCreateLocalUser({
    issuer: claims.iss,
    subject: claims.sub,
    email: claims.email,
  });

  writeLocalSessionCookie(res, user.id);
  res.redirect('/dashboard');
});

真实项目里还要处理错误、过期、回跳地址白名单、Cookie 安全属性、日志和限流。但主干就是这几步。

什么场景适合用 OIDC

OIDC 适合这些场景:

第一,有多个应用需要共用登录。

哪怕现在只有两个应用,也值得提前想一下。登录一旦散出去,后面再收回来会很费劲。用户数据、三方账号、邮箱验证、旧 token、历史订单,全会变成迁移问题。

第二,想把“登录”和“业务用户”分开。

这对有付费、积分、额度、团队、角色、封禁等业务状态的应用尤其重要。中心身份不要变成超级业务用户表,应用本地用户表也不要重复造一整套登录供应商。

第三,需要接入第三方身份。

Google、Microsoft、企业身份、组织账号,这些天然适合按 OIDC 思路理解。即使某些平台表面文档写的是 OAuth 登录,背后也往往会给 id_token 或提供类似的身份验证能力。

第四,需要给 CLI、移动端、独立服务提供统一登录。

Web 可以走 Authorization Code + PKCE。CLI 在有浏览器时也可以走 PKCE;没有浏览器时,可以考虑 device code flow。移动端要用系统浏览器或平台推荐方式,不要让用户在不可信 WebView 里输入密码。

第五,未来可能做跨应用会员或统一账号治理。

付费本身不一定在身份中心里做,但身份中心至少要稳定回答“这是哪个人”。否则订单、权益、额度、风控很难跨应用协调。

什么场景不必急着用

不是所有项目都需要 OIDC。

如果只是一个很小的团队工具,只有一个 Web 端,用户也很少,简单 session 登录足够。

如果应用完全依赖某个平台,比如只在某个小程序里运行,用户体系也只围绕平台 openid 展开,那么先把平台登录做好更实际。

如果只是服务间调用,机器访问机器,重点不是“用户是谁”,而是“这个服务有没有权限”,那 API key、mTLS、OAuth client credentials 可能更合适。

如果只是想让用户不用输密码,WebAuthn / Passkey 是很好的登录方式,但它不是 OIDC 的替代品。Passkey 更像一种认证手段,OIDC 更像多个应用之间传递身份结果的协议。

还有一种情况也要谨慎:为了显得正规,先上一个很重的身份平台,然后业务还没跑起来,配置、回调、证书、client、realm、scope 已经把人绕晕了。

标准是为了降低长期复杂度,不是为了在第一天制造复杂度。

和几种常见方案的区别

OAuth 2.0 和 OIDC 最容易混。

OAuth 的核心是授权。比如一个应用想读取用户的网盘文件,用户同意以后,应用拿到 access token 去访问网盘 API。这里重点是“能不能访问资源”。

OIDC 在 OAuth 上加了身份层。它标准化了 id_tokensubissaudnonce、Discovery、JWKS、UserInfo。它让应用可以按标准确认“登录用户是谁”。

SAML 更老,也很常见,尤其在企业 SSO 里。它基于 XML,企业软件生态成熟,但对现代 Web 和移动应用来说,开发体验通常不如 OIDC 轻。

CAS 多见于高校和早期组织系统,单点登录能力很直接,但生态和现代 API 场景不如 OIDC 普遍。

LDAP / Active Directory 更像目录服务。它擅长保存组织、用户、组和凭据,也常用于企业目录认证。但直接让现代 Web 应用到处接 LDAP,通常不如先放一层 OIDC Provider。

Session Cookie 是单个 Web 应用最常见的登录态。它和 OIDC 不冲突。很多时候最稳的做法正是:外部登录用 OIDC,应用自己的登录态用 HttpOnly Session Cookie。

JWT 也不是 OIDC 的替代品。JWT 只是 token 格式。自己签一个 JWT 不等于实现了 OIDC。OIDC 关心的是签发方、受众、发现机制、公钥、流程和校验规则。

WebAuthn / Passkey 解决的是“用户如何证明自己”。OIDC 解决的是“应用如何相信一个身份供应商给出的身份结果”。它们可以一起用:用户用 Passkey 登录身份中心,应用通过 OIDC 接收身份结果。

AI 时代为什么更该懂这些概念

AI 让写代码变快了,但也让很多系统问题更早暴露。

以前一个应用可以靠一张用户表和几个接口撑很久。现在应用之间更容易互相连接,后台、CLI、Agent、移动端、独立服务、自动化任务都可能变成同一套产品能力的入口。

AI 也会让更多人参与构建系统。一个人可以同时做前端、后端、后台、部署、支付、登录。能力变强以后,最危险的不是写不出代码,而是不知道边界应该放在哪里。

登录尤其如此。

登录一旦做错,后面不是改几个页面那么简单。用户身份、付费记录、权限、风控、账号合并、注销、审计,全都绑在一起。代码可以让 AI 帮忙写,概念要自己想明白。

我现在越来越觉得,独立开发者和小团队至少要有几个底层判断:

  • 登录不是一个按钮,是身份边界。
  • 付费不是一个回调,是业务权益和账本。
  • 中心身份不要吞掉应用用户表。
  • 应用用户表也不要重复造身份中心。
  • token 不是越多越好,scope 不是越大越好。
  • 用户是谁,和用户能做什么,要分开建模。

这些判断不花哨,但很管用。

最后

OIDC 不是必须每个项目第一天就上的东西。

但只要应用开始变多,只要你希望用户在多个产品之间有同一个身份,只要你不想在每个应用里重复维护邮箱、三方登录、验证码、风控和账号合并,就应该认真理解它。

它解决的不是“怎么做一个登录页”,而是“谁有资格证明用户是谁”。

这个问题一旦想清楚,后面的很多设计都会顺一点:身份中心负责认证,应用负责业务;id_token 证明身份,本地 session 维持登录;iss + sub 做稳定映射,付费和权限留在应用自己的账本里。

技术协议最后都会落回这种普通道理。

门要有人看,账要有人记。一个应用若想长期活下去,登录和付费这两件事,总归不能糊涂。