feat(identity): 收敛 AI Gateway 统一认证入口
Web 登录页合并为单一统一认证入口,未指定上下文时由认证中心在登录后决策。继续接受旧客户端显式传入 platform 或 tenant,并保持回调上下文及 tenant_hint 的绑定校验。同步更新 OpenAPI 产物。\n\n验证:Go 全量测试与 go vet 通过;Web lint、141 项 Vitest 和生产构建通过;本地 platform.admin 登录及管理工作台访问通过。
This commit is contained in:
@@ -5909,10 +5909,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "显式登录上下文:platform 或 tenant",
|
"description": "兼容旧入口的显式登录上下文:platform 或 tenant;省略时由认证中心选择",
|
||||||
"name": "contextType",
|
"name": "contextType",
|
||||||
"in": "query",
|
"in": "query"
|
||||||
"required": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -7895,10 +7895,9 @@ paths:
|
|||||||
in: query
|
in: query
|
||||||
name: returnTo
|
name: returnTo
|
||||||
type: string
|
type: string
|
||||||
- description: 显式登录上下文:platform 或 tenant
|
- description: 兼容旧入口的显式登录上下文:platform 或 tenant;省略时由认证中心选择
|
||||||
in: query
|
in: query
|
||||||
name: contextType
|
name: contextType
|
||||||
required: true
|
|
||||||
type: string
|
type: string
|
||||||
- description: Tenant 上下文可选的稳定 Tenant UUID
|
- description: Tenant 上下文可选的稳定 Tenant UUID
|
||||||
in: query
|
in: query
|
||||||
|
|||||||
@@ -86,14 +86,14 @@ func (c *OIDCPublicClient) AuthorizationURL(
|
|||||||
return "", errors.New("state, nonce and PKCE verifier are required")
|
return "", errors.New("state, nonce and PKCE verifier are required")
|
||||||
}
|
}
|
||||||
contextType = strings.TrimSpace(contextType)
|
contextType = strings.TrimSpace(contextType)
|
||||||
if contextType != "platform" && contextType != "tenant" {
|
if contextType != "" && contextType != "platform" && contextType != "tenant" {
|
||||||
return "", errors.New("context type must be platform or tenant")
|
return "", errors.New("context type must be empty, platform or tenant")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(tenantHint) != "" && uuid.Validate(strings.TrimSpace(tenantHint)) != nil {
|
if strings.TrimSpace(tenantHint) != "" && uuid.Validate(strings.TrimSpace(tenantHint)) != nil {
|
||||||
return "", errors.New("tenant hint must be a UUID")
|
return "", errors.New("tenant hint must be a UUID")
|
||||||
}
|
}
|
||||||
if contextType == "platform" && strings.TrimSpace(tenantHint) != "" {
|
if contextType != "tenant" && strings.TrimSpace(tenantHint) != "" {
|
||||||
return "", errors.New("platform context cannot bind a tenant hint")
|
return "", errors.New("only tenant context can bind a tenant hint")
|
||||||
}
|
}
|
||||||
config, _, err := c.configuration(ctx)
|
config, _, err := c.configuration(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -102,7 +102,9 @@ func (c *OIDCPublicClient) AuthorizationURL(
|
|||||||
options := []oauth2.AuthCodeOption{
|
options := []oauth2.AuthCodeOption{
|
||||||
oidc.Nonce(nonce),
|
oidc.Nonce(nonce),
|
||||||
oauth2.S256ChallengeOption(pkceVerifier),
|
oauth2.S256ChallengeOption(pkceVerifier),
|
||||||
oauth2.SetAuthURLParam("context_type", contextType),
|
}
|
||||||
|
if contextType != "" {
|
||||||
|
options = append(options, oauth2.SetAuthURLParam("context_type", contextType))
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(tenantHint) != "" {
|
if strings.TrimSpace(tenantHint) != "" {
|
||||||
options = append(options, oauth2.SetAuthURLParam("tenant_hint", strings.TrimSpace(tenantHint)))
|
options = append(options, oauth2.SetAuthURLParam("tenant_hint", strings.TrimSpace(tenantHint)))
|
||||||
|
|||||||
@@ -92,6 +92,43 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOIDCPublicClientOmitsOptionalContextForUnifiedLogin(t *testing.T) {
|
||||||
|
var issuer string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/.well-known/openid-configuration" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
|
||||||
|
"token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
issuer = server.URL
|
||||||
|
|
||||||
|
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
|
||||||
|
AppEnv: "test", Issuer: issuer, ClientID: "gateway-public",
|
||||||
|
RedirectURI: "https://gateway.example.com/callback",
|
||||||
|
PostLogoutRedirectURI: "https://gateway.example.com/",
|
||||||
|
HTTPClient: server.Client(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
authorizationURL, err := client.AuthorizationURL(
|
||||||
|
context.Background(), "state", "nonce",
|
||||||
|
"test-pkce-verifier-with-at-least-43-characters-1234", "", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
parsed, _ := url.Parse(authorizationURL)
|
||||||
|
if parsed.Query().Has("context_type") || parsed.Query().Has("tenant_hint") {
|
||||||
|
t.Fatalf("unified login leaked optional context binding: %v", parsed.Query())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOIDCPublicClientRejectsOfflineAccess(t *testing.T) {
|
func TestOIDCPublicClientRejectsOfflineAccess(t *testing.T) {
|
||||||
_, err := NewOIDCPublicClient(OIDCPublicClientConfig{
|
_, err := NewOIDCPublicClient(OIDCPublicClientConfig{
|
||||||
AppEnv: "production",
|
AppEnv: "production",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const (
|
|||||||
// @Description Gateway 生成 state、nonce 和 PKCE S256 参数,并跳转认证中心;浏览器不接触 Token。
|
// @Description Gateway 生成 state、nonce 和 PKCE S256 参数,并跳转认证中心;浏览器不接触 Token。
|
||||||
// @Tags auth
|
// @Tags auth
|
||||||
// @Param returnTo query string false "登录后返回的站内相对路径"
|
// @Param returnTo query string false "登录后返回的站内相对路径"
|
||||||
// @Param contextType query string true "显式登录上下文:platform 或 tenant"
|
// @Param contextType query string false "兼容旧入口的显式登录上下文:platform 或 tenant;省略时由认证中心选择"
|
||||||
// @Param tenantHint query string false "Tenant 上下文可选的稳定 Tenant UUID"
|
// @Param tenantHint query string false "Tenant 上下文可选的稳定 Tenant UUID"
|
||||||
// @Success 303
|
// @Success 303
|
||||||
// @Failure 400 {object} ErrorEnvelope
|
// @Failure 400 {object} ErrorEnvelope
|
||||||
@@ -54,7 +54,8 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
contextType := strings.TrimSpace(r.URL.Query().Get("contextType"))
|
contextType := strings.TrimSpace(r.URL.Query().Get("contextType"))
|
||||||
tenantHint := strings.TrimSpace(r.URL.Query().Get("tenantHint"))
|
tenantHint := strings.TrimSpace(r.URL.Query().Get("tenantHint"))
|
||||||
validContext := contextType == "tenant" ||
|
validContext := contextType == "" ||
|
||||||
|
contextType == "tenant" ||
|
||||||
contextType == "platform" &&
|
contextType == "platform" &&
|
||||||
runtime.Revision.TenantMode == "multi_tenant"
|
runtime.Revision.TenantMode == "multi_tenant"
|
||||||
validTenantHint := tenantHint == "" ||
|
validTenantHint := tenantHint == "" ||
|
||||||
@@ -141,9 +142,7 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.writeOIDCTokenFailure(w, r, "ACCESS_TOKEN_INVALID", auth.OIDCValidationCategory(err), "认证中心访问令牌校验失败")
|
s.writeOIDCTokenFailure(w, r, "ACCESS_TOKEN_INVALID", auth.OIDCValidationCategory(err), "认证中心访问令牌校验失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if identity.ContextType != transaction.ContextType ||
|
if !oidcLoginContextMatches(transaction, identity) {
|
||||||
transaction.TenantHint != "" &&
|
|
||||||
identity.TenantID != transaction.TenantHint {
|
|
||||||
s.writeOIDCTokenFailure(
|
s.writeOIDCTokenFailure(
|
||||||
w, r, "ACCESS_TOKEN_CONTEXT_MISMATCH",
|
w, r, "ACCESS_TOKEN_CONTEXT_MISMATCH",
|
||||||
"STABLE_IDENTITY_CLAIMS_INVALID",
|
"STABLE_IDENTITY_CLAIMS_INVALID",
|
||||||
@@ -190,6 +189,17 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, oidcReturnLocation(runtime.Revision.WebBaseURL, transaction.ReturnTo), http.StatusSeeOther)
|
http.Redirect(w, r, oidcReturnLocation(runtime.Revision.WebBaseURL, transaction.ReturnTo), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func oidcLoginContextMatches(
|
||||||
|
transaction oidcsession.LoginTransaction,
|
||||||
|
identity *auth.User,
|
||||||
|
) bool {
|
||||||
|
return identity != nil &&
|
||||||
|
(transaction.ContextType == "" ||
|
||||||
|
identity.ContextType == transaction.ContextType) &&
|
||||||
|
(transaction.TenantHint == "" ||
|
||||||
|
identity.TenantID == transaction.TenantHint)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) replaceOIDCBrowserSession(
|
func (s *Server) replaceOIDCBrowserSession(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
|
|||||||
@@ -50,6 +50,29 @@ func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStartOIDCLoginAllowsAuthCenterToSelectContext(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)),
|
||||||
|
}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.startOIDCLogin(recorder, httptest.NewRequest(
|
||||||
|
http.MethodGet, "/api/v1/auth/oidc/login?returnTo=%2Fworkspace", nil,
|
||||||
|
))
|
||||||
|
if recorder.Code != http.StatusSeeOther || client.contextType != "" || client.tenantHint != "" {
|
||||||
|
t.Fatalf("status=%d contextType=%q tenantHint=%q", recorder.Code, client.contextType, client.tenantHint)
|
||||||
|
}
|
||||||
|
cookies := recorder.Result().Cookies()
|
||||||
|
transaction, err := cipher.DecodeLoginTransaction(cookies[0].Value, time.Now())
|
||||||
|
if err != nil || transaction.ContextType != "" || transaction.ReturnTo != "/workspace" {
|
||||||
|
t.Fatalf("transaction=%+v err=%v", transaction, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) {
|
func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) {
|
||||||
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
|
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
|
||||||
server := &Server{
|
server := &Server{
|
||||||
@@ -114,10 +137,10 @@ func TestStartOIDCLoginRejectsInvalidOrSingleTenantHint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStartOIDCLoginRejectsMissingOrIncompatibleContext(t *testing.T) {
|
func TestStartOIDCLoginRejectsInvalidOrIncompatibleContext(t *testing.T) {
|
||||||
for _, path := range []string{
|
for _, path := range []string{
|
||||||
"/api/v1/auth/oidc/login",
|
|
||||||
"/api/v1/auth/oidc/login?contextType=account",
|
"/api/v1/auth/oidc/login?contextType=account",
|
||||||
|
"/api/v1/auth/oidc/login?tenantHint=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
"/api/v1/auth/oidc/login?contextType=platform&tenantHint=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
"/api/v1/auth/oidc/login?contextType=platform&tenantHint=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
} {
|
} {
|
||||||
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
|
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
|
||||||
@@ -140,6 +163,59 @@ func TestStartOIDCLoginRejectsMissingOrIncompatibleContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOIDCLoginContextBindingSupportsUnifiedAndExplicitEntries(t *testing.T) {
|
||||||
|
tenantID := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
transaction oidcsession.LoginTransaction
|
||||||
|
identity *auth.User
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unified platform",
|
||||||
|
transaction: oidcsession.LoginTransaction{},
|
||||||
|
identity: &auth.User{ContextType: "platform"},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unified tenant",
|
||||||
|
transaction: oidcsession.LoginTransaction{},
|
||||||
|
identity: &auth.User{ContextType: "tenant", TenantID: tenantID},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legacy explicit platform matches",
|
||||||
|
transaction: oidcsession.LoginTransaction{ContextType: "platform"},
|
||||||
|
identity: &auth.User{ContextType: "platform"},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legacy explicit platform rejects tenant",
|
||||||
|
transaction: oidcsession.LoginTransaction{ContextType: "platform"},
|
||||||
|
identity: &auth.User{ContextType: "tenant", TenantID: tenantID},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tenant hint remains bound",
|
||||||
|
transaction: oidcsession.LoginTransaction{ContextType: "tenant", TenantHint: tenantID},
|
||||||
|
identity: &auth.User{ContextType: "tenant", TenantID: tenantID},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tenant hint mismatch",
|
||||||
|
transaction: oidcsession.LoginTransaction{ContextType: "tenant", TenantHint: tenantID},
|
||||||
|
identity: &auth.User{ContextType: "tenant", TenantID: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := oidcLoginContextMatches(test.transaction, test.identity); got != test.want {
|
||||||
|
t.Fatalf("oidcLoginContextMatches() = %v, want %v", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) {
|
func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) {
|
||||||
cipher, err := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
|
cipher, err := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ func NewLoginTransactionWithContext(
|
|||||||
}
|
}
|
||||||
contextType = strings.TrimSpace(contextType)
|
contextType = strings.TrimSpace(contextType)
|
||||||
tenantHint = strings.TrimSpace(tenantHint)
|
tenantHint = strings.TrimSpace(tenantHint)
|
||||||
if contextType != "platform" && contextType != "tenant" {
|
if contextType != "" && contextType != "platform" && contextType != "tenant" {
|
||||||
return LoginTransaction{}, errors.New("contextType must be platform or tenant")
|
return LoginTransaction{}, errors.New("contextType must be empty, platform or tenant")
|
||||||
}
|
}
|
||||||
if contextType == "platform" && tenantHint != "" {
|
if contextType != "tenant" && tenantHint != "" {
|
||||||
return LoginTransaction{}, errors.New("platform context cannot bind a tenant hint")
|
return LoginTransaction{}, errors.New("only tenant context can bind a tenant hint")
|
||||||
}
|
}
|
||||||
if tenantHint != "" && uuid.Validate(tenantHint) != nil {
|
if tenantHint != "" && uuid.Validate(tenantHint) != nil {
|
||||||
return LoginTransaction{}, errors.New("tenantHint must be a UUID")
|
return LoginTransaction{}, errors.New("tenantHint must be a UUID")
|
||||||
@@ -88,8 +88,8 @@ func (c *Cipher) DecodeLoginTransaction(encoded string, now time.Time) (LoginTra
|
|||||||
return LoginTransaction{}, err
|
return LoginTransaction{}, err
|
||||||
}
|
}
|
||||||
if transaction.State == "" || transaction.Nonce == "" || transaction.PKCEVerifier == "" || !ValidReturnTo(transaction.ReturnTo) ||
|
if transaction.State == "" || transaction.Nonce == "" || transaction.PKCEVerifier == "" || !ValidReturnTo(transaction.ReturnTo) ||
|
||||||
transaction.ContextType != "platform" && transaction.ContextType != "tenant" ||
|
transaction.ContextType != "" && transaction.ContextType != "platform" && transaction.ContextType != "tenant" ||
|
||||||
transaction.ContextType == "platform" && transaction.TenantHint != "" ||
|
transaction.ContextType != "tenant" && transaction.TenantHint != "" ||
|
||||||
transaction.TenantHint != "" && uuid.Validate(transaction.TenantHint) != nil ||
|
transaction.TenantHint != "" && uuid.Validate(transaction.TenantHint) != nil ||
|
||||||
transaction.CreatedAt.IsZero() || now.Before(transaction.CreatedAt.Add(-time.Minute)) || !now.Before(transaction.CreatedAt.Add(10*time.Minute)) {
|
transaction.CreatedAt.IsZero() || now.Before(transaction.CreatedAt.Add(-time.Minute)) || !now.Before(transaction.CreatedAt.Add(10*time.Minute)) {
|
||||||
return LoginTransaction{}, errors.New("OIDC login transaction has expired or is invalid")
|
return LoginTransaction{}, errors.New("OIDC login transaction has expired or is invalid")
|
||||||
|
|||||||
@@ -54,6 +54,23 @@ func TestLoginTransactionEncryptsTenantHint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoginTransactionAllowsIssuerSelectedContextWithoutTenantHint(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
|
||||||
|
cipher, _ := NewCipher(bytes.Repeat([]byte{9}, 32))
|
||||||
|
transaction, err := NewLoginTransactionWithContext("/", "", "", now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
encoded, err := cipher.EncodeLoginTransaction(transaction)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
decoded, err := cipher.DecodeLoginTransaction(encoded, now)
|
||||||
|
if err != nil || decoded.ContextType != "" || decoded.TenantHint != "" {
|
||||||
|
t.Fatalf("decoded transaction=%+v err=%v", decoded, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoginTransactionRejectsInvalidContextBinding(t *testing.T) {
|
func TestLoginTransactionRejectsInvalidContextBinding(t *testing.T) {
|
||||||
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
||||||
tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||||
@@ -61,7 +78,7 @@ func TestLoginTransactionRejectsInvalidContextBinding(t *testing.T) {
|
|||||||
contextType string
|
contextType string
|
||||||
tenantHint string
|
tenantHint string
|
||||||
}{
|
}{
|
||||||
{contextType: ""},
|
{contextType: "", tenantHint: tenantHint},
|
||||||
{contextType: "account"},
|
{contextType: "account"},
|
||||||
{contextType: "platform", tenantHint: tenantHint},
|
{contextType: "platform", tenantHint: tenantHint},
|
||||||
} {
|
} {
|
||||||
|
|||||||
+3
-12
@@ -135,7 +135,6 @@ import {
|
|||||||
loadOIDCRuntimeConfiguration,
|
loadOIDCRuntimeConfiguration,
|
||||||
startOIDCLogin,
|
startOIDCLogin,
|
||||||
startOIDCLogout,
|
startOIDCLogout,
|
||||||
type OIDCContextType,
|
|
||||||
} from './lib/oidc';
|
} from './lib/oidc';
|
||||||
import { runTask, type RunTaskOptions } from './lib/run-task';
|
import { runTask, type RunTaskOptions } from './lib/run-task';
|
||||||
import { AdminPage } from './pages/AdminPage';
|
import { AdminPage } from './pages/AdminPage';
|
||||||
@@ -281,7 +280,6 @@ export function App() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [oidcCallbackError, setOIDCCallbackError] = useState(() => consumeOIDCCallbackError());
|
const [oidcCallbackError, setOIDCCallbackError] = useState(() => consumeOIDCCallbackError());
|
||||||
const [oidcEnabled, setOIDCEnabled] = useState(false);
|
const [oidcEnabled, setOIDCEnabled] = useState(false);
|
||||||
const [oidcContextTypes, setOIDCContextTypes] = useState<OIDCContextType[]>([]);
|
|
||||||
const currentCredentialRef = useRef(token);
|
const currentCredentialRef = useRef(token);
|
||||||
const resetAuthenticatedSessionRef = useRef<() => void>(() => undefined);
|
const resetAuthenticatedSessionRef = useRef<() => void>(() => undefined);
|
||||||
currentCredentialRef.current = token;
|
currentCredentialRef.current = token;
|
||||||
@@ -340,7 +338,6 @@ export function App() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const identityEnabled = configuration.enabled && configuration.oidcLogin;
|
const identityEnabled = configuration.enabled && configuration.oidcLogin;
|
||||||
setOIDCEnabled(identityEnabled);
|
setOIDCEnabled(identityEnabled);
|
||||||
setOIDCContextTypes(configuration.contextTypes);
|
|
||||||
if (force && currentCredentialRef.current === OIDC_BROWSER_SESSION_CREDENTIAL) {
|
if (force && currentCredentialRef.current === OIDC_BROWSER_SESSION_CREDENTIAL) {
|
||||||
await reconcileOIDCBrowserSessionAfterRuntimeChange({
|
await reconcileOIDCBrowserSessionAfterRuntimeChange({
|
||||||
credential: currentCredentialRef.current,
|
credential: currentCredentialRef.current,
|
||||||
@@ -1307,14 +1304,11 @@ export function App() {
|
|||||||
navigatePath('/');
|
navigatePath('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
function loginWithOIDC(
|
function loginWithOIDC() {
|
||||||
contextType: OIDCContextType =
|
|
||||||
oidcContextTypes.includes('platform') ? 'platform' : 'tenant',
|
|
||||||
) {
|
|
||||||
setState('loading');
|
setState('loading');
|
||||||
setError('');
|
setError('');
|
||||||
setOIDCCallbackError(null);
|
setOIDCCallbackError(null);
|
||||||
void startOIDCLogin(contextType).catch((err) => {
|
void startOIDCLogin().catch((err) => {
|
||||||
setState('error');
|
setState('error');
|
||||||
setError(err instanceof Error ? err.message : '统一认证登录失败');
|
setError(err instanceof Error ? err.message : '统一认证登录失败');
|
||||||
});
|
});
|
||||||
@@ -1327,13 +1321,12 @@ export function App() {
|
|||||||
const configuration = await loadOIDCRuntimeConfiguration(true);
|
const configuration = await loadOIDCRuntimeConfiguration(true);
|
||||||
const identityEnabled = configuration.enabled && configuration.oidcLogin;
|
const identityEnabled = configuration.enabled && configuration.oidcLogin;
|
||||||
setOIDCEnabled(identityEnabled);
|
setOIDCEnabled(identityEnabled);
|
||||||
setOIDCContextTypes(configuration.contextTypes);
|
|
||||||
if (
|
if (
|
||||||
loginEntryAction(identityEnabled) === 'oidc' &&
|
loginEntryAction(identityEnabled) === 'oidc' &&
|
||||||
configuration.contextTypes.length === 1
|
configuration.contextTypes.length === 1
|
||||||
) {
|
) {
|
||||||
setOIDCCallbackError(null);
|
setOIDCCallbackError(null);
|
||||||
await startOIDCLogin(configuration.contextTypes[0], configuration);
|
await startOIDCLogin(undefined, configuration);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState('idle');
|
setState('idle');
|
||||||
@@ -1492,7 +1485,6 @@ export function App() {
|
|||||||
onSubmitLogin={submitLogin}
|
onSubmitLogin={submitLogin}
|
||||||
onSubmitRegister={submitRegister}
|
onSubmitRegister={submitRegister}
|
||||||
oidcEnabled={oidcEnabled}
|
oidcEnabled={oidcEnabled}
|
||||||
oidcContextTypes={oidcContextTypes}
|
|
||||||
onOIDCLogin={loginWithOIDC}
|
onOIDCLogin={loginWithOIDC}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -1565,7 +1557,6 @@ export function App() {
|
|||||||
onSubmitLogin={submitLogin}
|
onSubmitLogin={submitLogin}
|
||||||
onSubmitRegister={submitRegister}
|
onSubmitRegister={submitRegister}
|
||||||
oidcEnabled={oidcEnabled}
|
oidcEnabled={oidcEnabled}
|
||||||
oidcContextTypes={oidcContextTypes}
|
|
||||||
onOIDCLogin={loginWithOIDC}
|
onOIDCLogin={loginWithOIDC}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,16 +15,16 @@ const baseProps = {
|
|||||||
onSubmitExternalToken: vi.fn(),
|
onSubmitExternalToken: vi.fn(),
|
||||||
onSubmitLogin: vi.fn(),
|
onSubmitLogin: vi.fn(),
|
||||||
onSubmitRegister: vi.fn(),
|
onSubmitRegister: vi.fn(),
|
||||||
oidcContextTypes: ['platform', 'tenant'] as const,
|
|
||||||
onOIDCLogin: vi.fn(),
|
onOIDCLogin: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('AuthPanel', () => {
|
describe('AuthPanel', () => {
|
||||||
it('shows explicit platform and tenant entries when OIDC is enabled', () => {
|
it('shows one unified authentication entry when OIDC is enabled', () => {
|
||||||
const html = renderToStaticMarkup(<AuthPanel {...baseProps} oidcEnabled />);
|
const html = renderToStaticMarkup(<AuthPanel {...baseProps} oidcEnabled />);
|
||||||
|
|
||||||
expect(html).toContain('使用平台账号登录');
|
expect(html).toContain('使用统一认证登录');
|
||||||
expect(html).toContain('使用租户账号登录');
|
expect(html).not.toContain('使用平台账号登录');
|
||||||
|
expect(html).not.toContain('使用租户账号登录');
|
||||||
expect(html).not.toContain('用户名或邮箱');
|
expect(html).not.toContain('用户名或邮箱');
|
||||||
expect(html).not.toContain('注册账号');
|
expect(html).not.toContain('注册账号');
|
||||||
expect(html).not.toContain('外部 Token');
|
expect(html).not.toContain('外部 Token');
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { FormEvent } from 'react';
|
|||||||
import { LogIn, UserPlus } from 'lucide-react';
|
import { LogIn, UserPlus } from 'lucide-react';
|
||||||
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, PasswordInput, Tabs } from './ui';
|
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, PasswordInput, Tabs } from './ui';
|
||||||
import type { AuthMode, LoadState, LoginForm, RegisterForm } from '../types';
|
import type { AuthMode, LoadState, LoginForm, RegisterForm } from '../types';
|
||||||
import type { OIDCContextType } from '../lib/oidc';
|
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ value: 'login', label: '账号登录' },
|
{ value: 'login', label: '账号登录' },
|
||||||
@@ -24,8 +23,7 @@ export function AuthPanel(props: {
|
|||||||
onSubmitLogin: (event: FormEvent<HTMLFormElement>) => void;
|
onSubmitLogin: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
onSubmitRegister: (event: FormEvent<HTMLFormElement>) => void;
|
onSubmitRegister: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
oidcEnabled: boolean;
|
oidcEnabled: boolean;
|
||||||
oidcContextTypes: readonly OIDCContextType[];
|
onOIDCLogin: () => void;
|
||||||
onOIDCLogin: (contextType: OIDCContextType) => void;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="authShell" aria-label="登录">
|
<section className="authShell" aria-label="登录">
|
||||||
@@ -39,26 +37,14 @@ export function AuthPanel(props: {
|
|||||||
<CardContent className="authContent">
|
<CardContent className="authContent">
|
||||||
{props.oidcEnabled ? (
|
{props.oidcEnabled ? (
|
||||||
<div className="formGrid">
|
<div className="formGrid">
|
||||||
{props.oidcContextTypes.includes('platform') && (
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
disabled={props.state === 'loading'}
|
||||||
disabled={props.state === 'loading'}
|
onClick={props.onOIDCLogin}
|
||||||
onClick={() => props.onOIDCLogin('platform')}
|
>
|
||||||
>
|
<LogIn size={15} />
|
||||||
<LogIn size={15} />
|
使用统一认证登录
|
||||||
使用平台账号登录
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{props.oidcContextTypes.includes('tenant') && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={props.state === 'loading'}
|
|
||||||
onClick={() => props.onOIDCLogin('tenant')}
|
|
||||||
>
|
|
||||||
<LogIn size={15} />
|
|
||||||
使用租户账号登录
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ describe('OIDC BFF navigation', () => {
|
|||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('binds platform context and returnTo at the Gateway login endpoint', async () => {
|
it('lets the authentication center select context and binds returnTo', async () => {
|
||||||
vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api');
|
vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api');
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -18,11 +18,11 @@ describe('OIDC BFF navigation', () => {
|
|||||||
location: { pathname: '/workspace', search: '?tab=wallet', hash: '#balance', assign },
|
location: { pathname: '/workspace', search: '?tab=wallet', hash: '#balance', assign },
|
||||||
});
|
});
|
||||||
const { startOIDCLogin } = await import('./oidc');
|
const { startOIDCLogin } = await import('./oidc');
|
||||||
await startOIDCLogin('platform');
|
await startOIDCLogin();
|
||||||
const target = new URL(String(assign.mock.calls[0]?.[0]));
|
const target = new URL(String(assign.mock.calls[0]?.[0]));
|
||||||
expect(target.pathname).toBe('/gateway-api/api/v1/auth/oidc/login');
|
expect(target.pathname).toBe('/gateway-api/api/v1/auth/oidc/login');
|
||||||
expect(target.searchParams.get('returnTo')).toBe('/workspace?tab=wallet#balance');
|
expect(target.searchParams.get('returnTo')).toBe('/workspace?tab=wallet#balance');
|
||||||
expect(target.searchParams.get('contextType')).toBe('platform');
|
expect(target.searchParams.has('contextType')).toBe(false);
|
||||||
expect(target.searchParams.has('client_id')).toBe(false);
|
expect(target.searchParams.has('client_id')).toBe(false);
|
||||||
expect(target.searchParams.has('code_challenge')).toBe(false);
|
expect(target.searchParams.has('code_challenge')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export async function loadOIDCRuntimeConfiguration(force = false): Promise<OIDCR
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startOIDCLogin(
|
export async function startOIDCLogin(
|
||||||
contextType: OIDCContextType,
|
contextType?: OIDCContextType,
|
||||||
configuration?: OIDCRuntimeConfiguration,
|
configuration?: OIDCRuntimeConfiguration,
|
||||||
) {
|
) {
|
||||||
configuration ??= await loadOIDCRuntimeConfiguration(true);
|
configuration ??= await loadOIDCRuntimeConfiguration(true);
|
||||||
@@ -100,12 +100,12 @@ export async function startOIDCLogin(
|
|||||||
!configuration.enabled ||
|
!configuration.enabled ||
|
||||||
!configuration.oidcLogin ||
|
!configuration.oidcLogin ||
|
||||||
!configuration.loginUrl ||
|
!configuration.loginUrl ||
|
||||||
!configuration.contextTypes.includes(contextType)
|
(contextType !== undefined && !configuration.contextTypes.includes(contextType))
|
||||||
) throw new Error('统一认证登录上下文未配置');
|
) throw new Error('统一认证登录上下文未配置');
|
||||||
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/';
|
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/';
|
||||||
const loginURL = new URL(identityEndpointURL(configuration.loginUrl), window.location.origin);
|
const loginURL = new URL(identityEndpointURL(configuration.loginUrl), window.location.origin);
|
||||||
loginURL.searchParams.set('returnTo', returnTo.startsWith('/') ? returnTo : '/');
|
loginURL.searchParams.set('returnTo', returnTo.startsWith('/') ? returnTo : '/');
|
||||||
loginURL.searchParams.set('contextType', contextType);
|
if (contextType !== undefined) loginURL.searchParams.set('contextType', contextType);
|
||||||
window.location.assign(loginURL.toString());
|
window.location.assign(loginURL.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user