从 OAuth2.0 到 OIDC
02-18 / 2025

OAuth2.0:第三方授权的基石

OAuth2.0 作为现代互联网的授权标准协议,其核心价值在于允许第三方应用在用户授权后访问特定资源,而无需共享用户密码。以 GitHub 的 OAuth 流程为例,典型授权过程分为三步:

  1. 用户通过客户端跳转至授权端点(authorization_endpoint)
  2. 授权服务器通过回调地址返回授权码(code)
  3. 客户端使用授权码换取访问令牌(access_token)

这是 github 的 oauth2.0 接入文档,在文档中有详细描述整个认证流程。

https://docs.github.com/zh/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps

如图,三步就可以完成整个流程。

OAuth2.0 的配置困境

从 github 的认证流程文档我们就能看到,作为接入方的我们需要为 github 定义三个常量:

  • authorization_endpoint:请求认证的地址,当授权成功,会携带 code 跳转到传递给它的 redirect_uri
  • token_endpoint:通过 code 换取 token 的 API 地址
  • userinfo_endpoint:获取用户信息的 API 地址

这就导致了接入不同的 OAuth 提供方时,需为每个提供方维护不同端点。当接入多个平台时会产生大量重复工作。

OAuth2.0 缺少”认证“逻辑

OAuth 协议中没有定义如何认证用户,而依赖认证系统比如 Google 自己通过账号密码进行用户认证,也就是常说的“OAuth2.0 仅负责授权,不验证用户身份“。

这看似和我们第三方没有什么关系,但其实会间接导致另一个问题:

OAuth 协议最后只会给第三方访问令牌(Token)来授权第三方使用用户的部分数据,而没有告诉第三方这个用户是谁,第三方无法区分 A 用户和 B 用户,进而第三方也无法为这位用户分配属于他自己系统的唯一身份。

这里有一个常见误解,许多应用使用 OAuth 2.0 实现“社交登录”(如“用 Google 账号登录”),那这些应用是如何知道用户是谁的呢?

答案是:虽然 OAuth 没有规定如何识别用户是谁,但有了 Token 之后,第三方其实可以调用各个 Provider 的接口获取用户信息,如用户的 id / openid,这样第三方就知道用户是谁。

所以我们平时使用的 ”OAuth 第三方登录“,实则已经不是只有 OAuth 了,只是部分依赖了 OAuth 协议。

而上面提到的解决方案(通过接口再次调用接口获取用户信息)有什么问题呢?

由于获取用户信息的接口没有被纳入规范,所以各个 Provider 的接口千奇百怪,并不好适配,而大多数情况下,用户信息是被需要的,这就导致又需要为不同的 Provider 写不同的代码。

题外话:如果你和我一样 对 ”授权“,”认证“ 有点模糊,那么可以问问参考下下面 Deepseek 的答案:

如何理解”授权“, ”认证“。

这两个概念确实打脑壳,让 deepseek r1 帮我理一下:

授权(Authorization):OAuth 2.0 的核心功能是允许第三方应用(Client)在用户同意后,安全地获取访问资源的权限(如访问用户的云存储文件或社交媒体数据)。

认证(Authentication):验证用户身份(例如确认用户是“谁”)并非 OAuth 2.0 的职责,而是由 **授权服务器(Authorization Server)**通过其他机制(如用户名/密码、多因素认证等)完成的。

OAuth 2.0 的流程中隐含认证,但协议本身不定义认证

在 OAuth 流程中,用户通常需要登录到授权服务器(如 Google、Facebook)以同意授权请求。虽然这看似是“认证”,但:

  • 认证由授权服务器独立完成:OAuth 2.0 协议不规定如何认证用户,而是依赖授权服务器自行实现认证逻辑(例如通过传统登录表单或生物识别)。
  • OAuth 仅关注授权结果:协议只关心用户是否同意授权,并返回访问令牌(Access Token),而不关心用户是如何被认证的。

实际场景中的误解

       • “使用 OAuth 登录”的真相:许多应用使用 OAuth 2.0 实现“社交登录”(如“用 Google 账号登录”),这实际上是通过 OAuth 的授权流程间接获取用户身份信息。但这种身份信息是由授权服务器提供的(例如通过 OpenID Connect 扩展),而非 OAuth 2.0 原生支持的功能。

OIDC 的改进

DiscoveryUrl:实现配置信息的动态获取

DiscoveryUrl 是 OIDC 协议中的一个标准化机制,用于动态获取 OIDC 提供者的配置信息。它简化了客户端的配置,使客户端能够自动适应不同的 OIDC 提供者。通过 DiscoveryUrl,客户端可以获取授权端点、令牌端点、用户信息端点等关键信息,从而实现身份验证和授权流程。

一般的 DiscoveryUrl 通常是 OIDC 提供者的基础 URL 加上标准路径 /.well-known/openid-configuration。例如:https://accounts.google.com/.well-known/openid-configuration

访问这个地址会返回如下内容:

