ScuttleBot

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

Keyboard Shortcuts

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