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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user