feat(identity): 增加统一认证配置版本
新增统一认证 Revision 状态机、单 Active 数据库约束、SecretStore 引用字段、Break-glass 与本地租户门禁,并在关键身份变化或禁用时清理旧 BFF Session。\n\n同时实现标准应用接入 Manifest v1 消费端,接入码只进入请求 Body,Exchange Token 只进入 Authorization Header,禁用重定向并限制响应大小。\n\n验证:go test ./...;go vet ./...
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOnboardingClientKeepsCodeAndExchangeTokenOutOfURLs(t *testing.T) {
|
||||
const code = "onb1.aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.abcdefghijklmnopqrstuvwxyzABCDEFGH"
|
||||
const token = "ex1.bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb.abcdefghijklmnopqrstuvwxyzABCDEFGH"
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.String(), code) || strings.Contains(r.URL.String(), token) {
|
||||
t.Fatal("onboarding credential leaked into URL")
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/onboarding-exchanges":
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["onboarding_code"] != code || r.Header.Get("Authorization") != "" {
|
||||
t.Fatalf("unexpected claim request: body=%#v authorization=%q", body, r.Header.Get("Authorization"))
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"exchange_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","exchange_token":"` + token + `","expires_at":"2026-07-17T12:30:00Z","version":1}`))
|
||||
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/onboarding-exchanges/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/metadata":
|
||||
if r.Header.Get("Authorization") != "Bearer "+token || r.Header.Get("If-Match") != `W/"1"` || len(r.Header.Get("Idempotency-Key")) < 16 {
|
||||
t.Fatalf("unexpected exchange headers: %#v", r.Header)
|
||||
}
|
||||
w.Header().Set("ETag", `W/"2"`)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte(`{"exchange_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","application_id":"cccccccc-cccc-cccc-cccc-cccccccccccc","status":"preparing","version":2,"expires_at":"2026-07-17T12:30:00Z"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewOnboardingClient(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err := client.Claim(context.Background(), code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := ConsumerMetadata{PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", RedirectURIs: []string{"https://api.example.com/api/v1/auth/oidc/callback"}, LogoutURIs: []string{"https://gateway.example.com/"}}
|
||||
view, err := client.SubmitMetadata(context.Background(), claimed, metadata, "pairing-metadata-123456")
|
||||
if err != nil || view.Status != ExchangePreparing || view.Version != 2 {
|
||||
t.Fatalf("view=%#v err=%v", view, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestV1ValidationRequiresStableFieldsAndCapabilityDependencies(t *testing.T) {
|
||||
valid := ManifestV1{
|
||||
SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/shared",
|
||||
TenantID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", ApplicationID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"},
|
||||
Audience: "urn:easyai:resource:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", Scopes: []string{"openid", "gateway.access"},
|
||||
Clients: ManifestClients{BrowserLogin: &ManifestClient{ClientID: "browser"}, MachineToMachine: &ManifestClient{ClientID: "service"}},
|
||||
SecurityEvents: &ManifestSecurityEvents{TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", Audience: "urn:easyai:ssf:receiver:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"},
|
||||
}
|
||||
if err := valid.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invalid := valid
|
||||
invalid.Capabilities = []string{"session_revocation"}
|
||||
if err := invalid.Validate(); err == nil {
|
||||
t.Fatal("manifest with missing capability dependencies was accepted")
|
||||
}
|
||||
invalid = valid
|
||||
invalid.SchemaVersion = 2
|
||||
if err := invalid.Validate(); err == nil {
|
||||
t.Fatal("unsupported manifest schema was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnboardingClientRejectsRedirects(t *testing.T) {
|
||||
target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "should not be reached", http.StatusTeapot)
|
||||
}))
|
||||
defer target.Close()
|
||||
redirect := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer redirect.Close()
|
||||
client, err := NewOnboardingClient(redirect.URL, redirect.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.Claim(context.Background(), "onb1.aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.abcdefghijklmnopqrstuvwxyzABCDEFGH"); err == nil {
|
||||
t.Fatal("redirecting onboarding endpoint was accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user