fix: 为失效 OIDC 事务提供安全重试

细分回调事务、状态和授权响应错误,提供脱敏诊断编号与显式重新登录入口,避免失效事务形成重试循环。
This commit is contained in:
2026-07-13 22:50:09 +08:00
parent 6e0a5fe397
commit 8ca68eb3cd
8 changed files with 314 additions and 15 deletions
@@ -7,6 +7,8 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
@@ -59,6 +61,77 @@ func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) {
}
}
func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) {
cipher, err := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
if err != nil {
t.Fatal(err)
}
transaction, _, err := oidcsession.NewLoginTransaction("/", time.Now())
if err != nil {
t.Fatal(err)
}
encodedTransaction, err := cipher.EncodeLoginTransaction(transaction)
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name, cookie, state, code, wantReason string
}{
{name: "cookie missing", state: "sensitive-state-marker", code: "sensitive-code-marker", wantReason: "COOKIE_MISSING"},
{name: "transaction invalid", cookie: "invalid-encrypted-cookie", state: "sensitive-state-marker", code: "sensitive-code-marker", wantReason: "TRANSACTION_INVALID"},
{name: "state mismatch", cookie: encodedTransaction, state: "sensitive-state-marker", code: "sensitive-code-marker", wantReason: "STATE_MISMATCH"},
{name: "authorization response missing", cookie: encodedTransaction, state: transaction.State, wantReason: "AUTHORIZATION_RESPONSE_MISSING"},
} {
t.Run(test.name, func(t *testing.T) {
var logs bytes.Buffer
server := &Server{
cfg: config.Config{
OIDCEnabled: true, OIDCBrowserSessionEnabled: true,
WebBaseURL: "http://localhost:5178",
},
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: &fakeOIDCClient{},
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher,
logger: slog.New(slog.NewJSONHandler(&logs, nil)),
}
query := url.Values{}
if test.state != "" {
query.Set("state", test.state)
}
if test.code != "" {
query.Set("code", test.code)
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/callback?"+query.Encode(), nil)
if test.cookie != "" {
request.AddCookie(&http.Cookie{Name: oidcsession.LoginTransactionCookieName, Value: test.cookie})
}
recorder := httptest.NewRecorder()
server.completeOIDCLogin(recorder, request)
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)
}
if location.Query().Get("oidcError") != errorCodeOIDCLoginInvalid || location.Query().Get("oidcErrorReason") != test.wantReason {
t.Fatalf("callback error=%q reason=%q", location.Query().Get("oidcError"), location.Query().Get("oidcErrorReason"))
}
diagnosticID := location.Query().Get("oidcDiagnosticId")
if diagnosticID == "" || !strings.Contains(logs.String(), `"diagnosticId":"`+diagnosticID+`"`) || !strings.Contains(logs.String(), `"reason":"`+test.wantReason+`"`) {
t.Fatalf("missing correlated safe diagnostic: location=%q logs=%s", location.RawQuery, logs.String())
}
for _, secretMarker := range []string{"sensitive-state-marker", "sensitive-code-marker", "invalid-encrypted-cookie"} {
if strings.Contains(location.RawQuery, secretMarker) || strings.Contains(logs.String(), secretMarker) {
t.Fatalf("OIDC diagnostic leaked callback security material")
}
}
})
}
}
func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) {
sessions := &fakeOIDCSessions{}
server := &Server{cfg: config.Config{OIDCSessionCookieSecure: true}, oidcSessions: sessions}