fix(oidc): 修复登录会话落库兼容问题
部分已配对环境在 0090 首次执行后缺少 oidc_client_id,导致统一认证完成投影后无法写入 Gateway 服务端会话。新增幂等前向迁移补齐该列,并为 Token 处理及会话创建增加不泄露凭据的稳定失败分类和关联诊断。\n\n验证:OIDC Session 全量单测、HTTP 回调定向测试、迁移升级集成测试、隔离 PostgreSQL 跨仓库 OIDC E2E 和本地 Chrome 真实登录均通过。
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -291,6 +292,90 @@ func TestServiceBindsMultiTenantSessionToIssuerApplicationTenantAndClient(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCreateReportsSafeFailureCategory(t *testing.T) {
|
||||
now := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC)
|
||||
validVerified := &auth.User{
|
||||
ID: "subject-1", Source: "oidc", Issuer: "http://localhost:18003",
|
||||
ApplicationID: "application-1", TenantID: "tenant-1", OIDCClientID: "gateway-browser",
|
||||
TokenExpiresAt: now.Add(5 * time.Minute),
|
||||
}
|
||||
validLocal := &auth.User{
|
||||
ID: "subject-1", GatewayUserID: "11111111-1111-4111-8111-111111111111",
|
||||
GatewayTenantID: "22222222-2222-4222-8222-222222222222",
|
||||
OIDCUserBindingID: "44444444-4444-4444-8444-444444444444",
|
||||
Issuer: validVerified.Issuer, ApplicationID: validVerified.ApplicationID,
|
||||
TenantID: validVerified.TenantID, OIDCClientID: validVerified.OIDCClientID,
|
||||
}
|
||||
validBundle := TokenBundle{AccessToken: "access", RefreshToken: "refresh", IDToken: "id"}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
repository *fakeRepository
|
||||
verifier fakeVerifier
|
||||
bundle TokenBundle
|
||||
localUser *auth.User
|
||||
wantCategory string
|
||||
wantRoot error
|
||||
}{
|
||||
{
|
||||
name: "prerequisites invalid", repository: newFakeRepository("subject-1"),
|
||||
verifier: fakeVerifier{users: map[string]*auth.User{"access": validVerified}},
|
||||
bundle: validBundle, wantCategory: "SESSION_PREREQUISITES_INVALID", wantRoot: ErrSessionInvalid,
|
||||
},
|
||||
{
|
||||
name: "access token invalid", repository: newFakeRepository("subject-1"),
|
||||
verifier: fakeVerifier{users: map[string]*auth.User{}},
|
||||
bundle: validBundle, localUser: validLocal,
|
||||
wantCategory: "SESSION_ACCESS_TOKEN_INVALID", wantRoot: ErrSessionInvalid,
|
||||
},
|
||||
{
|
||||
name: "identity mismatch", repository: newFakeRepository("subject-1"),
|
||||
verifier: fakeVerifier{users: map[string]*auth.User{"access": validVerified}},
|
||||
bundle: validBundle, localUser: func() *auth.User {
|
||||
copy := *validLocal
|
||||
copy.OIDCClientID = "different-client"
|
||||
return ©
|
||||
}(),
|
||||
wantCategory: "SESSION_IDENTITY_MISMATCH", wantRoot: ErrSessionInvalid,
|
||||
},
|
||||
{
|
||||
name: "token expired", repository: newFakeRepository("subject-1"),
|
||||
verifier: fakeVerifier{users: map[string]*auth.User{"access": func() *auth.User {
|
||||
copy := *validVerified
|
||||
copy.TokenExpiresAt = now
|
||||
return ©
|
||||
}()}},
|
||||
bundle: validBundle, localUser: validLocal,
|
||||
wantCategory: "SESSION_TOKEN_EXPIRED", wantRoot: ErrSessionExpired,
|
||||
},
|
||||
{
|
||||
name: "repository failed", repository: &fakeRepository{
|
||||
external: "subject-1", createError: errors.New("sensitive database detail"),
|
||||
},
|
||||
verifier: fakeVerifier{users: map[string]*auth.User{"access": validVerified}},
|
||||
bundle: validBundle, localUser: validLocal,
|
||||
wantCategory: "SESSION_REPOSITORY_FAILED", wantRoot: ErrSessionStoreUnavailable,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
service := newTestService(t, test.repository, test.verifier, &fakePublicClient{})
|
||||
service.now = func() time.Time { return now }
|
||||
|
||||
_, err := service.Create(context.Background(), test.bundle, test.localUser)
|
||||
|
||||
if !errors.Is(err, test.wantRoot) {
|
||||
t.Fatalf("Create() error = %v, want root %v", err, test.wantRoot)
|
||||
}
|
||||
if category := SessionCreationCategory(err); category != test.wantCategory {
|
||||
t.Fatalf("SessionCreationCategory() = %q, want %q", category, test.wantCategory)
|
||||
}
|
||||
if strings.Contains(err.Error(), "sensitive database detail") {
|
||||
t.Fatal("session creation error exposed repository detail")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testLocalUser() *auth.User {
|
||||
return &auth.User{
|
||||
ID: "subject-1", GatewayUserID: "11111111-1111-4111-8111-111111111111",
|
||||
@@ -345,10 +430,11 @@ func (f *fakePublicClient) EndSessionURL(context.Context, string) (string, error
|
||||
}
|
||||
|
||||
type fakeRepository struct {
|
||||
mu sync.Mutex
|
||||
record store.OIDCSession
|
||||
deleted bool
|
||||
external string
|
||||
mu sync.Mutex
|
||||
record store.OIDCSession
|
||||
deleted bool
|
||||
external string
|
||||
createError error
|
||||
}
|
||||
|
||||
func newFakeRepository(external string) *fakeRepository { return &fakeRepository{external: external} }
|
||||
@@ -356,6 +442,9 @@ func newFakeRepository(external string) *fakeRepository { return &fakeRepository
|
||||
func (f *fakeRepository) CreateOIDCSession(_ context.Context, input store.CreateOIDCSessionInput) (store.OIDCSession, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.createError != nil {
|
||||
return store.OIDCSession{}, f.createError
|
||||
}
|
||||
f.record = store.OIDCSession{
|
||||
ID: "33333333-3333-4333-8333-333333333333", SessionTokenHash: append([]byte(nil), input.SessionTokenHash...),
|
||||
GatewayUserID: input.GatewayUserID, GatewayTenantID: input.GatewayTenantID, ExternalUserID: f.external,
|
||||
|
||||
Reference in New Issue
Block a user