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 }