{
  "issuer": "<https://accounts.google.com>",
  "authorization_endpoint": "<https://accounts.google.com/o/oauth2/v2/auth>",
  "token_endpoint": "<https://oauth2.googleapis.com/token>",
  "userinfo_endpoint": "<https://openidconnect.googleapis.com/v1/userinfo>",
  "jwks_uri": "<https://www.googleapis.com/oauth2/v3/certs>",
  "response_types_supported": ["code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "scopes_supported": ["openid", "email", "profile"]
}

可以看到这里面包括了 OAuth 需要用到的所有端点(Endpoint),所以有了 DiscoveryUrl,我们就能实现配置信息的动态获取。

在各个语言应该都有 OIDC 库用于请求 DiscoveryUrl 获取 OAuth 流程中需要的配置。

例如 golang 中的这个库:https://github.com/coreos/go-oidc

provider, err := oidc.NewProvider(ctx, "<https://accounts.google.com>")
if err != nil {
    // handle error
}

// Configure an OpenID Connect aware OAuth2 client.
oauth2Config := oauth2.Config{
    ClientID:     clientID,
    ClientSecret: clientSecret,
    RedirectURL:  redirectURL,

    // Discovery returns the OAuth2 endpoints.
    Endpoint: provider.Endpoint(),

    // "openid" is a required scope for OpenID Connect flows.
    Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}

ID Token:身份认证的标准化方案

而大多数情况下,用户信息是被需要的,所以规范就诞生了:ID Token.

OIDC 协议规定通过 code 换取 token 的时候,除了返回 access_token,还会返回 id_token。

ID Token 是一个 JWT(JSON Web Token),包含用户的身份信息(如用户 ID、姓名、电子邮件等)。包含标准声明(如 iss、sub、aud、exp 等)和自定义声明。

例如:

{
  "iss": "<https://accounts.google.com>",
  "sub": "1234567890",
  "aud": "client-id",
  "exp": 1672531199,
  "name": "John Doe",
  "email": "[email protected]"
}

其中 sub 就是用户的唯一标识符。

!需要注意的是,JWT 需要另外的用 Provider 提供的公钥来验证其有效性,这点我们后面在“安全”部分详细介绍下。

UserInfo Endpoint

另外 OIDC 同时也定义了 “UserInfo Endpoint

IDC 中的 UserInfo Endpoint 规定了返回的用户信息格式。根据 OpenID Connect Core 1.0 规范,UserInfo Endpoint 返回的响应必须是一个 JSON 对象,包含用户的相关声明(claims)。

必须包含的字段:

  • sub (Subject Identifier): 用户的唯一标识符,必须与 ID Token 中的 sub 声明一致。

可选字段:

  • name: 用户的全名。
  • given_name: 用户的名字。
  • family_name: 用户的姓氏。
  • middle_name: 用户的中间名。
  • nickname: 用户的昵称。
  • preferred_username: 用户的首选用户名。
  • profile: 用户个人资料的 URL。
  • picture: 用户头像的 URL。

现在我们要获取用户信息就有了比较统一的规范,有两个方法:

  • 直接解析 ID Token,里面会存放简单的如用户唯一标识(sub)的信息。
  • 如果我们需要更多信息,比如头像,我们可以请求 UserInfo Endpoint。

JWT 的安全哲学

为什么 ID Token 是 JWT 格式,而不是明文?

我们知道在通过 code 交换 token 的过程中,都是直接由服务端直接发起,并且流程全程使用HTTPS加密,ID Token 应该不会被泄露或者伪造才对。

我们来演练下如果没有 JWT 的防护可能发生的问题:

现在“支付宝“是 OAuth 使用方,而“百度“是提供方,另有个有钱的“百度用户“,百度账户 baidu_id = ‘richman’。

有一个有能力的攻击者劫持了 baidu.com 域名,现在支付宝在进行 百度 OAuth 授权过程中的信息就会被攻击者劫持,于是他使用自己的账号进行登录,并替换了 ID Token,把 ID Token 中的 sub 由 ‘poolman’ 替换成了 ‘richman’,而 支付宝 在收到 ID Token 之后并没有校验 ID Token 是否被篡改,于是攻击者登录上 支付宝 的账号变成了 richman,现在攻击者就可以随意使用 richman 的账号做非法的事情了。

JWT 如何避免这次攻击?只需要 支付宝 在使用 ID Token 的时候使用 百度 提供的公钥来校验下 ID Token 是否被篡改即可。

// JWT 验证示例(使用提供方公钥)
keySet := oidc.NewRemoteKeySet(ctx, "<https://www.googleapis.com/oauth2/v3/certs>")
verifier := provider.Verifier(&oidc.Config{ClientID: clientID})

idToken, _ := verifier.Verify(ctx, rawIDToken)
claims := struct {
    Email string `json:"email"`
    Sub   string `json:"sub"`
}{}
idToken.Claims(&claims)

这里就引生出安全设计原则 :纵深防御(Defense in Depth) 

  • 多层保护
    • HTTPS(传输安全) + JWT 签名(数据安全) + 短有效期(减少泄露影响)。
  • 不信任任何单一机制
    • 即使 HTTPS 被绕过(如服务器配置错误、证书伪造),JWT 签名仍能提供保护。

Golang OIDC 库的选择

截止 2025-02-18,Github 上 star 最多的两个库是:https://github.com/coreos/go-oidc(2k star)(前文提到了) 和https://github.com/zitadel/oidc(1.5k star)。

这两个库我都试用了,可以提供一些建议:

zitadel/oidccoreos/go-oidc 最大的功能区别是:前者支持 OP(OpenID Provider,OIDC 提供者)功能给,而后者只支持 RP(Relying Party,依赖方)。

zitadel/oidc 看似很强,但实际使用过程中,如果你不需要用的 OP 功能,那么还是不要碰这个库,因为这个库代码很复杂并且没有说明文档,大而全但定制复杂,有点过度设计嫌疑,不要以为 OIDC 是标准就无需定制了,并不是每个 Provider 都严格按照标准执行,很可能会需要针对不同 Provider 打补丁。

而后者 coreos/go-oidc 没有过度设计,就算这个库无法提供对应功能或者需要定制,我们可以直接回退到使用 oauth2 库并部分使用这个库的工具方法。

© 2024 bysir's Blog - by

Creght