fix: 修复 OIDC 用户预配与跨标签页登录态
增加受控 JIT 本地用户投影,并使用 HttpOnly Cookie 在新标签页恢复认证中心登录态。补充错误语义、安全校验、自动化测试与回滚配置。
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
type fakeOIDCUserResolver struct {
|
||||
result store.ResolveOrProvisionOIDCUserResult
|
||||
err error
|
||||
calls int
|
||||
input store.ResolveOrProvisionOIDCUserInput
|
||||
}
|
||||
|
||||
func (f *fakeOIDCUserResolver) ResolveOrProvisionOIDCUser(_ context.Context, input store.ResolveOrProvisionOIDCUserInput) (store.ResolveOrProvisionOIDCUserResult, error) {
|
||||
f.calls++
|
||||
f.input = input
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) {
|
||||
resolver := &fakeOIDCUserResolver{result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{
|
||||
ID: "platform-user",
|
||||
Username: "alice",
|
||||
Roles: []string{"basic"},
|
||||
TenantID: "external-tenant",
|
||||
Source: "oidc",
|
||||
GatewayUserID: "21dd9ccb-3793-4023-ab31-4d04982ca4d3",
|
||||
GatewayTenantID: "8f17f3ac-136e-4d0f-b097-655e2a6240a3",
|
||||
TenantKey: "default",
|
||||
UserGroupID: "6dcf86f2-8eaf-4b43-8e69-181315db24f0",
|
||||
}}}
|
||||
server := &Server{
|
||||
cfg: config.Config{
|
||||
OIDCIssuer: "https://auth.test.example/realms/easyai",
|
||||
OIDCGatewayTenantKey: "default",
|
||||
OIDCJITProvisioningEnabled: true,
|
||||
},
|
||||
oidcUserResolver: resolver,
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user.GatewayUserID == "" || user.GatewayTenantID == "" || user.UserGroupID == "" {
|
||||
t.Fatalf("resolved Gateway context missing: %+v", user)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
request = request.WithContext(auth.WithUser(request.Context(), &auth.User{
|
||||
ID: "platform-user",
|
||||
Username: "alice",
|
||||
Roles: []string{"basic"},
|
||||
TenantID: "external-tenant",
|
||||
Source: "oidc",
|
||||
}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.resolveGatewayUser(next).ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", recorder.Code)
|
||||
}
|
||||
if resolver.calls != 1 || resolver.input.Subject != "platform-user" || resolver.input.GatewayTenantKey != "default" || !resolver.input.ProvisioningEnabled {
|
||||
t.Fatalf("unexpected resolver call: calls=%d input=%+v", resolver.calls, resolver.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGatewayUserLeavesNonOIDCIdentityChainsUnchanged(t *testing.T) {
|
||||
for _, source := range []string{"gateway", "api_key", "server-main"} {
|
||||
t.Run(source, func(t *testing.T) {
|
||||
resolver := &fakeOIDCUserResolver{}
|
||||
server := &Server{oidcUserResolver: resolver, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
original := &auth.User{ID: "local-user", Source: source, GatewayUserID: "local-user"}
|
||||
request = request.WithContext(auth.WithUser(request.Context(), original))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.resolveGatewayUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resolved, _ := auth.UserFromContext(r.Context())
|
||||
if resolved != original {
|
||||
t.Fatalf("non-OIDC identity context was replaced: %+v", resolved)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusNoContent || resolver.calls != 0 {
|
||||
t.Fatalf("status=%d resolver calls=%d", recorder.Code, resolver.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGatewayUserReturnsStructuredErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{name: "not provisioned", err: store.ErrOIDCUserNotProvisioned, status: http.StatusForbidden, code: "GATEWAY_USER_NOT_PROVISIONED"},
|
||||
{name: "disabled", err: store.ErrOIDCUserDisabled, status: http.StatusForbidden, code: "GATEWAY_USER_DISABLED"},
|
||||
{name: "tenant unavailable", err: store.ErrOIDCTenantUnavailable, status: http.StatusServiceUnavailable, code: "GATEWAY_TENANT_UNAVAILABLE"},
|
||||
{name: "storage failure", err: errors.New("database unavailable"), status: http.StatusServiceUnavailable, code: "GATEWAY_USER_PROVISIONING_FAILED"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := &Server{
|
||||
cfg: config.Config{OIDCIssuer: "https://auth.test.example", OIDCGatewayTenantKey: "default"},
|
||||
oidcUserResolver: &fakeOIDCUserResolver{err: test.err},
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
request = request.WithContext(auth.WithUser(request.Context(), &auth.User{
|
||||
ID: "platform-user", Source: "oidc", TenantID: "external-tenant",
|
||||
}))
|
||||
recorder := httptest.NewRecorder()
|
||||
server.resolveGatewayUser(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("next handler must not run")
|
||||
})).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != test.status {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, test.status)
|
||||
}
|
||||
var envelope struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status int `json:"status"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode error envelope: %v", err)
|
||||
}
|
||||
if envelope.Error.Code != test.code || envelope.Error.Status != test.status || envelope.Error.Message == "" {
|
||||
t.Fatalf("unexpected error envelope: %+v", envelope)
|
||||
}
|
||||
if envelope.Error.Message == test.err.Error() {
|
||||
t.Fatalf("internal error leaked to response: %q", envelope.Error.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user