fix(identity): 支持平台用户显式登录 AI Gateway

修复多租户身份配置将所有人类登录都强制解释为租户上下文的问题。Web 现在提供平台与租户两个受控入口,API 严格绑定 context_type、tid、issuer、application 和 subject,并为平台用户建立独立本地投影与可刷新会话。\n\n风险:新增会话身份列保持旧会话可读,新会话一律使用严格约束;未改变租户数据隔离和 API Key 行为。\n\n验证:Go 全量测试与 go vet 通过;PostgreSQL 平台投影、会话和安全事件集成测试通过;前端 lint、141 项测试与生产 build 通过;OpenAPI 生成和迁移安全测试通过。
This commit is contained in:
2026-07-31 14:24:40 +08:00
parent 88c971564a
commit c0296dbf06
29 changed files with 1061 additions and 84 deletions
+37 -7
View File
@@ -25,9 +25,10 @@ func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testin
server := &Server{
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: client,
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher,
identityTestRevision: identity.Revision{TenantMode: "multi_tenant"},
identityTestCookieSecure: true, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?returnTo=%2Fworkspace%3Ftab%3Dwallet", nil)
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?contextType=platform&returnTo=%2Fworkspace%3Ftab%3Dwallet", nil)
recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, request)
response := recorder.Result()
@@ -40,10 +41,11 @@ func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testin
t.Fatalf("unsafe login transaction cookie: %#v", cookies)
}
transaction, err := cipher.DecodeLoginTransaction(cookies[0].Value, cookies[0].Expires.Add(-time.Minute))
if err != nil || transaction.ReturnTo != "/workspace?tab=wallet" {
if err != nil || transaction.ContextType != "platform" || transaction.ReturnTo != "/workspace?tab=wallet" {
t.Fatalf("transaction=%#v err=%v", transaction, err)
}
if client.state == "" || client.nonce == "" || client.challenge == "" {
if client.state == "" || client.nonce == "" || client.challenge == "" ||
client.contextType != "platform" {
t.Fatal("authorization redirect omitted state, nonce or PKCE challenge")
}
}
@@ -55,7 +57,7 @@ func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) {
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?returnTo=https%3A%2F%2Fevil.example", nil))
server.startOIDCLogin(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?contextType=platform&returnTo=https%3A%2F%2Fevil.example", nil))
if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Set-Cookie") != "" {
t.Fatalf("open redirect status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie"))
}
@@ -73,7 +75,7 @@ func TestStartOIDCLoginEncryptsAndForwardsMultiTenantHint(t *testing.T) {
tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, httptest.NewRequest(
http.MethodGet, "/api/v1/auth/oidc/login?tenantHint="+tenantHint, nil,
http.MethodGet, "/api/v1/auth/oidc/login?contextType=tenant&tenantHint="+tenantHint, nil,
))
if recorder.Code != http.StatusSeeOther || client.tenantHint != tenantHint {
t.Fatalf("status=%d forwarded tenantHint=%q", recorder.Code, client.tenantHint)
@@ -103,7 +105,7 @@ func TestStartOIDCLoginRejectsInvalidOrSingleTenantHint(t *testing.T) {
}
recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, httptest.NewRequest(
http.MethodGet, "/api/v1/auth/oidc/login?tenantHint="+test.hint, nil,
http.MethodGet, "/api/v1/auth/oidc/login?contextType=tenant&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"))
@@ -112,6 +114,32 @@ func TestStartOIDCLoginRejectsInvalidOrSingleTenantHint(t *testing.T) {
}
}
func TestStartOIDCLoginRejectsMissingOrIncompatibleContext(t *testing.T) {
for _, path := range []string{
"/api/v1/auth/oidc/login",
"/api/v1/auth/oidc/login?contextType=account",
"/api/v1/auth/oidc/login?contextType=platform&tenantHint=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
} {
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
server := &Server{
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}},
oidcClient: &fakeOIDCClient{}, oidcSessions: &fakeOIDCSessions{},
oidcSessionCipher: cipher,
identityTestRevision: identity.Revision{TenantMode: "multi_tenant"},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
recorder := httptest.NewRecorder()
server.startOIDCLogin(
recorder,
httptest.NewRequest(http.MethodGet, path, nil),
)
if recorder.Code != http.StatusBadRequest ||
recorder.Header().Get("Set-Cookie") != "" {
t.Fatalf("path=%q status=%d cookie=%q", path, recorder.Code, recorder.Header().Get("Set-Cookie"))
}
}
}
func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) {
cipher, err := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
if err != nil {
@@ -511,12 +539,14 @@ func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) {
type fakeOIDCClient struct {
authorizationURL string
state, nonce, challenge string
contextType string
tenantHint string
revokedRefreshToken string
}
func (f *fakeOIDCClient) AuthorizationURL(_ context.Context, state, nonce, challenge, tenantHint string) (string, error) {
func (f *fakeOIDCClient) AuthorizationURL(_ context.Context, state, nonce, challenge, contextType, tenantHint string) (string, error) {
f.state, f.nonce, f.challenge = state, nonce, challenge
f.contextType = contextType
f.tenantHint = tenantHint
return f.authorizationURL, nil
}