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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user