|
68677f9…
|
noreply
|
1 |
package api |
|
68677f9…
|
noreply
|
2 |
|
|
68677f9…
|
noreply
|
3 |
import ( |
|
68677f9…
|
noreply
|
4 |
"encoding/json" |
|
68677f9…
|
noreply
|
5 |
"net/http" |
|
68677f9…
|
noreply
|
6 |
"time" |
|
68677f9…
|
noreply
|
7 |
|
|
68677f9…
|
noreply
|
8 |
"github.com/conflicthq/scuttlebot/internal/auth" |
|
68677f9…
|
noreply
|
9 |
) |
|
68677f9…
|
noreply
|
10 |
|
|
68677f9…
|
noreply
|
11 |
type createAPIKeyRequest struct { |
|
68677f9…
|
noreply
|
12 |
Name string `json:"name"` |
|
68677f9…
|
noreply
|
13 |
Scopes []string `json:"scopes"` |
|
68677f9…
|
noreply
|
14 |
ExpiresIn string `json:"expires_in,omitempty"` // e.g. "720h" for 30 days, empty = never |
|
68677f9…
|
noreply
|
15 |
} |
|
68677f9…
|
noreply
|
16 |
|
|
68677f9…
|
noreply
|
17 |
type createAPIKeyResponse struct { |
|
68677f9…
|
noreply
|
18 |
ID string `json:"id"` |
|
68677f9…
|
noreply
|
19 |
Name string `json:"name"` |
|
68677f9…
|
noreply
|
20 |
Token string `json:"token"` // plaintext, shown only once |
|
68677f9…
|
noreply
|
21 |
Scopes []auth.Scope `json:"scopes"` |
|
68677f9…
|
noreply
|
22 |
CreatedAt time.Time `json:"created_at"` |
|
68677f9…
|
noreply
|
23 |
ExpiresAt *time.Time `json:"expires_at,omitempty"` |
|
68677f9…
|
noreply
|
24 |
} |
|
68677f9…
|
noreply
|
25 |
|
|
68677f9…
|
noreply
|
26 |
type apiKeyListEntry struct { |
|
68677f9…
|
noreply
|
27 |
ID string `json:"id"` |
|
68677f9…
|
noreply
|
28 |
Name string `json:"name"` |
|
68677f9…
|
noreply
|
29 |
Scopes []auth.Scope `json:"scopes"` |
|
68677f9…
|
noreply
|
30 |
CreatedAt time.Time `json:"created_at"` |
|
68677f9…
|
noreply
|
31 |
LastUsed *time.Time `json:"last_used,omitempty"` |
|
68677f9…
|
noreply
|
32 |
ExpiresAt *time.Time `json:"expires_at,omitempty"` |
|
68677f9…
|
noreply
|
33 |
Active bool `json:"active"` |
|
68677f9…
|
noreply
|
34 |
} |
|
68677f9…
|
noreply
|
35 |
|
|
68677f9…
|
noreply
|
36 |
// handleListAPIKeys handles GET /v1/api-keys. |
|
68677f9…
|
noreply
|
37 |
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) { |
|
68677f9…
|
noreply
|
38 |
keys := s.apiKeys.List() |
|
68677f9…
|
noreply
|
39 |
out := make([]apiKeyListEntry, len(keys)) |
|
68677f9…
|
noreply
|
40 |
for i, k := range keys { |
|
68677f9…
|
noreply
|
41 |
out[i] = apiKeyListEntry{ |
|
68677f9…
|
noreply
|
42 |
ID: k.ID, |
|
68677f9…
|
noreply
|
43 |
Name: k.Name, |
|
68677f9…
|
noreply
|
44 |
Scopes: k.Scopes, |
|
68677f9…
|
noreply
|
45 |
CreatedAt: k.CreatedAt, |
|
68677f9…
|
noreply
|
46 |
Active: k.Active, |
|
68677f9…
|
noreply
|
47 |
} |
|
68677f9…
|
noreply
|
48 |
if !k.LastUsed.IsZero() { |
|
68677f9…
|
noreply
|
49 |
t := k.LastUsed |
|
68677f9…
|
noreply
|
50 |
out[i].LastUsed = &t |
|
68677f9…
|
noreply
|
51 |
} |
|
68677f9…
|
noreply
|
52 |
if !k.ExpiresAt.IsZero() { |
|
68677f9…
|
noreply
|
53 |
t := k.ExpiresAt |
|
68677f9…
|
noreply
|
54 |
out[i].ExpiresAt = &t |
|
68677f9…
|
noreply
|
55 |
} |
|
68677f9…
|
noreply
|
56 |
} |
|
68677f9…
|
noreply
|
57 |
writeJSON(w, http.StatusOK, out) |
|
68677f9…
|
noreply
|
58 |
} |
|
68677f9…
|
noreply
|
59 |
|
|
68677f9…
|
noreply
|
60 |
// handleCreateAPIKey handles POST /v1/api-keys. |
|
68677f9…
|
noreply
|
61 |
func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) { |
|
68677f9…
|
noreply
|
62 |
var req createAPIKeyRequest |
|
68677f9…
|
noreply
|
63 |
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
|
68677f9…
|
noreply
|
64 |
writeError(w, http.StatusBadRequest, "invalid request body") |
|
68677f9…
|
noreply
|
65 |
return |
|
68677f9…
|
noreply
|
66 |
} |
|
68677f9…
|
noreply
|
67 |
if req.Name == "" { |
|
68677f9…
|
noreply
|
68 |
writeError(w, http.StatusBadRequest, "name is required") |
|
68677f9…
|
noreply
|
69 |
return |
|
68677f9…
|
noreply
|
70 |
} |
|
68677f9…
|
noreply
|
71 |
|
|
68677f9…
|
noreply
|
72 |
scopes := make([]auth.Scope, len(req.Scopes)) |
|
68677f9…
|
noreply
|
73 |
for i, s := range req.Scopes { |
|
68677f9…
|
noreply
|
74 |
scope := auth.Scope(s) |
|
68677f9…
|
noreply
|
75 |
if !auth.ValidScopes[scope] { |
|
68677f9…
|
noreply
|
76 |
writeError(w, http.StatusBadRequest, "unknown scope: "+s) |
|
68677f9…
|
noreply
|
77 |
return |
|
68677f9…
|
noreply
|
78 |
} |
|
68677f9…
|
noreply
|
79 |
scopes[i] = scope |
|
68677f9…
|
noreply
|
80 |
} |
|
68677f9…
|
noreply
|
81 |
if len(scopes) == 0 { |
|
68677f9…
|
noreply
|
82 |
writeError(w, http.StatusBadRequest, "at least one scope is required") |
|
68677f9…
|
noreply
|
83 |
return |
|
68677f9…
|
noreply
|
84 |
} |
|
68677f9…
|
noreply
|
85 |
|
|
68677f9…
|
noreply
|
86 |
var expiresAt time.Time |
|
68677f9…
|
noreply
|
87 |
if req.ExpiresIn != "" { |
|
68677f9…
|
noreply
|
88 |
dur, err := time.ParseDuration(req.ExpiresIn) |
|
68677f9…
|
noreply
|
89 |
if err != nil { |
|
68677f9…
|
noreply
|
90 |
writeError(w, http.StatusBadRequest, "invalid expires_in duration: "+err.Error()) |
|
68677f9…
|
noreply
|
91 |
return |
|
68677f9…
|
noreply
|
92 |
} |
|
68677f9…
|
noreply
|
93 |
expiresAt = time.Now().Add(dur) |
|
68677f9…
|
noreply
|
94 |
} |
|
68677f9…
|
noreply
|
95 |
|
|
68677f9…
|
noreply
|
96 |
token, key, err := s.apiKeys.Create(req.Name, scopes, expiresAt) |
|
68677f9…
|
noreply
|
97 |
if err != nil { |
|
68677f9…
|
noreply
|
98 |
s.log.Error("create api key", "err", err) |
|
68677f9…
|
noreply
|
99 |
writeError(w, http.StatusInternalServerError, "failed to create API key") |
|
68677f9…
|
noreply
|
100 |
return |
|
68677f9…
|
noreply
|
101 |
} |
|
68677f9…
|
noreply
|
102 |
|
|
68677f9…
|
noreply
|
103 |
resp := createAPIKeyResponse{ |
|
68677f9…
|
noreply
|
104 |
ID: key.ID, |
|
68677f9…
|
noreply
|
105 |
Name: key.Name, |
|
68677f9…
|
noreply
|
106 |
Token: token, |
|
68677f9…
|
noreply
|
107 |
Scopes: key.Scopes, |
|
68677f9…
|
noreply
|
108 |
CreatedAt: key.CreatedAt, |
|
68677f9…
|
noreply
|
109 |
} |
|
68677f9…
|
noreply
|
110 |
if !key.ExpiresAt.IsZero() { |
|
68677f9…
|
noreply
|
111 |
t := key.ExpiresAt |
|
68677f9…
|
noreply
|
112 |
resp.ExpiresAt = &t |
|
68677f9…
|
noreply
|
113 |
} |
|
68677f9…
|
noreply
|
114 |
writeJSON(w, http.StatusCreated, resp) |
|
68677f9…
|
noreply
|
115 |
} |
|
68677f9…
|
noreply
|
116 |
|
|
68677f9…
|
noreply
|
117 |
// handleRevokeAPIKey handles DELETE /v1/api-keys/{id}. |
|
68677f9…
|
noreply
|
118 |
func (s *Server) handleRevokeAPIKey(w http.ResponseWriter, r *http.Request) { |
|
68677f9…
|
noreply
|
119 |
id := r.PathValue("id") |
|
68677f9…
|
noreply
|
120 |
if err := s.apiKeys.Revoke(id); err != nil { |
|
68677f9…
|
noreply
|
121 |
writeError(w, http.StatusNotFound, err.Error()) |
|
68677f9…
|
noreply
|
122 |
return |
|
68677f9…
|
noreply
|
123 |
} |
|
68677f9…
|
noreply
|
124 |
w.WriteHeader(http.StatusNoContent) |
|
68677f9…
|
noreply
|
125 |
} |