|
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 |
} |