package webapp import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "errors" "fmt" "net/url" "strings" "sync" "time" ) const RuntimeTokenTTL = 15 * time.Minute const maxExpiredCapabilityTombstones = 1024 var ( ErrRuntimeTokenInvalid = errors.New("runtime_token_invalid") ErrRuntimeTokenExpired = errors.New("runtime_token_expired") ErrRuntimeTokenStale = errors.New("runtime_token_stale") ) // CapabilityBinding is the complete authority binding for one runtime URL. // It contains identifiers and a release artifact reference, never a user // credential or a source payload. type CapabilityBinding struct { PluginID string UserID string InstanceID string ReleaseID string WebAppKey string Placement string ScopeKind string DataScopeKind string WorkspaceID string TaskID string SessionID string RepositoryID string GrantGeneration int64 Permissions []string Artifact Artifact Entry string NetworkOrigins []string } type storedCapability struct { binding CapabilityBinding issuedAt time.Time expiresAt time.Time } // TokenManager stores only a SHA-256 digest of each random runtime token. // Tokens are deliberately process-local and short-lived; instance and grant // changes are checked by the caller through BindingValidator on every request. type TokenManager struct { mu sync.Mutex tokens map[[sha256.Size]byte]storedCapability expired map[[sha256.Size]byte]time.Time now func() time.Time } func NewTokenManager(now func() time.Time) *TokenManager { if now == nil { now = time.Now } return &TokenManager{tokens: make(map[[sha256.Size]byte]storedCapability), expired: make(map[[sha256.Size]byte]time.Time), now: now} } func (m *TokenManager) Issue(binding CapabilityBinding, ttl time.Duration) (string, error) { if m == nil || binding.UserID == "" || binding.InstanceID == "" || binding.ReleaseID == "" || binding.WebAppKey == "" || binding.Entry == "" { return "", ErrRuntimeTokenInvalid } if ttl <= 0 || ttl > RuntimeTokenTTL { ttl = RuntimeTokenTTL } raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", fmt.Errorf("generate runtime capability: %w", err) } token := base64.RawURLEncoding.EncodeToString(raw) issuedAt := m.now().UTC() m.mu.Lock() m.sweepExpiredLocked(issuedAt) digest := digestToken(token) delete(m.expired, digest) m.tokens[digest] = storedCapability{binding: binding, issuedAt: issuedAt, expiresAt: issuedAt.Add(ttl)} m.mu.Unlock() return token, nil } func (m *TokenManager) IssueURL(baseURL string, binding CapabilityBinding, ttl time.Duration) (string, error) { parsed, err := url.Parse(strings.TrimSpace(baseURL)) if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" { return "", ErrRuntimeTokenInvalid } token, err := m.Issue(binding, ttl) if err != nil { return "", err } parsed.Path = strings.TrimRight(parsed.Path, "/") + "/api/v1/plugins/web-apps/runtime/" + token + "/" return parsed.String(), nil } func (m *TokenManager) Validate(token string) (CapabilityBinding, error) { if m == nil || strings.TrimSpace(token) == "" { return CapabilityBinding{}, ErrRuntimeTokenInvalid } digest := digestToken(token) now := m.now().UTC() m.mu.Lock() m.sweepExpiredLocked(now) capability, ok := m.tokens[digest] if ok && !now.Before(capability.expiresAt) { delete(m.tokens, digest) m.expired[digest] = now ok = false } _, wasExpired := m.expired[digest] m.mu.Unlock() if !ok { if wasExpired { return CapabilityBinding{}, ErrRuntimeTokenExpired } return CapabilityBinding{}, ErrRuntimeTokenInvalid } return capability.binding, nil } // Renew extends a valid token by at most RuntimeTokenTTL. The raw token is // never copied into the stored capability. func (m *TokenManager) Renew(token string, ttl time.Duration) error { if m == nil || strings.TrimSpace(token) == "" { return ErrRuntimeTokenInvalid } if ttl <= 0 || ttl > RuntimeTokenTTL { ttl = RuntimeTokenTTL } digest := digestToken(token) now := m.now().UTC() m.mu.Lock() defer m.mu.Unlock() m.sweepExpiredLocked(now) capability, ok := m.tokens[digest] if !ok { if _, wasExpired := m.expired[digest]; wasExpired { return ErrRuntimeTokenExpired } return ErrRuntimeTokenInvalid } if !now.Before(capability.expiresAt) { delete(m.tokens, digest) m.expired[digest] = now return ErrRuntimeTokenExpired } capability.expiresAt = now.Add(ttl) m.tokens[digest] = capability return nil } func (m *TokenManager) Revoke(token string) { if m == nil { return } m.mu.Lock() delete(m.tokens, digestToken(token)) delete(m.expired, digestToken(token)) m.mu.Unlock() } func (m *TokenManager) StoredTokenCount() int { if m == nil { return 0 } m.mu.Lock() defer m.mu.Unlock() m.sweepExpiredLocked(m.now().UTC()) return len(m.tokens) } func (m *TokenManager) sweepExpiredLocked(now time.Time) { cutoff := now.Add(-RuntimeTokenTTL) for digest, expiredAt := range m.expired { if !expiredAt.After(cutoff) { delete(m.expired, digest) } } for len(m.expired) > maxExpiredCapabilityTombstones { var oldestDigest [sha256.Size]byte var oldestAt time.Time for digest, expiredAt := range m.expired { if oldestAt.IsZero() || expiredAt.Before(oldestAt) { oldestDigest, oldestAt = digest, expiredAt } } delete(m.expired, oldestDigest) } } // HasRawToken is a diagnostic test helper. Since the map key is a fixed-size // digest, this is always false unless a future implementation regresses to a // raw-token map. func (m *TokenManager) HasRawToken(token string) bool { if m == nil { return false } m.mu.Lock() defer m.mu.Unlock() _, ok := any(m.tokens).(map[string]storedCapability) return ok && token != "" } func digestToken(token string) [sha256.Size]byte { return sha256.Sum256([]byte(token)) } // BindingValidator rechecks instance status, active release, grants, scope, // and current caller authorization on every runtime request. type BindingValidator func(context.Context, CapabilityBinding) error