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
+43 -8
View File
@@ -23,6 +23,38 @@ var (
ErrSecurityStateUnavailable = errors.New("OIDC security event state is unavailable")
)
const (
sessionCreationPrerequisitesInvalid = "SESSION_PREREQUISITES_INVALID"
sessionCreationAccessTokenInvalid = "SESSION_ACCESS_TOKEN_INVALID"
sessionCreationIdentityMismatch = "SESSION_IDENTITY_MISMATCH"
sessionCreationTokenExpired = "SESSION_TOKEN_EXPIRED"
sessionCreationTokenGeneration = "SESSION_TOKEN_GENERATION_FAILED"
sessionCreationTokenEncryption = "SESSION_TOKEN_ENCRYPTION_FAILED"
sessionCreationRepositoryFailed = "SESSION_REPOSITORY_FAILED"
)
type sessionCreationError struct {
category string
root error
}
func (e *sessionCreationError) Error() string { return e.category }
func (e *sessionCreationError) Unwrap() error { return e.root }
func newSessionCreationError(category string, root error) error {
return &sessionCreationError{category: category, root: root}
}
// SessionCreationCategory returns a stable, non-sensitive failure category.
// It intentionally does not expose verifier, encryption, or repository errors.
func SessionCreationCategory(err error) string {
var creationError *sessionCreationError
if errors.As(err, &creationError) {
return creationError.category
}
return "SESSION_CREATE_FAILED"
}
type Repository interface {
CreateOIDCSession(context.Context, store.CreateOIDCSessionInput) (store.OIDCSession, error)
FindOIDCSessionByHash(context.Context, []byte) (store.OIDCSession, error)
@@ -79,30 +111,33 @@ func NewService(repository Repository, cipher *Cipher, verifier TokenVerifier, c
func (s *Service) Create(ctx context.Context, bundle TokenBundle, localUser *auth.User) (string, error) {
if localUser == nil || localUser.GatewayUserID == "" || localUser.GatewayTenantID == "" || bundle.RefreshToken == "" {
return "", ErrSessionInvalid
return "", newSessionCreationError(sessionCreationPrerequisitesInvalid, ErrSessionInvalid)
}
verified, err := s.verifier.Verify(ctx, bundle.AccessToken)
if err != nil || verified == nil || verified.Source != "oidc" || verified.ID == "" || verified.ID != localUser.ID {
return "", ErrSessionInvalid
if err != nil || verified == nil || verified.Source != "oidc" || verified.ID == "" {
return "", newSessionCreationError(sessionCreationAccessTokenInvalid, ErrSessionInvalid)
}
if verified.ID != localUser.ID {
return "", newSessionCreationError(sessionCreationIdentityMismatch, ErrSessionInvalid)
}
if localUser.OIDCUserBindingID != "" &&
(localUser.Issuer == "" || localUser.ApplicationID == "" || localUser.TenantID == "" || localUser.OIDCClientID == "" ||
verified.Issuer != localUser.Issuer || verified.ApplicationID != localUser.ApplicationID ||
verified.TenantID != localUser.TenantID || verified.OIDCClientID != localUser.OIDCClientID) {
return "", ErrSessionInvalid
return "", newSessionCreationError(sessionCreationIdentityMismatch, ErrSessionInvalid)
}
now := s.now()
if !verified.TokenExpiresAt.After(now) {
return "", ErrSessionExpired
return "", newSessionCreationError(sessionCreationTokenExpired, ErrSessionExpired)
}
raw, hash, err := newSessionToken()
if err != nil {
return "", ErrSessionStoreUnavailable
return "", newSessionCreationError(sessionCreationTokenGeneration, ErrSessionStoreUnavailable)
}
aadSessionID := hex.EncodeToString(hash)
ciphertext, err := s.cipher.EncryptBundle(bundle, aadSessionID, localUser.GatewayUserID)
if err != nil {
return "", ErrSessionStoreUnavailable
return "", newSessionCreationError(sessionCreationTokenEncryption, ErrSessionStoreUnavailable)
}
_, err = s.repository.CreateOIDCSession(ctx, store.CreateOIDCSessionInput{
SessionTokenHash: hash, GatewayUserID: localUser.GatewayUserID, GatewayTenantID: localUser.GatewayTenantID,
@@ -112,7 +147,7 @@ func (s *Service) Create(ctx context.Context, bundle TokenBundle, localUser *aut
LastSeenAt: now, IdleExpiresAt: now.Add(s.config.IdleTTL), AbsoluteExpiresAt: now.Add(s.config.AbsoluteTTL),
})
if err != nil {
return "", ErrSessionStoreUnavailable
return "", newSessionCreationError(sessionCreationRepositoryFailed, ErrSessionStoreUnavailable)
}
return raw, nil
}
+93 -4
View File
@@ -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 &copy
}(),
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 &copy
}()}},
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,