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:
2026-07-31 15:28:12 +08:00
parent c0296dbf06
commit 59f0817938
13 changed files with 186 additions and 69 deletions
+2 -3
View File
@@ -5909,10 +5909,9 @@
},
{
"type": "string",
"description": "显式登录上下文:platform 或 tenant",
"description": "兼容旧入口的显式登录上下文:platform 或 tenant;省略时由认证中心选择",
"name": "contextType",
"in": "query",
"required": true
"in": "query"
},
{
"type": "string",
+1 -2
View File
@@ -7895,10 +7895,9 @@ paths:
in: query
name: returnTo
type: string
- description: 显式登录上下文:platform 或 tenant
- description: 兼容旧入口的显式登录上下文:platform 或 tenant;省略时由认证中心选择
in: query
name: contextType
required: true
type: string
- description: Tenant 上下文可选的稳定 Tenant UUID
in: query
+7 -5
View File
@@ -86,14 +86,14 @@ func (c *OIDCPublicClient) AuthorizationURL(
return "", errors.New("state, nonce and PKCE verifier are required")
}
contextType = strings.TrimSpace(contextType)
if contextType != "platform" && contextType != "tenant" {
return "", errors.New("context type must be platform or tenant")
if contextType != "" && contextType != "platform" && contextType != "tenant" {
return "", errors.New("context type must be empty, platform or tenant")
}
if strings.TrimSpace(tenantHint) != "" && uuid.Validate(strings.TrimSpace(tenantHint)) != nil {
return "", errors.New("tenant hint must be a UUID")
}
if contextType == "platform" && strings.TrimSpace(tenantHint) != "" {
return "", errors.New("platform context cannot bind a tenant hint")
if contextType != "tenant" && strings.TrimSpace(tenantHint) != "" {
return "", errors.New("only tenant context can bind a tenant hint")
}
config, _, err := c.configuration(ctx)
if err != nil {
@@ -102,7 +102,9 @@ func (c *OIDCPublicClient) AuthorizationURL(
options := []oauth2.AuthCodeOption{
oidc.Nonce(nonce),
oauth2.S256ChallengeOption(pkceVerifier),
oauth2.SetAuthURLParam("context_type", contextType),
}
if contextType != "" {
options = append(options, oauth2.SetAuthURLParam("context_type", contextType))
}
if 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) {
_, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "production",
+15 -5
View File
@@ -36,7 +36,7 @@ const (
// @Description Gateway 生成 state、nonce 和 PKCE S256 参数,并跳转认证中心;浏览器不接触 Token。
// @Tags auth
// @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"
// @Success 303
// @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"))
tenantHint := strings.TrimSpace(r.URL.Query().Get("tenantHint"))
validContext := contextType == "tenant" ||
validContext := contextType == "" ||
contextType == "tenant" ||
contextType == "platform" &&
runtime.Revision.TenantMode == "multi_tenant"
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), "认证中心访问令牌校验失败")
return
}
if identity.ContextType != transaction.ContextType ||
transaction.TenantHint != "" &&
identity.TenantID != transaction.TenantHint {
if !oidcLoginContextMatches(transaction, identity) {
s.writeOIDCTokenFailure(
w, r, "ACCESS_TOKEN_CONTEXT_MISMATCH",
"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)
}
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(
ctx context.Context,
r *http.Request,
+78 -2
View File
@@ -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) {
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
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{
"/api/v1/auth/oidc/login",
"/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",
} {
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) {
cipher, err := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
if err != nil {
@@ -42,11 +42,11 @@ func NewLoginTransactionWithContext(
}
contextType = strings.TrimSpace(contextType)
tenantHint = strings.TrimSpace(tenantHint)
if contextType != "platform" && contextType != "tenant" {
return LoginTransaction{}, errors.New("contextType must be platform or tenant")
if contextType != "" && contextType != "platform" && contextType != "tenant" {
return LoginTransaction{}, errors.New("contextType must be empty, platform or tenant")
}
if contextType == "platform" && tenantHint != "" {
return LoginTransaction{}, errors.New("platform context cannot bind a tenant hint")
if contextType != "tenant" && tenantHint != "" {
return LoginTransaction{}, errors.New("only tenant context can bind a tenant hint")
}
if tenantHint != "" && uuid.Validate(tenantHint) != nil {
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
}
if transaction.State == "" || transaction.Nonce == "" || transaction.PKCEVerifier == "" || !ValidReturnTo(transaction.ReturnTo) ||
transaction.ContextType != "platform" && transaction.ContextType != "tenant" ||
transaction.ContextType == "platform" && transaction.TenantHint != "" ||
transaction.ContextType != "" && transaction.ContextType != "platform" && transaction.ContextType != "tenant" ||
transaction.ContextType != "tenant" && transaction.TenantHint != "" ||
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)) {
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) {
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
@@ -61,7 +78,7 @@ func TestLoginTransactionRejectsInvalidContextBinding(t *testing.T) {
contextType string
tenantHint string
}{
{contextType: ""},
{contextType: "", tenantHint: tenantHint},
{contextType: "account"},
{contextType: "platform", tenantHint: tenantHint},
} {