自定义协议走不通了
统一认证中心第一版只做了内部系统的 SSO,用的是自定义的 cookie + session 方案。后来要接入外部第三方应用,还要让企业客户拿自己的飞书/企微做身份源(IdP),自定义协议就走不通了。于是我决定把它改造成标准的 OAuth2/OIDC Provider:任何符合协议的客户端都能接入,认证中心自己也能作为 RP 去对接外部 IdP。
标准协议听起来就是实现几个端点的事,真落地才发现一堆工程细节等着:授权码模式的 PKCE、state/nonce 防 CSRF、redirect_uri 严格校验、ID Token 的签名与 claims、JWKS 公钥轮换。
四个端点
OIDC 在 OAuth2 之上加了身份层,核心要实现四个端点:
GET /oauth/authorize:用户登录与授权同意,返回 code;POST /oauth/token:用 code 换 access_token / id_token / refresh_token;GET /oauth/userinfo:用 access_token 取用户信息;GET /.well-known/openid-configuration 和 /oauth/jwks:发现文档与公钥集合。
授权码模式(Authorization Code)+ PKCE 作为默认,所有 public client 强制 PKCE;client credentials 留给服务间调用。

/authorize:先校验,再谈登录
这个端点先做参数校验,再看登录态,没登录就跳登录页,state 带上保证回跳:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| func (h *OAuthHandler) Authorize(c *gin.Context) {
var req AuthorizeReq
if err := c.ShouldBindQuery(&req); err != nil {
c.String(400, "invalid request")
return
}
app, err := h.appSvc.VerifyRedirectURI(c, req.ClientID, req.RedirectURI)
if err != nil {
c.String(400, "invalid redirect_uri")
return
}
if req.ResponseType != "code" {
c.Redirect(302, appendErr(req.RedirectURI, "unsupported_response_type", req.State))
return
}
// PKCE: code_challenge 必填
if req.CodeChallenge == "" || req.CodeChallengeMethod != "S256" {
c.Redirect(302, appendErr(req.RedirectURI, "invalid_request", req.State))
return
}
userID, loggedIn := session.GetUserID(c)
if !loggedIn {
c.Redirect(302, "/login?redirect="+url.QueryEscape(c.Request.RequestURI))
return
}
code, err := h.authSvc.CreateAuthCode(c, AuthCode{
AppID: app.AppID,
UserID: userID,
RedirectURI: req.RedirectURI,
Scope: req.Scope,
Nonce: req.Nonce,
CodeChallenge: req.CodeChallenge,
ExpiresAt: time.Now().Add(60 * time.Second),
})
if err != nil {
c.String(500, "server error")
return
}
u, _ := url.Parse(req.RedirectURI)
q := u.Query()
q.Set("code", code)
q.Set("state", req.State)
u.RawQuery = q.Encode()
c.Redirect(302, u.String())
}
|
/token:验完 code 和 PKCE 才发 token
客户端拿到 code 之后来换 token,服务端要校验 code 和 PKCE verifier,都过了才签发:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
| func (h *OAuthHandler) Token(c *gin.Context) {
if err := c.Request.ParseForm(); err != nil {
c.JSON(400, tokenErr("invalid_request"))
return
}
grant := c.PostForm("grant_type")
clientID, secret, ok := c.Request.BasicAuth()
if !ok {
c.JSON(401, tokenErr("invalid_client"))
return
}
app, err := h.appSvc.Authenticate(c, clientID, secret)
if err != nil {
c.JSON(401, tokenErr("invalid_client"))
return
}
switch grant {
case "authorization_code":
code := c.PostForm("code")
verifier := c.PostForm("code_verifier")
ac, err := h.authSvc.ConsumeAuthCode(c, code, app.AppID)
if err != nil {
c.JSON(400, tokenErr("invalid_grant"))
return
}
if !pkce.Verify(ac.CodeChallenge, verifier) {
c.JSON(400, tokenErr("invalid_grant"))
return
}
pair, err := h.tokenSvc.Issue(OIDCTokenInput{
UserID: ac.UserID,
AppID: app.AppID,
Nonce: ac.Nonce,
Scope: ac.Scope,
AuthTime: ac.CreatedAt,
})
if err != nil {
c.JSON(500, tokenErr("server_error"))
return
}
c.JSON(200, gin.H{
"access_token": pair.AccessToken,
"id_token": pair.IDToken,
"refresh_token": pair.RefreshToken,
"token_type": "Bearer",
"expires_in": 7200,
"scope": ac.Scope,
})
case "refresh_token":
// 省略:验证 refresh token 并重新签发
}
}
|
PKCE 校验本身没什么玄机,就是 SHA256 + Base64URL:
1
2
3
4
5
| func Verify(challenge, verifier string) bool {
sum := sha256.Sum256([]byte(verifier))
computed := base64.RawURLEncoding.EncodeToString(sum[:])
return subtle.ConstantTimeCompare([]byte(computed), []byte(challenge)) == 1
}
|
ID Token 与 JWKS
ID Token 是 OIDC 的核心,标准 claims 一个不能少:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| func (s *Service) buildIDToken(input OIDCTokenInput, user *User) (string, error) {
now := time.Now()
claims := IDTokenClaims{
Issuer: s.issuer,
Subject: strconv.FormatInt(user.ID, 10),
Audience: jwt.ClaimStrings{input.AppID},
ExpiresAt: jwt.NewNumericDate(now.Add(2 * time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
AuthTime: jwt.NewNumericDate(input.AuthTime),
Nonce: input.Nonce,
Name: user.Nickname,
Picture: user.Avatar,
Email: user.Email,
Phone: user.Phone,
}
return jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(s.privKey)
}
|
JWKS 端点暴露公钥,支持轮换:
1
2
3
4
| func (h *OAuthHandler) JWKS(c *gin.Context) {
set := h.keySvc.PublicKeySet() // 返回当前 + 上一把公钥
c.JSON(200, gin.H{"keys": set})
}
|
公钥的 JSON 表示要用 jwk 格式(kty/n/e/x5c 等字段)。我用 lestrrat-go/jwx/jwk 来做序列化,不手写:
1
2
3
4
5
6
7
8
9
10
11
12
13
| import "github.com/lestrrat-go/jwx/jwk"
func (s *KeyService) PublicKeySet() jwk.Set {
set := jwk.NewSet()
for _, k := range s.activeKeys() {
key, _ := jwk.New(k.PublicKey)
key.Set(jwk.KeyIDKey, k.Kid)
key.Set(jwk.AlgorithmKey, "RS256")
key.Set(jwk.KeyUsageKey, "sig")
set.AddKey(key)
}
return set
}
|
那些必须较真的细节
redirect_uri 必须精确匹配。早期为了图方便支持了前缀匹配,被安全团队指出有开放重定向风险,后来改成配置里的完整 URL 白名单,查询参数不参与匹配。
ID Token 的 nonce 一定要原样回传。客户端靠它防重放,如果我们漏传,严格的 OIDC 客户端会直接拒绝登录。
code 一次性使用,加短过期。我设成 60 秒过期、用后即删,而且同一个 code 被二次使用时,立即吊销该 app 下该用户的所有活跃 token,这也是 OAuth2 安全 BCP 的推荐做法。
公钥轮换要平滑。JWT header 里带 kid,资源服务器按 kid 从 JWKS 缓存公钥;换密钥时,新私钥签发的 token 带新 kid,旧公钥在 JWKS 里保留 7 天,让存量 token 自然过期。
/userinfo 默认只返回 sub,其他 claims 要看 access token 的 scope 里有没有 profile/email/phone。不能一股脑把用户信息全吐出去。
还有一条经验:别自己造 JWT 轮子。我签发用 golang-jwt/jwt/v5,JWK 处理用 lestrrat-go/jwx,两个都是社区主流,省得自己手写 base64 和 JSON 序列化时漏掉边界条件。
后来
实现标准 OAuth2/OIDC,工作量大头在把协议里那些 MUST/SHOULD 逐条落到工程里:PKCE、state/nonce、精确 redirect_uri、code 一次性、JWKS 轮换。改造完成后,任何标准 OIDC 客户端(NextAuth、Spring Security、Keycloak adapter)都能直接接入,飞书/企微作为外部 IdP 也能走同一个 OIDC 联邦框架,扩展性比自定义协议好得多:对接这件事,不再需要一对一谈判。
封面图:Strooks-traveller1 / Flickr · CC BY-SA 2.0