feat(identity): 接入认证中心多租户登录

支持 Manifest V2 动态 tid 验证、Tenant Context 同步和租户内 JIT 投影,并保留 Manifest V1 与旧 Session 兼容。\n\n增加 tenantHint、租户切换、普通注册关闭及 application/principal/tenant 两级 SSF 撤销;迁移、定向安全测试和本地双租户跨仓 E2E 已通过。\n\nrelease_required=true;未执行 Release、Staging 或真实链路。
This commit is contained in:
2026-07-28 17:28:35 +08:00
parent 0b02e62c72
commit 5c679ff13f
45 changed files with 2986 additions and 139 deletions
@@ -255,6 +255,42 @@ func TestServiceDeletesSessionEvenWhenCiphertextCannotBeDecryptedDuringLogout(t
}
}
func TestServiceBindsMultiTenantSessionToIssuerApplicationTenantAndClient(t *testing.T) {
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
tenantA := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
tenantB := "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
applicationID := "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
verified := &auth.User{
ID: "shared-subject", Source: "oidc", Issuer: "https://auth.example.test",
ApplicationID: applicationID, TenantID: tenantA, OIDCClientID: "gateway-browser",
TokenExpiresAt: now.Add(5 * time.Minute),
}
verifier := fakeVerifier{users: map[string]*auth.User{"access": verified}}
repository := newFakeRepository("shared-subject")
service := newTestService(t, repository, verifier, &fakePublicClient{})
service.now = func() time.Time { return now }
local := &auth.User{
ID: "shared-subject", GatewayUserID: "11111111-1111-4111-8111-111111111111",
GatewayTenantID: "22222222-2222-4222-8222-222222222222",
OIDCUserBindingID: "44444444-4444-4444-8444-444444444444",
Issuer: "https://auth.example.test", ApplicationID: applicationID,
TenantID: tenantA, OIDCClientID: "gateway-browser",
}
raw, err := service.Create(context.Background(), TokenBundle{
AccessToken: "access", RefreshToken: "refresh",
}, local)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
verified.TenantID = tenantB
if _, err := service.Resolve(context.Background(), raw); !errors.Is(err, ErrSessionInvalid) {
t.Fatalf("Resolve() error = %v, want ErrSessionInvalid", err)
}
if !repository.isDeleted() {
t.Fatal("identity-binding mismatch did not destroy the session")
}
}
func testLocalUser() *auth.User {
return &auth.User{
ID: "subject-1", GatewayUserID: "11111111-1111-4111-8111-111111111111",
@@ -323,6 +359,8 @@ func (f *fakeRepository) CreateOIDCSession(_ context.Context, input store.Create
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,
OIDCUserBindingID: input.OIDCUserBindingID, OIDCClientID: input.OIDCClientID,
Issuer: input.Issuer, ApplicationID: input.ApplicationID, TenantID: input.TenantID,
UserStatus: "active", TokenCiphertext: append([]byte(nil), input.TokenCiphertext...), AccessTokenExpiresAt: input.AccessTokenExpiresAt,
LastSeenAt: input.LastSeenAt, IdleExpiresAt: input.IdleExpiresAt, AbsoluteExpiresAt: input.AbsoluteExpiresAt, RefreshVersion: 1,
}