Gateway 使用一次性接入码创建 Exchange,将 Exchange Token、机器凭据与 Session Key 仅写入 SecretStore,并持久化脱敏配对进度。\n\n资源就绪后先保存凭据和 Manifest,再自动准备 SSF,最后确认远端 Exchange;SecretStore 失败时保持 ready,重试会触发新 Secret 轮换,不回放旧值。\n\n验证:go test ./...;go vet ./...
231 lines
9.0 KiB
Go
231 lines
9.0 KiB
Go
package identity
|
|
|
|
import (
|
|
"errors"
|
|
"net"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
var (
|
|
ErrRevisionNotFound = errors.New("identity configuration revision not found")
|
|
ErrRevisionConflict = errors.New("identity configuration revision conflicts with current state")
|
|
ErrBreakGlassRequired = errors.New("a local break-glass manager credential is required")
|
|
ErrLocalTenantInvalid = errors.New("local tenant mapping is invalid")
|
|
)
|
|
|
|
type RevisionState string
|
|
|
|
const (
|
|
RevisionDraft RevisionState = "draft"
|
|
RevisionValidated RevisionState = "validated"
|
|
RevisionActive RevisionState = "active"
|
|
RevisionSuperseded RevisionState = "superseded"
|
|
RevisionFailed RevisionState = "failed"
|
|
)
|
|
|
|
type Revision struct {
|
|
ID string `json:"id"`
|
|
State RevisionState `json:"state"`
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
AuthCenterURL string `json:"authCenterUrl"`
|
|
Issuer string `json:"issuer,omitempty"`
|
|
TenantID string `json:"tenantId,omitempty"`
|
|
ApplicationID string `json:"applicationId,omitempty"`
|
|
Audience string `json:"audience,omitempty"`
|
|
BrowserClientID string `json:"browserClientId,omitempty"`
|
|
MachineClientID string `json:"machineClientId,omitempty"`
|
|
Scopes []string `json:"scopes"`
|
|
Capabilities []string `json:"capabilities"`
|
|
RolePrefix string `json:"rolePrefix"`
|
|
LocalTenantKey string `json:"localTenantKey"`
|
|
PublicBaseURL string `json:"publicBaseUrl"`
|
|
WebBaseURL string `json:"webBaseUrl"`
|
|
JITEnabled bool `json:"jitEnabled"`
|
|
LegacyJWTEnabled bool `json:"legacyJwtEnabled"`
|
|
TokenIntrospection bool `json:"tokenIntrospection"`
|
|
SessionRevocation bool `json:"sessionRevocation"`
|
|
SecurityEventIssuer string `json:"securityEventIssuer,omitempty"`
|
|
SecurityEventConfigURL string `json:"securityEventConfigurationUrl,omitempty"`
|
|
SecurityEventAudience string `json:"securityEventAudience,omitempty"`
|
|
MachineCredentialRef string `json:"-"`
|
|
SessionEncryptionKeyRef string `json:"-"`
|
|
SessionIdleSeconds int `json:"sessionIdleSeconds"`
|
|
SessionAbsoluteSeconds int `json:"sessionAbsoluteSeconds"`
|
|
SessionRefreshSeconds int `json:"sessionRefreshSeconds"`
|
|
Version int64 `json:"version"`
|
|
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
|
|
LastTraceID string `json:"lastTraceId,omitempty"`
|
|
LastAuditID string `json:"lastAuditId,omitempty"`
|
|
ValidatedAt *time.Time `json:"validatedAt,omitempty"`
|
|
ActivatedAt *time.Time `json:"activatedAt,omitempty"`
|
|
SupersededAt *time.Time `json:"supersededAt,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
type PairingInput struct {
|
|
AuthCenterURL string `json:"authCenterUrl"`
|
|
OnboardingCode string `json:"onboardingCode"`
|
|
PublicBaseURL string `json:"publicBaseUrl"`
|
|
WebBaseURL string `json:"webBaseUrl"`
|
|
LocalTenantKey string `json:"localTenantKey"`
|
|
LegacyJWTEnabled bool `json:"legacyJwtEnabled"`
|
|
}
|
|
|
|
type ConsumerMetadata struct {
|
|
PublicBaseURL string `json:"public_base_url"`
|
|
WebBaseURL string `json:"web_base_url"`
|
|
RedirectURIs []string `json:"redirect_uris"`
|
|
LogoutURIs []string `json:"logout_uris"`
|
|
ReceiverEndpoint string `json:"receiver_endpoint,omitempty"`
|
|
}
|
|
|
|
type ManifestApplication struct {
|
|
Manifest ManifestV1
|
|
MachineCredentialRef string
|
|
SessionEncryptionKeyRef string
|
|
TraceID string
|
|
AuditID string
|
|
}
|
|
|
|
func NewDraft(input PairingInput) (Revision, error) {
|
|
if _, err := input.ConsumerMetadata(false); err != nil {
|
|
return Revision{}, err
|
|
}
|
|
authCenter, _ := exactBaseURL(input.AuthCenterURL)
|
|
publicBase, _ := exactBaseURL(input.PublicBaseURL)
|
|
webBase, _ := exactBaseURL(input.WebBaseURL)
|
|
return Revision{
|
|
ID: uuid.NewString(), State: RevisionDraft, SchemaVersion: 1,
|
|
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,
|
|
SessionRefreshSeconds: 60, Version: 1,
|
|
}, nil
|
|
}
|
|
|
|
func ApplyManifest(revision Revision, input ManifestApplication) (Revision, error) {
|
|
if revision.State != RevisionDraft {
|
|
return Revision{}, ErrRevisionConflict
|
|
}
|
|
if err := input.Manifest.Validate(); err != nil {
|
|
return Revision{}, err
|
|
}
|
|
capabilities := make(map[string]bool, len(input.Manifest.Capabilities))
|
|
for _, capability := range input.Manifest.Capabilities {
|
|
capabilities[capability] = true
|
|
}
|
|
if capabilities["machine_to_machine"] && strings.TrimSpace(input.MachineCredentialRef) == "" {
|
|
return Revision{}, errors.New("machine credential reference is required")
|
|
}
|
|
if capabilities["oidc_login"] && strings.TrimSpace(input.SessionEncryptionKeyRef) == "" {
|
|
return Revision{}, errors.New("session encryption key reference is required")
|
|
}
|
|
revision.Issuer = strings.TrimRight(input.Manifest.Issuer, "/")
|
|
revision.TenantID = input.Manifest.TenantID
|
|
revision.ApplicationID = input.Manifest.ApplicationID
|
|
revision.Audience = input.Manifest.Audience
|
|
revision.Scopes = append([]string(nil), input.Manifest.Scopes...)
|
|
revision.Capabilities = append([]string(nil), input.Manifest.Capabilities...)
|
|
if input.Manifest.Clients.BrowserLogin != nil {
|
|
revision.BrowserClientID = input.Manifest.Clients.BrowserLogin.ClientID
|
|
}
|
|
if input.Manifest.Clients.MachineToMachine != nil {
|
|
revision.MachineClientID = input.Manifest.Clients.MachineToMachine.ClientID
|
|
}
|
|
revision.TokenIntrospection = capabilities["token_introspection"]
|
|
revision.SessionRevocation = capabilities["session_revocation"]
|
|
if input.Manifest.SecurityEvents != nil {
|
|
revision.SecurityEventIssuer = input.Manifest.SecurityEvents.TransmitterIssuer
|
|
revision.SecurityEventConfigURL = input.Manifest.SecurityEvents.ConfigurationEndpoint
|
|
revision.SecurityEventAudience = input.Manifest.SecurityEvents.Audience
|
|
}
|
|
revision.MachineCredentialRef = strings.TrimSpace(input.MachineCredentialRef)
|
|
revision.SessionEncryptionKeyRef = strings.TrimSpace(input.SessionEncryptionKeyRef)
|
|
revision.LastTraceID = strings.TrimSpace(input.TraceID)
|
|
revision.LastAuditID = strings.TrimSpace(input.AuditID)
|
|
return revision, nil
|
|
}
|
|
|
|
func CanTransition(from, to RevisionState) bool {
|
|
switch from {
|
|
case RevisionDraft:
|
|
return to == RevisionValidated || to == RevisionFailed
|
|
case RevisionValidated:
|
|
return to == RevisionActive || to == RevisionFailed
|
|
case RevisionActive:
|
|
return to == RevisionSuperseded
|
|
case RevisionSuperseded:
|
|
return to == RevisionValidated
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (input PairingInput) ConsumerMetadata(sessionRevocation bool) (ConsumerMetadata, error) {
|
|
authCenter, err := exactBaseURL(input.AuthCenterURL)
|
|
if err != nil {
|
|
return ConsumerMetadata{}, errors.New("auth center URL is invalid")
|
|
}
|
|
_ = authCenter
|
|
publicBase, err := exactBaseURL(input.PublicBaseURL)
|
|
if err != nil {
|
|
return ConsumerMetadata{}, errors.New("public base URL is invalid")
|
|
}
|
|
webBase, err := exactBaseURL(input.WebBaseURL)
|
|
if err != nil {
|
|
return ConsumerMetadata{}, errors.New("web base URL is invalid")
|
|
}
|
|
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"},
|
|
LogoutURIs: []string{webBase + "/"},
|
|
}
|
|
if sessionRevocation {
|
|
metadata.ReceiverEndpoint = publicBase + "/api/v1/security-events/ssf"
|
|
}
|
|
return metadata, nil
|
|
}
|
|
|
|
func exactBaseURL(raw string) (string, error) {
|
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil || parsed.Opaque != "" || parsed.User != nil || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return "", errors.New("invalid public URL")
|
|
}
|
|
hostname := strings.ToLower(parsed.Hostname())
|
|
if parsed.Path != "" && parsed.Path != "/" {
|
|
return "", errors.New("base URL must not contain a path")
|
|
}
|
|
scheme := strings.ToLower(parsed.Scheme)
|
|
if scheme != "https" && !(scheme == "http" && isLoopbackHost(hostname)) {
|
|
return "", errors.New("public URL must use HTTPS")
|
|
}
|
|
port := parsed.Port()
|
|
if scheme == "https" && port == "443" || scheme == "http" && port == "80" {
|
|
port = ""
|
|
}
|
|
host := hostname
|
|
if strings.Contains(hostname, ":") {
|
|
host = "[" + hostname + "]"
|
|
}
|
|
if port != "" {
|
|
host = net.JoinHostPort(hostname, port)
|
|
}
|
|
return scheme + "://" + host, nil
|
|
}
|
|
|
|
func isLoopbackHost(host string) bool {
|
|
if host == "localhost" {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(host)
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|