fix(oidc): 修复登录会话落库兼容问题

部分已配对环境在 0090 首次执行后缺少 oidc_client_id,导致统一认证完成投影后无法写入 Gateway 服务端会话。新增幂等前向迁移补齐该列,并为 Token 处理及会话创建增加不泄露凭据的稳定失败分类和关联诊断。\n\n验证:OIDC Session 全量单测、HTTP 回调定向测试、迁移升级集成测试、隔离 PostgreSQL 跨仓库 OIDC E2E 和本地 Chrome 真实登录均通过。
This commit is contained in:
2026-07-29 10:40:33 +08:00
parent fd9bbbb508
commit 2e5a90731b
6 changed files with 305 additions and 17 deletions
+40 -4
View File
@@ -118,8 +118,8 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
return
}
tokens, err := runtime.PublicClient.ExchangeCode(r.Context(), r.URL.Query().Get("code"), transaction.PKCEVerifier)
if err != nil || tokens.AccessToken == "" || tokens.RefreshToken == "" || tokens.IDToken == "" {
s.writeOIDCTokenFailure(w, r, "TOKEN_ENDPOINT_REJECTED", "TOKEN_RESPONSE_INVALID", "认证中心登录结果无效,请重新登录")
if stage, category, failed := classifyOIDCTokenExchangeFailure(tokens, err); failed {
s.writeOIDCTokenFailure(w, r, stage, category, "认证中心登录结果无效,请重新登录")
return
}
identity, err := runtime.Verifier.Verify(r.Context(), tokens.AccessToken)
@@ -145,7 +145,7 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
AccessToken: tokens.AccessToken, RefreshToken: tokens.RefreshToken, IDToken: tokens.IDToken,
}, projection.User)
if err != nil {
s.writeOIDCCallbackError(w, r, http.StatusServiceUnavailable, "登录会话保存失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable)
s.writeOIDCSessionFailure(w, r, err)
return
}
if err := s.replaceOIDCBrowserSession(r.Context(), r, runtime, rawSession); err != nil {
@@ -312,11 +312,47 @@ func (s *Server) writeOIDCTokenFailure(w http.ResponseWriter, r *http.Request, s
http.StatusUnauthorized,
message,
errorCodeOIDCTokenExchangeFailed,
"",
validationCategory,
diagnosticID,
)
}
func (s *Server) writeOIDCSessionFailure(w http.ResponseWriter, r *http.Request, err error) {
category := oidcsession.SessionCreationCategory(err)
diagnosticID := newOIDCDiagnosticID()
if s.logger != nil {
s.logger.WarnContext(r.Context(), "OIDC session creation rejected",
"event", "oidc_session_creation_rejected",
"category", category,
"diagnosticId", diagnosticID,
)
}
s.writeOIDCCallbackErrorWithDiagnostics(
w,
r,
http.StatusServiceUnavailable,
"登录会话保存失败,请稍后重试",
errorCodeOIDCSessionStoreUnavailable,
category,
diagnosticID,
)
}
func classifyOIDCTokenExchangeFailure(tokens auth.OIDCTokenResponse, err error) (stage, category string, failed bool) {
switch {
case err != nil:
return "TOKEN_ENDPOINT_REJECTED", "TOKEN_ENDPOINT_REQUEST_FAILED", true
case strings.TrimSpace(tokens.AccessToken) == "":
return "TOKEN_RESPONSE_INVALID", "ACCESS_TOKEN_MISSING", true
case strings.TrimSpace(tokens.RefreshToken) == "":
return "TOKEN_RESPONSE_INVALID", "REFRESH_TOKEN_MISSING", true
case strings.TrimSpace(tokens.IDToken) == "":
return "TOKEN_RESPONSE_INVALID", "ID_TOKEN_MISSING", true
default:
return "", "", false
}
}
func (s *Server) writeOIDCLoginTransactionError(w http.ResponseWriter, r *http.Request, message, reason string) {
diagnosticID := newOIDCDiagnosticID()
if s.logger != nil {
+96 -1
View File
@@ -3,6 +3,7 @@ package httpapi
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"net/http"
@@ -211,7 +212,7 @@ func TestOIDCTokenFailureReportsOnlyCorrelatedSafeStage(t *testing.T) {
}
diagnosticID := location.Query().Get("oidcDiagnosticId")
if location.Query().Get("oidcError") != errorCodeOIDCTokenExchangeFailed ||
location.Query().Get("oidcErrorReason") != "" ||
location.Query().Get("oidcErrorReason") != "REQUIRED_SCOPE_MISSING" ||
diagnosticID == "" {
t.Fatalf("unsafe callback diagnostic: %q", location.RawQuery)
}
@@ -224,6 +225,100 @@ func TestOIDCTokenFailureReportsOnlyCorrelatedSafeStage(t *testing.T) {
}
}
func TestOIDCSessionFailureReportsOnlyCorrelatedSafeCategory(t *testing.T) {
var logs bytes.Buffer
server := &Server{
identityTestRevision: identity.Revision{
ID: "diagnostic-revision", WebBaseURL: "http://localhost:5178",
},
logger: slog.New(slog.NewJSONHandler(&logs, nil)),
}
request := httptest.NewRequest(
http.MethodGet,
"/api/v1/auth/oidc/callback?code=sensitive-code-marker",
nil,
)
recorder := httptest.NewRecorder()
server.writeOIDCSessionFailure(
recorder,
request,
errors.New("SESSION_REPOSITORY_FAILED"),
)
if recorder.Code != http.StatusSeeOther {
t.Fatalf("callback status=%d, want 303", recorder.Code)
}
location, err := url.Parse(recorder.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
diagnosticID := location.Query().Get("oidcDiagnosticId")
if location.Query().Get("oidcError") != errorCodeOIDCSessionStoreUnavailable ||
location.Query().Get("oidcErrorReason") != "SESSION_CREATE_FAILED" ||
diagnosticID == "" {
t.Fatalf("unsafe callback diagnostic: %q", location.RawQuery)
}
if !strings.Contains(logs.String(), `"diagnosticId":"`+diagnosticID+`"`) ||
!strings.Contains(logs.String(), `"category":"SESSION_CREATE_FAILED"`) ||
strings.Contains(logs.String(), "sensitive-code-marker") ||
strings.Contains(location.RawQuery, "sensitive-code-marker") {
t.Fatalf("OIDC session diagnostic was missing or leaked callback material")
}
}
func TestClassifyOIDCTokenExchangeFailure(t *testing.T) {
for _, test := range []struct {
name string
tokens auth.OIDCTokenResponse
err error
wantStage string
wantCategory string
wantFailed bool
}{
{
name: "endpoint request failed",
err: errors.New("sensitive provider detail"),
wantStage: "TOKEN_ENDPOINT_REJECTED",
wantCategory: "TOKEN_ENDPOINT_REQUEST_FAILED",
wantFailed: true,
},
{
name: "access token missing",
tokens: auth.OIDCTokenResponse{RefreshToken: "refresh", IDToken: "id"},
wantStage: "TOKEN_RESPONSE_INVALID",
wantCategory: "ACCESS_TOKEN_MISSING",
wantFailed: true,
},
{
name: "refresh token missing",
tokens: auth.OIDCTokenResponse{AccessToken: "access", IDToken: "id"},
wantStage: "TOKEN_RESPONSE_INVALID",
wantCategory: "REFRESH_TOKEN_MISSING",
wantFailed: true,
},
{
name: "id token missing",
tokens: auth.OIDCTokenResponse{AccessToken: "access", RefreshToken: "refresh"},
wantStage: "TOKEN_RESPONSE_INVALID",
wantCategory: "ID_TOKEN_MISSING",
wantFailed: true,
},
{
name: "complete response",
tokens: auth.OIDCTokenResponse{AccessToken: "access", RefreshToken: "refresh", IDToken: "id"},
wantFailed: false,
},
} {
t.Run(test.name, func(t *testing.T) {
stage, category, failed := classifyOIDCTokenExchangeFailure(test.tokens, test.err)
if stage != test.wantStage || category != test.wantCategory || failed != test.wantFailed {
t.Fatalf("classification=(%q, %q, %v), want (%q, %q, %v)", stage, category, failed, test.wantStage, test.wantCategory, test.wantFailed)
}
})
}
}
func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) {
sessions := &fakeOIDCSessions{}
server := &Server{oidcSessions: sessions, identityTestCookieSecure: true}