ScuttleBot

scuttlebot / internal / auth / apikeys.go
Source Blame History 288 lines
68677f9… noreply 1 package auth
68677f9… noreply 2
68677f9… noreply 3 import (
68677f9… noreply 4 "crypto/rand"
68677f9… noreply 5 "crypto/sha256"
68677f9… noreply 6 "encoding/hex"
68677f9… noreply 7 "encoding/json"
68677f9… noreply 8 "fmt"
68677f9… noreply 9 "os"
68677f9… noreply 10 "strings"
68677f9… noreply 11 "sync"
68677f9… noreply 12 "time"
68677f9… noreply 13
68677f9… noreply 14 "github.com/oklog/ulid/v2"
68677f9… noreply 15 )
68677f9… noreply 16
68677f9… noreply 17 // Scope represents a permission scope for an API key.
68677f9… noreply 18 type Scope string
68677f9… noreply 19
68677f9… noreply 20 const (
68677f9… noreply 21 ScopeAdmin Scope = "admin" // full access
68677f9… noreply 22 ScopeAgents Scope = "agents" // agent registration, rotation, revocation
68677f9… noreply 23 ScopeChannels Scope = "channels" // channel CRUD, join, messages, presence
68677f9… noreply 24 ScopeTopology Scope = "topology" // channel provisioning, topology management
68677f9… noreply 25 ScopeBots Scope = "bots" // bot configuration, start/stop
68677f9… noreply 26 ScopeConfig Scope = "config" // server config read/write
68677f9… noreply 27 ScopeRead Scope = "read" // read-only access to all GET endpoints
68677f9… noreply 28 ScopeChat Scope = "chat" // send/receive messages only
68677f9… noreply 29 )
68677f9… noreply 30
68677f9… noreply 31 // ValidScopes is the set of all recognised scopes.
68677f9… noreply 32 var ValidScopes = map[Scope]bool{
68677f9… noreply 33 ScopeAdmin: true, ScopeAgents: true, ScopeChannels: true,
68677f9… noreply 34 ScopeTopology: true, ScopeBots: true, ScopeConfig: true,
68677f9… noreply 35 ScopeRead: true, ScopeChat: true,
68677f9… noreply 36 }
68677f9… noreply 37
68677f9… noreply 38 // APIKey is a single API key record.
68677f9… noreply 39 type APIKey struct {
68677f9… noreply 40 ID string `json:"id"`
68677f9… noreply 41 Name string `json:"name"`
68677f9… noreply 42 Hash string `json:"hash"` // SHA-256 of the plaintext token
68677f9… noreply 43 Scopes []Scope `json:"scopes"`
68677f9… noreply 44 CreatedAt time.Time `json:"created_at"`
68677f9… noreply 45 LastUsed time.Time `json:"last_used,omitempty"`
68677f9… noreply 46 ExpiresAt time.Time `json:"expires_at,omitempty"` // zero = never
68677f9… noreply 47 Active bool `json:"active"`
68677f9… noreply 48 }
68677f9… noreply 49
68677f9… noreply 50 // HasScope reports whether the key has the given scope (or admin, which implies all).
68677f9… noreply 51 func (k *APIKey) HasScope(s Scope) bool {
68677f9… noreply 52 for _, scope := range k.Scopes {
68677f9… noreply 53 if scope == ScopeAdmin || scope == s {
68677f9… noreply 54 return true
68677f9… noreply 55 }
68677f9… noreply 56 }
68677f9… noreply 57 return false
68677f9… noreply 58 }
68677f9… noreply 59
68677f9… noreply 60 // IsExpired reports whether the key has passed its expiry time.
68677f9… noreply 61 func (k *APIKey) IsExpired() bool {
68677f9… noreply 62 return !k.ExpiresAt.IsZero() && time.Now().After(k.ExpiresAt)
68677f9… noreply 63 }
68677f9… noreply 64
68677f9… noreply 65 // APIKeyStore persists API keys to a JSON file.
68677f9… noreply 66 type APIKeyStore struct {
68677f9… noreply 67 mu sync.RWMutex
68677f9… noreply 68 path string
68677f9… noreply 69 data []APIKey
68677f9… noreply 70 }
68677f9… noreply 71
68677f9… noreply 72 // NewAPIKeyStore loads (or creates) the API key store at the given path.
68677f9… noreply 73 func NewAPIKeyStore(path string) (*APIKeyStore, error) {
68677f9… noreply 74 s := &APIKeyStore{path: path}
68677f9… noreply 75 if err := s.load(); err != nil {
68677f9… noreply 76 return nil, err
68677f9… noreply 77 }
68677f9… noreply 78 return s, nil
68677f9… noreply 79 }
68677f9… noreply 80
68677f9… noreply 81 // Create generates a new API key with the given name and scopes.
68677f9… noreply 82 // Returns the plaintext token (shown only once) and the stored key record.
68677f9… noreply 83 func (s *APIKeyStore) Create(name string, scopes []Scope, expiresAt time.Time) (plaintext string, key APIKey, err error) {
68677f9… noreply 84 s.mu.Lock()
68677f9… noreply 85 defer s.mu.Unlock()
68677f9… noreply 86
68677f9… noreply 87 token, err := genToken()
68677f9… noreply 88 if err != nil {
68677f9… noreply 89 return "", APIKey{}, fmt.Errorf("apikeys: generate token: %w", err)
68677f9… noreply 90 }
68677f9… noreply 91
68677f9… noreply 92 key = APIKey{
68677f9… noreply 93 ID: newULID(),
68677f9… noreply 94 Name: name,
68677f9… noreply 95 Hash: hashToken(token),
68677f9… noreply 96 Scopes: scopes,
68677f9… noreply 97 CreatedAt: time.Now().UTC(),
68677f9… noreply 98 ExpiresAt: expiresAt,
68677f9… noreply 99 Active: true,
68677f9… noreply 100 }
68677f9… noreply 101 s.data = append(s.data, key)
68677f9… noreply 102 if err := s.save(); err != nil {
68677f9… noreply 103 // Roll back.
68677f9… noreply 104 s.data = s.data[:len(s.data)-1]
68677f9… noreply 105 return "", APIKey{}, err
68677f9… noreply 106 }
68677f9… noreply 107 return token, key, nil
68677f9… noreply 108 }
68677f9… noreply 109
68677f9… noreply 110 // Insert adds a pre-built API key with a known plaintext token.
68677f9… noreply 111 // Used for migrating the startup token into the store.
68677f9… noreply 112 func (s *APIKeyStore) Insert(name, plaintext string, scopes []Scope) (APIKey, error) {
68677f9… noreply 113 s.mu.Lock()
68677f9… noreply 114 defer s.mu.Unlock()
68677f9… noreply 115
68677f9… noreply 116 key := APIKey{
68677f9… noreply 117 ID: newULID(),
68677f9… noreply 118 Name: name,
68677f9… noreply 119 Hash: hashToken(plaintext),
68677f9… noreply 120 Scopes: scopes,
68677f9… noreply 121 CreatedAt: time.Now().UTC(),
68677f9… noreply 122 Active: true,
68677f9… noreply 123 }
68677f9… noreply 124 s.data = append(s.data, key)
68677f9… noreply 125 if err := s.save(); err != nil {
68677f9… noreply 126 s.data = s.data[:len(s.data)-1]
68677f9… noreply 127 return APIKey{}, err
68677f9… noreply 128 }
68677f9… noreply 129 return key, nil
68677f9… noreply 130 }
68677f9… noreply 131
68677f9… noreply 132 // Lookup finds an active, non-expired key by plaintext token.
68677f9… noreply 133 // Returns nil if no match.
68677f9… noreply 134 func (s *APIKeyStore) Lookup(token string) *APIKey {
68677f9… noreply 135 hash := hashToken(token)
68677f9… noreply 136 s.mu.RLock()
68677f9… noreply 137 defer s.mu.RUnlock()
68677f9… noreply 138 for i := range s.data {
68677f9… noreply 139 if s.data[i].Hash == hash && s.data[i].Active && !s.data[i].IsExpired() {
68677f9… noreply 140 k := s.data[i]
68677f9… noreply 141 return &k
68677f9… noreply 142 }
68677f9… noreply 143 }
68677f9… noreply 144 return nil
68677f9… noreply 145 }
68677f9… noreply 146
68677f9… noreply 147 // TouchLastUsed updates the last-used timestamp for a key by ID.
68677f9… noreply 148 func (s *APIKeyStore) TouchLastUsed(id string) {
68677f9… noreply 149 s.mu.Lock()
68677f9… noreply 150 defer s.mu.Unlock()
68677f9… noreply 151 for i := range s.data {
68677f9… noreply 152 if s.data[i].ID == id {
68677f9… noreply 153 s.data[i].LastUsed = time.Now().UTC()
68677f9… noreply 154 _ = s.save() // best-effort persistence
68677f9… noreply 155 return
68677f9… noreply 156 }
68677f9… noreply 157 }
68677f9… noreply 158 }
68677f9… noreply 159
68677f9… noreply 160 // Get returns a key by ID, or nil if not found.
68677f9… noreply 161 func (s *APIKeyStore) Get(id string) *APIKey {
68677f9… noreply 162 s.mu.RLock()
68677f9… noreply 163 defer s.mu.RUnlock()
68677f9… noreply 164 for i := range s.data {
68677f9… noreply 165 if s.data[i].ID == id {
68677f9… noreply 166 k := s.data[i]
68677f9… noreply 167 return &k
68677f9… noreply 168 }
68677f9… noreply 169 }
68677f9… noreply 170 return nil
68677f9… noreply 171 }
68677f9… noreply 172
68677f9… noreply 173 // List returns all keys (active and revoked).
68677f9… noreply 174 func (s *APIKeyStore) List() []APIKey {
68677f9… noreply 175 s.mu.RLock()
68677f9… noreply 176 defer s.mu.RUnlock()
68677f9… noreply 177 out := make([]APIKey, len(s.data))
68677f9… noreply 178 copy(out, s.data)
68677f9… noreply 179 return out
68677f9… noreply 180 }
68677f9… noreply 181
68677f9… noreply 182 // Revoke deactivates a key by ID.
68677f9… noreply 183 func (s *APIKeyStore) Revoke(id string) error {
68677f9… noreply 184 s.mu.Lock()
68677f9… noreply 185 defer s.mu.Unlock()
68677f9… noreply 186 for i := range s.data {
68677f9… noreply 187 if s.data[i].ID == id {
68677f9… noreply 188 if !s.data[i].Active {
68677f9… noreply 189 return fmt.Errorf("apikeys: key %q already revoked", id)
68677f9… noreply 190 }
68677f9… noreply 191 s.data[i].Active = false
68677f9… noreply 192 return s.save()
68677f9… noreply 193 }
68677f9… noreply 194 }
68677f9… noreply 195 return fmt.Errorf("apikeys: key %q not found", id)
68677f9… noreply 196 }
68677f9… noreply 197
68677f9… noreply 198 // Lookup (TokenValidator interface) reports whether the token is valid.
68677f9… noreply 199 // Satisfies the mcp.TokenValidator interface.
68677f9… noreply 200 func (s *APIKeyStore) ValidToken(token string) bool {
68677f9… noreply 201 return s.Lookup(token) != nil
68677f9… noreply 202 }
68677f9… noreply 203
68677f9… noreply 204 // TestStore creates an in-memory APIKeyStore with a single admin-scope key
68677f9… noreply 205 // for the given token. Intended for tests only — does not persist to disk.
68677f9… noreply 206 func TestStore(token string) *APIKeyStore {
68677f9… noreply 207 s := &APIKeyStore{path: "", data: []APIKey{{
68677f9… noreply 208 ID: "test-key",
68677f9… noreply 209 Name: "test",
68677f9… noreply 210 Hash: hashToken(token),
68677f9… noreply 211 Scopes: []Scope{ScopeAdmin},
68677f9… noreply 212 CreatedAt: time.Now().UTC(),
68677f9… noreply 213 Active: true,
68677f9… noreply 214 }}}
68677f9… noreply 215 return s
68677f9… noreply 216 }
68677f9… noreply 217
68677f9… noreply 218 // IsEmpty reports whether there are no keys.
68677f9… noreply 219 func (s *APIKeyStore) IsEmpty() bool {
68677f9… noreply 220 s.mu.RLock()
68677f9… noreply 221 defer s.mu.RUnlock()
68677f9… noreply 222 return len(s.data) == 0
68677f9… noreply 223 }
68677f9… noreply 224
68677f9… noreply 225 func (s *APIKeyStore) load() error {
68677f9… noreply 226 raw, err := os.ReadFile(s.path)
68677f9… noreply 227 if os.IsNotExist(err) {
68677f9… noreply 228 return nil
68677f9… noreply 229 }
68677f9… noreply 230 if err != nil {
68677f9… noreply 231 return fmt.Errorf("apikeys: read %s: %w", s.path, err)
68677f9… noreply 232 }
68677f9… noreply 233 if err := json.Unmarshal(raw, &s.data); err != nil {
68677f9… noreply 234 return fmt.Errorf("apikeys: parse: %w", err)
68677f9… noreply 235 }
68677f9… noreply 236 return nil
68677f9… noreply 237 }
68677f9… noreply 238
68677f9… noreply 239 func (s *APIKeyStore) save() error {
68677f9… noreply 240 if s.path == "" {
68677f9… noreply 241 return nil // in-memory only (tests)
68677f9… noreply 242 }
68677f9… noreply 243 raw, err := json.MarshalIndent(s.data, "", " ")
68677f9… noreply 244 if err != nil {
68677f9… noreply 245 return err
68677f9… noreply 246 }
68677f9… noreply 247 return os.WriteFile(s.path, raw, 0600)
68677f9… noreply 248 }
68677f9… noreply 249
68677f9… noreply 250 func hashToken(token string) string {
68677f9… noreply 251 h := sha256.Sum256([]byte(token))
68677f9… noreply 252 return hex.EncodeToString(h[:])
68677f9… noreply 253 }
68677f9… noreply 254
68677f9… noreply 255 func genToken() (string, error) {
68677f9… noreply 256 b := make([]byte, 32)
68677f9… noreply 257 if _, err := rand.Read(b); err != nil {
68677f9… noreply 258 return "", err
68677f9… noreply 259 }
68677f9… noreply 260 return hex.EncodeToString(b), nil
68677f9… noreply 261 }
68677f9… noreply 262
68677f9… noreply 263 func newULID() string {
68677f9… noreply 264 entropy := ulid.Monotonic(rand.Reader, 0)
68677f9… noreply 265 return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String()
68677f9… noreply 266 }
68677f9… noreply 267
68677f9… noreply 268 // ParseScopes parses a comma-separated scope string into a slice.
68677f9… noreply 269 // Returns an error if any scope is unrecognised.
68677f9… noreply 270 func ParseScopes(s string) ([]Scope, error) {
68677f9… noreply 271 parts := strings.Split(s, ",")
68677f9… noreply 272 scopes := make([]Scope, 0, len(parts))
68677f9… noreply 273 for _, p := range parts {
68677f9… noreply 274 p = strings.TrimSpace(p)
68677f9… noreply 275 if p == "" {
68677f9… noreply 276 continue
68677f9… noreply 277 }
68677f9… noreply 278 scope := Scope(p)
68677f9… noreply 279 if !ValidScopes[scope] {
68677f9… noreply 280 return nil, fmt.Errorf("unknown scope %q", p)
68677f9… noreply 281 }
68677f9… noreply 282 scopes = append(scopes, scope)
68677f9… noreply 283 }
68677f9… noreply 284 if len(scopes) == 0 {
68677f9… noreply 285 return nil, fmt.Errorf("at least one scope is required")
68677f9… noreply 286 }
68677f9… noreply 287 return scopes, nil
68677f9… noreply 288 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button