feat(identity): 接入认证中心多租户登录

支持 Manifest V2 动态 tid 验证、Tenant Context 同步和租户内 JIT 投影,并保留 Manifest V1 与旧 Session 兼容。\n\n增加 tenantHint、租户切换、普通注册关闭及 application/principal/tenant 两级 SSF 撤销;迁移、定向安全测试和本地双租户跨仓 E2E 已通过。\n\nrelease_required=true;未执行 Release、Staging 或真实链路。
This commit is contained in:
2026-07-28 17:28:35 +08:00
parent 0b02e62c72
commit 5c679ff13f
45 changed files with 2986 additions and 139 deletions
+23 -5
View File
@@ -22,6 +22,7 @@ var (
)
type RevisionPolicy struct {
TenantMode string `json:"tenantMode"`
LocalTenantKey string `json:"localTenantKey"`
RolePrefix string `json:"rolePrefix"`
JITEnabled bool `json:"jitEnabled"`
@@ -32,7 +33,8 @@ type RevisionPolicy struct {
}
func (policy RevisionPolicy) Validate() error {
if strings.TrimSpace(policy.LocalTenantKey) == "" || strings.TrimSpace(policy.RolePrefix) == "" {
if strings.TrimSpace(policy.RolePrefix) == "" ||
policy.TenantMode != "multi_tenant" && strings.TrimSpace(policy.LocalTenantKey) == "" {
return errors.New("local tenant mapping and role prefix are required")
}
if policy.SessionIdleSeconds <= 0 || policy.SessionAbsoluteSeconds <= policy.SessionIdleSeconds ||
@@ -56,6 +58,7 @@ type Revision struct {
ID string `json:"id"`
State RevisionState `json:"state"`
SchemaVersion int `json:"schemaVersion"`
TenantMode string `json:"tenantMode,omitempty"`
AuthCenterURL string `json:"authCenterUrl"`
Issuer string `json:"issuer,omitempty"`
TenantID string `json:"tenantId,omitempty"`
@@ -76,6 +79,9 @@ type Revision struct {
SecurityEventIssuer string `json:"securityEventIssuer,omitempty"`
SecurityEventConfigURL string `json:"securityEventConfigurationUrl,omitempty"`
SecurityEventAudience string `json:"securityEventAudience,omitempty"`
TenantContextEndpoint string `json:"tenantContextEndpoint,omitempty"`
TenantContextAudience string `json:"tenantContextAudience,omitempty"`
TenantContextScope string `json:"tenantContextScope,omitempty"`
MachineCredentialRef string `json:"-"`
SessionEncryptionKeyRef string `json:"-"`
SessionIdleSeconds int `json:"sessionIdleSeconds"`
@@ -126,7 +132,7 @@ func NewDraft(input PairingInput, appEnv string) (Revision, error) {
publicBase, _ := exactBaseURL(input.PublicBaseURL, appEnv)
webBase, _ := exactBaseURL(input.WebBaseURL, appEnv)
return Revision{
ID: uuid.NewString(), State: RevisionDraft, SchemaVersion: 1,
ID: uuid.NewString(), State: RevisionDraft, SchemaVersion: 1, TenantMode: "single_tenant",
AuthCenterURL: authCenter, RolePrefix: "gateway.", LocalTenantKey: strings.TrimSpace(input.LocalTenantKey),
PublicBaseURL: publicBase, WebBaseURL: webBase, JITEnabled: true, LegacyJWTEnabled: input.LegacyJWTEnabled,
Scopes: []string{}, Capabilities: []string{}, SessionIdleSeconds: 1800, SessionAbsoluteSeconds: 28800,
@@ -151,8 +157,13 @@ func ApplyManifest(revision Revision, input ManifestApplication) (Revision, erro
if capabilities["oidc_login"] && strings.TrimSpace(input.SessionEncryptionKeyRef) == "" {
return Revision{}, errors.New("session encryption key reference is required")
}
if input.Manifest.SchemaVersion == 1 && strings.TrimSpace(revision.LocalTenantKey) == "" {
return Revision{}, ErrLocalTenantInvalid
}
revision.SchemaVersion = input.Manifest.SchemaVersion
revision.Issuer = strings.TrimRight(input.Manifest.Issuer, "/")
revision.TenantID = input.Manifest.TenantID
revision.TenantMode = input.Manifest.TenantMode
revision.ApplicationID = input.Manifest.ApplicationID
revision.Audience = input.Manifest.Audience
revision.Scopes = append([]string(nil), input.Manifest.Scopes...)
@@ -165,6 +176,16 @@ func ApplyManifest(revision Revision, input ManifestApplication) (Revision, erro
}
revision.TokenIntrospection = capabilities["token_introspection"]
revision.SessionRevocation = capabilities["session_revocation"]
if input.Manifest.SchemaVersion == 2 {
revision.LocalTenantKey = ""
revision.TenantContextEndpoint = input.Manifest.TenantContext.Endpoint
revision.TenantContextAudience = input.Manifest.TenantContext.Audience
revision.TenantContextScope = input.Manifest.TenantContext.Scope
} else {
revision.TenantContextEndpoint = ""
revision.TenantContextAudience = ""
revision.TenantContextScope = ""
}
if input.Manifest.SecurityEvents != nil {
revision.SecurityEventIssuer = input.Manifest.SecurityEvents.TransmitterIssuer
revision.SecurityEventConfigURL = input.Manifest.SecurityEvents.ConfigurationEndpoint
@@ -209,9 +230,6 @@ func (input PairingInput) ConsumerMetadata(sessionRevocation bool, appEnv string
if !sameSiteBaseURLs(publicBase, webBase) {
return ConsumerMetadata{}, errors.New("public base URL and web base URL must be same-site")
}
if strings.TrimSpace(input.LocalTenantKey) == "" {
return ConsumerMetadata{}, errors.New("local tenant mapping is required")
}
metadata := ConsumerMetadata{
PublicBaseURL: publicBase, WebBaseURL: webBase,
RedirectURIs: []string{publicBase + "/api/v1/auth/oidc/callback"},
@@ -176,3 +176,53 @@ func TestNewDraftAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) {
}
}
}
func TestApplyManifestV2RemovesFixedTenantMappingWhileV1StillRequiresIt(t *testing.T) {
draft, err := NewDraft(PairingInput{
AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com",
WebBaseURL: "https://gateway.example.com",
}, "production")
if err != nil {
t.Fatalf("multi-tenant pairing draft rejected: %v", err)
}
applied, err := ApplyManifest(draft, ManifestApplication{
AppEnv: "production", MachineCredentialRef: "identity-machine-example",
SessionEncryptionKeyRef: "identity-session-example",
Manifest: ManifestV2{
SchemaVersion: 2, TenantMode: "multi_tenant", Issuer: "https://auth.example.com/issuer/shared",
ApplicationID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
Audience: "urn:easyai:resource:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"},
Scopes: []string{"openid", "gateway.access"},
Clients: ManifestClients{
BrowserLogin: &ManifestClient{ClientID: "browser"}, MachineToMachine: &ManifestClient{ClientID: "service"},
},
TenantContext: &ManifestTenantContext{
Endpoint: "https://auth.example.com/api/v1/runtime/tenants/{tenantId}",
Audience: "urn:easyai:auth-center:tenant-context", Scope: "tenant.context.read",
},
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 != nil {
t.Fatal(err)
}
if applied.SchemaVersion != 2 || applied.TenantMode != "multi_tenant" || applied.TenantID != "" ||
applied.LocalTenantKey != "" || applied.TenantContextEndpoint == "" {
t.Fatalf("unexpected multi-tenant revision: %#v", applied)
}
v1Draft := draft
v1Draft.ID = "v1-draft"
if _, err := ApplyManifest(v1Draft, ManifestApplication{AppEnv: "production", Manifest: ManifestV1{
SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/shared",
TenantID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", ApplicationID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
Capabilities: []string{}, Scopes: []string{}, Clients: ManifestClients{},
}}); err == nil {
t.Fatal("Manifest V1 without a fixed local tenant mapping was accepted")
}
}
@@ -61,18 +61,31 @@ type ManifestSecurityEvents struct {
Audience string `json:"audience"`
}
type ManifestTenantContext struct {
Endpoint string `json:"endpoint"`
Audience string `json:"audience"`
Scope string `json:"scope"`
}
type ManifestV1 struct {
SchemaVersion int `json:"schema_version"`
TenantMode string `json:"tenant_mode,omitempty"`
Issuer string `json:"issuer"`
TenantID string `json:"tenant_id"`
TenantID string `json:"tenant_id,omitempty"`
ApplicationID string `json:"application_id"`
Capabilities []string `json:"capabilities"`
Audience string `json:"audience,omitempty"`
Scopes []string `json:"scopes"`
Clients ManifestClients `json:"clients"`
TenantContext *ManifestTenantContext `json:"tenant_context,omitempty"`
SecurityEvents *ManifestSecurityEvents `json:"security_events,omitempty"`
}
// ManifestV2 intentionally shares the wire structure with ManifestV1 so the
// onboarding delivery can be decoded without guessing a union variant. Validate
// always branches on schema_version and rejects fields from the other mode.
type ManifestV2 = ManifestV1
type MachineCredential struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
@@ -86,11 +99,30 @@ type CredentialDelivery struct {
}
func (manifest ManifestV1) Validate(appEnv string) error {
if manifest.SchemaVersion != 1 || validatePublicIdentityURL(manifest.Issuer, appEnv) != nil {
if validatePublicIdentityURL(manifest.Issuer, appEnv) != nil {
return errors.New("application manifest identity metadata is invalid")
}
if _, err := uuid.Parse(manifest.TenantID); err != nil {
return errors.New("application manifest tenant is invalid")
switch manifest.SchemaVersion {
case 1:
if strings.TrimSpace(manifest.TenantMode) != "" || manifest.TenantContext != nil {
return errors.New("application manifest tenant mode is invalid")
}
if _, err := uuid.Parse(manifest.TenantID); err != nil {
return errors.New("application manifest tenant is invalid")
}
case 2:
if manifest.TenantMode != "multi_tenant" || strings.TrimSpace(manifest.TenantID) != "" {
return errors.New("application manifest tenant mode is invalid")
}
if manifest.TenantContext == nil ||
validatePublicIdentityURL(strings.ReplaceAll(manifest.TenantContext.Endpoint, "{tenantId}", uuid.Nil.String()), appEnv) != nil ||
!strings.Contains(manifest.TenantContext.Endpoint, "{tenantId}") ||
manifest.TenantContext.Audience != "urn:easyai:auth-center:tenant-context" ||
manifest.TenantContext.Scope != "tenant.context.read" {
return errors.New("application manifest tenant context is invalid")
}
default:
return errors.New("application manifest identity metadata is invalid")
}
if _, err := uuid.Parse(manifest.ApplicationID); err != nil {
return errors.New("application manifest application is invalid")
@@ -107,6 +139,11 @@ func (manifest ManifestV1) Validate(appEnv string) error {
capabilities["token_introspection"] && !capabilities["machine_to_machine"] {
return errors.New("application manifest capability dependencies are invalid")
}
if manifest.SchemaVersion == 2 &&
(!capabilities["oidc_login"] || !capabilities["machine_to_machine"] ||
!capabilities["token_introspection"] || !capabilities["session_revocation"]) {
return errors.New("application manifest multi-tenant capabilities are invalid")
}
if capabilities["oidc_login"] && (manifest.Clients.BrowserLogin == nil || strings.TrimSpace(manifest.Clients.BrowserLogin.ClientID) == "") {
return errors.New("application manifest browser client is missing")
}
@@ -78,6 +78,47 @@ func TestManifestV1ValidationRequiresStableFieldsAndCapabilityDependencies(t *te
}
}
func TestManifestV2ValidationRequiresExplicitMultiTenantRuntimeContract(t *testing.T) {
valid := ManifestV2{
SchemaVersion: 2, TenantMode: "multi_tenant",
Issuer: "https://auth.example.com/issuer/shared", 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"},
},
TenantContext: &ManifestTenantContext{
Endpoint: "https://auth.example.com/api/v1/runtime/tenants/{tenantId}",
Audience: "urn:easyai:auth-center:tenant-context",
Scope: "tenant.context.read",
},
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("production"); err != nil {
t.Fatal(err)
}
for _, mutate := range []func(*ManifestV2){
func(value *ManifestV2) { value.TenantMode = "" },
func(value *ManifestV2) { value.TenantID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" },
func(value *ManifestV2) { value.TenantContext = nil },
func(value *ManifestV2) { value.TenantContext.Scope = "" },
func(value *ManifestV2) { value.TenantContext.Audience = "urn:wrong" },
} {
candidate := valid
contextCopy := *valid.TenantContext
candidate.TenantContext = &contextCopy
mutate(&candidate)
if err := candidate.Validate("production"); err == nil {
t.Fatalf("invalid Manifest V2 was accepted: %#v", candidate)
}
}
}
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)
@@ -0,0 +1,188 @@
package identity
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
var (
ErrTenantContextNotFound = errors.New("tenant context was not found")
ErrTenantContextUnavailable = errors.New("tenant context is unavailable")
)
type TenantContext struct {
ApplicationID string `json:"application_id"`
TenantID string `json:"tenant_id"`
DisplayName string `json:"display_name"`
Slug string `json:"slug"`
TenantStatus string `json:"tenant_status"`
TenantApplicationStatus string `json:"tenant_application_status"`
Version string `json:"version"`
UpdatedAt time.Time `json:"updated_at"`
ETag string `json:"-"`
}
func (context TenantContext) Active() bool {
return context.TenantStatus == "active" && context.TenantApplicationStatus == "active"
}
type TenantContextCredentialProvider func(context.Context) (string, []byte, error)
type TenantContextClient struct {
issuer string
appEnv string
application string
endpoint string
audience string
scope string
credentials TenantContextCredentialProvider
client *http.Client
mutex sync.Mutex
token string
tokenExpiry time.Time
tokenURL string
}
func NewTenantContextClient(appEnv, issuer, applicationID, endpoint, audience, scope string, credentials TenantContextCredentialProvider, base *http.Client) (*TenantContextClient, error) {
issuer = strings.TrimRight(strings.TrimSpace(issuer), "/")
endpoint = strings.TrimSpace(endpoint)
applicationID = strings.TrimSpace(applicationID)
if validatePublicIdentityURL(issuer, appEnv) != nil ||
validatePublicIdentityURL(strings.ReplaceAll(endpoint, "{tenantId}", uuid.Nil.String()), appEnv) != nil ||
!strings.Contains(endpoint, "{tenantId}") || uuid.Validate(applicationID) != nil ||
audience != "urn:easyai:auth-center:tenant-context" || scope != "tenant.context.read" || credentials == nil {
return nil, errors.New("tenant context configuration is invalid")
}
if base == nil {
base = &http.Client{Timeout: 10 * time.Second}
}
client := *base
if client.Timeout <= 0 {
client.Timeout = 10 * time.Second
}
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return &TenantContextClient{
issuer: issuer, appEnv: appEnv, application: applicationID, endpoint: endpoint,
audience: audience, scope: scope, credentials: credentials, client: &client,
}, nil
}
func (client *TenantContextClient) Get(ctx context.Context, tenantID, etag string) (TenantContext, bool, error) {
if uuid.Validate(strings.TrimSpace(tenantID)) != nil {
return TenantContext{}, false, ErrTenantContextNotFound
}
token, err := client.accessToken(ctx)
if err != nil {
return TenantContext{}, false, ErrTenantContextUnavailable
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.ReplaceAll(client.endpoint, "{tenantId}", tenantID), nil)
if err != nil {
return TenantContext{}, false, ErrTenantContextUnavailable
}
request.Header.Set("Accept", "application/json")
request.Header.Set("Authorization", "Bearer "+token)
if strings.TrimSpace(etag) != "" {
request.Header.Set("If-None-Match", strings.TrimSpace(etag))
}
response, err := client.client.Do(request)
if err != nil {
return TenantContext{}, false, ErrTenantContextUnavailable
}
defer response.Body.Close()
if response.StatusCode == http.StatusNotModified {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes))
return TenantContext{}, true, nil
}
if response.StatusCode == http.StatusNotFound {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes))
return TenantContext{}, false, ErrTenantContextNotFound
}
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes))
return TenantContext{}, false, ErrTenantContextUnavailable
}
var output TenantContext
decoder := json.NewDecoder(io.LimitReader(response.Body, maxOnboardingResponseBytes))
if decoder.Decode(&output) != nil || output.ApplicationID != client.application || output.TenantID != tenantID ||
strings.TrimSpace(output.DisplayName) == "" || strings.TrimSpace(output.Slug) == "" || strings.TrimSpace(output.Version) == "" ||
output.UpdatedAt.IsZero() {
return TenantContext{}, false, ErrTenantContextUnavailable
}
output.ETag = strings.TrimSpace(response.Header.Get("ETag"))
return output, false, nil
}
func (client *TenantContextClient) accessToken(ctx context.Context) (string, error) {
client.mutex.Lock()
defer client.mutex.Unlock()
if client.token != "" && time.Now().Add(30*time.Second).Before(client.tokenExpiry) {
return client.token, nil
}
if client.tokenURL == "" {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.issuer+"/.well-known/openid-configuration", nil)
if err != nil {
return "", err
}
request.Header.Set("Accept", "application/json")
response, err := client.client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
var discovery struct {
Issuer string `json:"issuer"`
TokenEndpoint string `json:"token_endpoint"`
}
if response.StatusCode != http.StatusOK ||
json.NewDecoder(io.LimitReader(response.Body, maxOnboardingResponseBytes)).Decode(&discovery) != nil ||
strings.TrimRight(discovery.Issuer, "/") != client.issuer ||
validatePublicIdentityURL(discovery.TokenEndpoint, client.appEnv) != nil {
return "", errors.New("tenant context discovery is invalid")
}
client.tokenURL = discovery.TokenEndpoint
}
clientID, secret, err := client.credentials(ctx)
if err != nil {
return "", err
}
defer clear(secret)
if strings.TrimSpace(clientID) == "" || len(secret) < 16 {
return "", errors.New("tenant context credential is unavailable")
}
form := url.Values{"grant_type": {"client_credentials"}, "scope": {client.scope}}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
request.SetBasicAuth(clientID, string(secret))
response, err := client.client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
var tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
if response.StatusCode != http.StatusOK ||
json.NewDecoder(io.LimitReader(response.Body, maxOnboardingResponseBytes)).Decode(&tokenResponse) != nil ||
tokenResponse.AccessToken == "" || !strings.EqualFold(tokenResponse.TokenType, "Bearer") ||
tokenResponse.ExpiresIn <= 0 {
return "", errors.New("tenant context token response is invalid")
}
client.token = tokenResponse.AccessToken
client.tokenExpiry = time.Now().Add(time.Duration(tokenResponse.ExpiresIn) * time.Second)
return client.token, nil
}
@@ -0,0 +1,118 @@
package identity
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/uuid"
)
func TestTenantContextClientUsesApplicationMachineTokenAndETag(t *testing.T) {
tenantID, applicationID := uuid.NewString(), uuid.NewString()
var issuer string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/issuer/shared/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": issuer, "token_endpoint": serverURL(r) + "/issuer/shared/token",
})
case "/issuer/shared/token":
clientID, secret, ok := r.BasicAuth()
if !ok || clientID != "gateway-machine" || secret != "machine-secret-material" ||
r.FormValue("grant_type") != "client_credentials" || r.FormValue("scope") != "tenant.context.read" {
t.Fatal("tenant context token request was not least-privilege client credentials")
}
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "opaque-machine-token", "token_type": "Bearer", "expires_in": 300,
})
case "/api/v1/runtime/tenants/" + tenantID:
if r.Header.Get("Authorization") != "Bearer opaque-machine-token" {
t.Fatal("tenant context request omitted the machine token")
}
if r.Header.Get("If-None-Match") == `"tenant-v7"` {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", `"tenant-v7"`)
_ = json.NewEncoder(w).Encode(TenantContext{
ApplicationID: applicationID, TenantID: tenantID, DisplayName: "Tenant A", Slug: "tenant-a",
TenantStatus: "active", TenantApplicationStatus: "active", Version: "7",
UpdatedAt: time.Unix(1_780_000_000, 0).UTC(),
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
issuer = server.URL + "/issuer/shared"
client, err := NewTenantContextClient(
"test", issuer, applicationID, server.URL+"/api/v1/runtime/tenants/{tenantId}",
"urn:easyai:auth-center:tenant-context", "tenant.context.read",
func(context.Context) (string, []byte, error) {
return "gateway-machine", []byte("machine-secret-material"), nil
}, server.Client(),
)
if err != nil {
t.Fatal(err)
}
tenant, unchanged, err := client.Get(context.Background(), tenantID, "")
if err != nil || unchanged || tenant.ETag != `"tenant-v7"` || !tenant.Active() {
t.Fatalf("tenant=%#v unchanged=%t error=%v", tenant, unchanged, err)
}
_, unchanged, err = client.Get(context.Background(), tenantID, tenant.ETag)
if err != nil || !unchanged {
t.Fatalf("etag request unchanged=%t error=%v", unchanged, err)
}
}
func TestTenantContextClientDistinguishesNotFoundFromTemporaryFailure(t *testing.T) {
tenantID, applicationID := uuid.NewString(), uuid.NewString()
for _, test := range []struct {
status int
want error
}{
{status: http.StatusNotFound, want: ErrTenantContextNotFound},
{status: http.StatusServiceUnavailable, want: ErrTenantContextUnavailable},
} {
t.Run(http.StatusText(test.status), func(t *testing.T) {
var issuer string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/issuer/shared/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": issuer, "token_endpoint": serverURL(r) + "/issuer/shared/token",
})
case r.URL.Path == "/issuer/shared/token":
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "opaque-machine-token", "token_type": "Bearer", "expires_in": 300,
})
default:
w.WriteHeader(test.status)
}
}))
defer server.Close()
issuer = server.URL + "/issuer/shared"
client, err := NewTenantContextClient(
"test", issuer, applicationID, server.URL+"/api/v1/runtime/tenants/{tenantId}",
"urn:easyai:auth-center:tenant-context", "tenant.context.read",
func(context.Context) (string, []byte, error) {
return "gateway-machine", []byte("machine-secret-material"), nil
}, server.Client(),
)
if err != nil {
t.Fatal(err)
}
if _, _, err := client.Get(context.Background(), tenantID, ""); err != test.want {
t.Fatalf("error=%v want=%v", err, test.want)
}
})
}
}
func serverURL(request *http.Request) string {
return "http://" + request.Host
}