feat(observability): 增加 OIDC 失败关联诊断
将 OIDC Access Token 与 ID Token 校验失败映射为固定安全分类,并生成 diagnosticId 关联浏览器错误与服务端日志。日志不记录授权码、Token 或底层敏感输入,且保留 errors.Is(ErrUnauthorized) 兼容语义。 验证:go test ./... -count=1(apps/api)
This commit is contained in:
@@ -111,17 +111,17 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
tokens, err := runtime.PublicClient.ExchangeCode(r.Context(), r.URL.Query().Get("code"), transaction.PKCEVerifier)
|
||||
if err != nil || tokens.AccessToken == "" || tokens.RefreshToken == "" || tokens.IDToken == "" {
|
||||
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心登录结果无效,请重新登录", errorCodeOIDCTokenExchangeFailed)
|
||||
s.writeOIDCTokenFailure(w, r, "TOKEN_ENDPOINT_REJECTED", "TOKEN_RESPONSE_INVALID", "认证中心登录结果无效,请重新登录")
|
||||
return
|
||||
}
|
||||
identity, err := runtime.Verifier.Verify(r.Context(), tokens.AccessToken)
|
||||
if err != nil || identity == nil {
|
||||
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心访问令牌校验失败", errorCodeOIDCTokenExchangeFailed)
|
||||
s.writeOIDCTokenFailure(w, r, "ACCESS_TOKEN_INVALID", auth.OIDCValidationCategory(err), "认证中心访问令牌校验失败")
|
||||
return
|
||||
}
|
||||
idSubject, err := runtime.PublicClient.VerifyIDToken(r.Context(), tokens.IDToken, transaction.Nonce)
|
||||
if err != nil || idSubject != identity.ID {
|
||||
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心身份令牌校验失败", errorCodeOIDCTokenExchangeFailed)
|
||||
s.writeOIDCTokenFailure(w, r, "ID_TOKEN_INVALID", auth.OIDCValidationCategory(err), "认证中心身份令牌校验失败")
|
||||
return
|
||||
}
|
||||
projection, err := s.resolveOIDCUserProjectionForRevision(r.Context(), r, identity, runtime.Revision)
|
||||
@@ -253,6 +253,27 @@ func (s *Server) writeOIDCCallbackError(w http.ResponseWriter, r *http.Request,
|
||||
s.writeOIDCCallbackErrorWithDiagnostics(w, r, status, message, code, "", "")
|
||||
}
|
||||
|
||||
func (s *Server) writeOIDCTokenFailure(w http.ResponseWriter, r *http.Request, stage, validationCategory, message string) {
|
||||
diagnosticID := newOIDCDiagnosticID()
|
||||
if s.logger != nil {
|
||||
s.logger.WarnContext(r.Context(), "OIDC token processing rejected",
|
||||
"event", "oidc_token_processing_rejected",
|
||||
"stage", stage,
|
||||
"validationCategory", validationCategory,
|
||||
"diagnosticId", diagnosticID,
|
||||
)
|
||||
}
|
||||
s.writeOIDCCallbackErrorWithDiagnostics(
|
||||
w,
|
||||
r,
|
||||
http.StatusUnauthorized,
|
||||
message,
|
||||
errorCodeOIDCTokenExchangeFailed,
|
||||
"",
|
||||
diagnosticID,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) writeOIDCLoginTransactionError(w http.ResponseWriter, r *http.Request, message, reason string) {
|
||||
diagnosticID := newOIDCDiagnosticID()
|
||||
if s.logger != nil {
|
||||
|
||||
@@ -128,6 +128,51 @@ func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCTokenFailureReportsOnlyCorrelatedSafeStage(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.writeOIDCTokenFailure(
|
||||
recorder,
|
||||
request,
|
||||
"ACCESS_TOKEN_INVALID",
|
||||
"REQUIRED_SCOPE_MISSING",
|
||||
"认证中心访问令牌校验失败",
|
||||
)
|
||||
|
||||
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") != errorCodeOIDCTokenExchangeFailed ||
|
||||
location.Query().Get("oidcErrorReason") != "" ||
|
||||
diagnosticID == "" {
|
||||
t.Fatalf("unsafe callback diagnostic: %q", location.RawQuery)
|
||||
}
|
||||
if !strings.Contains(logs.String(), `"diagnosticId":"`+diagnosticID+`"`) ||
|
||||
!strings.Contains(logs.String(), `"stage":"ACCESS_TOKEN_INVALID"`) ||
|
||||
!strings.Contains(logs.String(), `"validationCategory":"REQUIRED_SCOPE_MISSING"`) ||
|
||||
strings.Contains(logs.String(), "sensitive-code-marker") ||
|
||||
strings.Contains(location.RawQuery, "sensitive-code-marker") {
|
||||
t.Fatalf("OIDC token diagnostic was missing or leaked callback material")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) {
|
||||
sessions := &fakeOIDCSessions{}
|
||||
server := &Server{oidcSessions: sessions, identityTestCookieSecure: true}
|
||||
|
||||
Reference in New Issue
Block a user