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
+100 -4
View File
@@ -60,6 +60,57 @@ func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) {
}
}
func TestStartOIDCLoginEncryptsAndForwardsMultiTenantHint(t *testing.T) {
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
client := &fakeOIDCClient{authorizationURL: "https://auth.example.com/authorize"}
server := &Server{
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: client,
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher,
identityTestRevision: identity.Revision{TenantMode: "multi_tenant"},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, httptest.NewRequest(
http.MethodGet, "/api/v1/auth/oidc/login?tenantHint="+tenantHint, nil,
))
if recorder.Code != http.StatusSeeOther || client.tenantHint != tenantHint {
t.Fatalf("status=%d forwarded tenantHint=%q", recorder.Code, client.tenantHint)
}
cookies := recorder.Result().Cookies()
transaction, err := cipher.DecodeLoginTransaction(cookies[0].Value, time.Now())
if err != nil || transaction.TenantHint != tenantHint {
t.Fatalf("transaction=%+v err=%v", transaction, err)
}
}
func TestStartOIDCLoginRejectsInvalidOrSingleTenantHint(t *testing.T) {
for _, test := range []struct {
name string
revision identity.Revision
hint string
}{
{name: "invalid UUID", revision: identity.Revision{TenantMode: "multi_tenant"}, hint: "not-a-uuid"},
{name: "single tenant", revision: identity.Revision{TenantMode: "single_tenant"}, hint: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"},
} {
t.Run(test.name, func(t *testing.T) {
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
server := &Server{
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: &fakeOIDCClient{},
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher,
identityTestRevision: test.revision, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, httptest.NewRequest(
http.MethodGet, "/api/v1/auth/oidc/login?tenantHint="+test.hint, nil,
))
if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Set-Cookie") != "" {
t.Fatalf("status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie"))
}
})
}
}
func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) {
cipher, err := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
if err != nil {
@@ -191,6 +242,42 @@ func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) {
}
}
func TestReplaceOIDCBrowserSessionDeletesPreviousSessionAndRevokesRefreshToken(t *testing.T) {
sessions := &fakeOIDCSessions{
deleteBundle: oidcsession.TokenBundle{RefreshToken: "previous-refresh-token"},
}
client := &fakeOIDCClient{}
server := &Server{}
runtime := &identityRequestRuntime{Sessions: sessions, PublicClient: client}
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/callback", nil)
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "previous-session"})
if err := server.replaceOIDCBrowserSession(request.Context(), request, runtime, "new-session"); err != nil {
t.Fatal(err)
}
if sessions.deleted != "previous-session" {
t.Fatalf("deleted session=%q", sessions.deleted)
}
if client.revokedRefreshToken != "previous-refresh-token" {
t.Fatalf("revoked refresh token=%q", client.revokedRefreshToken)
}
}
func TestReplaceOIDCBrowserSessionDoesNotDeleteNewSession(t *testing.T) {
sessions := &fakeOIDCSessions{}
server := &Server{}
runtime := &identityRequestRuntime{Sessions: sessions, PublicClient: &fakeOIDCClient{}}
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/callback", nil)
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "same-session"})
if err := server.replaceOIDCBrowserSession(request.Context(), request, runtime, "same-session"); err != nil {
t.Fatal(err)
}
if sessions.deleted != "" {
t.Fatalf("unexpected deleted session=%q", sessions.deleted)
}
}
func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
server := &Server{
cfg: config.Config{CORSAllowedOrigin: "https://gateway.example.com"},
@@ -329,10 +416,13 @@ func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) {
type fakeOIDCClient struct {
authorizationURL string
state, nonce, challenge string
tenantHint string
revokedRefreshToken string
}
func (f *fakeOIDCClient) AuthorizationURL(_ context.Context, state, nonce, challenge string) (string, error) {
func (f *fakeOIDCClient) AuthorizationURL(_ context.Context, state, nonce, challenge, tenantHint string) (string, error) {
f.state, f.nonce, f.challenge = state, nonce, challenge
f.tenantHint = tenantHint
return f.authorizationURL, nil
}
func (f *fakeOIDCClient) ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error) {
@@ -344,12 +434,18 @@ func (f *fakeOIDCClient) VerifyIDToken(context.Context, string, string) (string,
func (f *fakeOIDCClient) Refresh(context.Context, string) (auth.OIDCTokenResponse, error) {
return auth.OIDCTokenResponse{}, nil
}
func (f *fakeOIDCClient) RevokeRefreshToken(context.Context, string) error { return nil }
func (f *fakeOIDCClient) RevokeRefreshToken(_ context.Context, refreshToken string) error {
f.revokedRefreshToken = refreshToken
return nil
}
func (f *fakeOIDCClient) EndSessionURL(context.Context, string) (string, error) {
return "https://gateway.example.com/", nil
}
type fakeOIDCSessions struct{ deleted string }
type fakeOIDCSessions struct {
deleted string
deleteBundle oidcsession.TokenBundle
}
func (f *fakeOIDCSessions) Create(context.Context, oidcsession.TokenBundle, *auth.User) (string, error) {
return "opaque-session", nil
@@ -357,6 +453,6 @@ func (f *fakeOIDCSessions) Create(context.Context, oidcsession.TokenBundle, *aut
func (f *fakeOIDCSessions) Resolve(context.Context, string) (*auth.User, error) { return nil, nil }
func (f *fakeOIDCSessions) Delete(_ context.Context, raw string) (oidcsession.TokenBundle, error) {
f.deleted = raw
return oidcsession.TokenBundle{}, nil
return f.deleteBundle, nil
}
func (f *fakeOIDCSessions) Cleanup(context.Context) (int64, error) { return 0, nil }