ScuttleBot
| 2d8a379… | lmata | 1 | package api |
| 2d8a379… | lmata | 2 | |
| 2d8a379… | lmata | 3 | import ( |
| 68677f9… | noreply | 4 | "context" |
| 2d8a379… | lmata | 5 | "net/http" |
| 2d8a379… | lmata | 6 | "strings" |
| 68677f9… | noreply | 7 | |
| 68677f9… | noreply | 8 | "github.com/conflicthq/scuttlebot/internal/auth" |
| 2d8a379… | lmata | 9 | ) |
| 2d8a379… | lmata | 10 | |
| 68677f9… | noreply | 11 | type ctxKey string |
| 68677f9… | noreply | 12 | |
| 68677f9… | noreply | 13 | const ctxAPIKey ctxKey = "apikey" |
| 68677f9… | noreply | 14 | |
| 68677f9… | noreply | 15 | // apiKeyFromContext returns the authenticated APIKey from the request context, |
| 68677f9… | noreply | 16 | // or nil if not authenticated. |
| 68677f9… | noreply | 17 | func apiKeyFromContext(ctx context.Context) *auth.APIKey { |
| 68677f9… | noreply | 18 | k, _ := ctx.Value(ctxAPIKey).(*auth.APIKey) |
| 68677f9… | noreply | 19 | return k |
| 68677f9… | noreply | 20 | } |
| 68677f9… | noreply | 21 | |
| 68677f9… | noreply | 22 | // authMiddleware validates the Bearer token and injects the APIKey into context. |
| 2d8a379… | lmata | 23 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 2d8a379… | lmata | 24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 2d8a379… | lmata | 25 | token := bearerToken(r) |
| 2d8a379… | lmata | 26 | if token == "" { |
| 2d8a379… | lmata | 27 | writeError(w, http.StatusUnauthorized, "missing authorization header") |
| 2d8a379… | lmata | 28 | return |
| 2d8a379… | lmata | 29 | } |
| 68677f9… | noreply | 30 | key := s.apiKeys.Lookup(token) |
| 68677f9… | noreply | 31 | if key == nil { |
| 2d8a379… | lmata | 32 | writeError(w, http.StatusUnauthorized, "invalid token") |
| 2d8a379… | lmata | 33 | return |
| 2d8a379… | lmata | 34 | } |
| 68677f9… | noreply | 35 | // Update last-used timestamp in the background. |
| 68677f9… | noreply | 36 | go s.apiKeys.TouchLastUsed(key.ID) |
| 68677f9… | noreply | 37 | |
| 68677f9… | noreply | 38 | ctx := context.WithValue(r.Context(), ctxAPIKey, key) |
| 68677f9… | noreply | 39 | next.ServeHTTP(w, r.WithContext(ctx)) |
| 2d8a379… | lmata | 40 | }) |
| 68677f9… | noreply | 41 | } |
| 68677f9… | noreply | 42 | |
| 68677f9… | noreply | 43 | // requireScope returns middleware that rejects requests without the given scope. |
| 68677f9… | noreply | 44 | func (s *Server) requireScope(scope auth.Scope, next http.HandlerFunc) http.HandlerFunc { |
| 68677f9… | noreply | 45 | return func(w http.ResponseWriter, r *http.Request) { |
| 68677f9… | noreply | 46 | key := apiKeyFromContext(r.Context()) |
| 68677f9… | noreply | 47 | if key == nil { |
| 68677f9… | noreply | 48 | writeError(w, http.StatusUnauthorized, "missing authentication") |
| 68677f9… | noreply | 49 | return |
| 68677f9… | noreply | 50 | } |
| 68677f9… | noreply | 51 | if !key.HasScope(scope) { |
| 68677f9… | noreply | 52 | writeError(w, http.StatusForbidden, "insufficient scope: requires "+string(scope)) |
| 68677f9… | noreply | 53 | return |
| 68677f9… | noreply | 54 | } |
| 68677f9… | noreply | 55 | next(w, r) |
| 68677f9… | noreply | 56 | } |
| 2d8a379… | lmata | 57 | } |
| 2d8a379… | lmata | 58 | |
| 2d8a379… | lmata | 59 | func bearerToken(r *http.Request) string { |
| 2d8a379… | lmata | 60 | auth := r.Header.Get("Authorization") |
| 2d8a379… | lmata | 61 | token, found := strings.CutPrefix(auth, "Bearer ") |
| 2d8a379… | lmata | 62 | if !found { |
| 2d8a379… | lmata | 63 | return "" |
| 2d8a379… | lmata | 64 | } |
| 2d8a379… | lmata | 65 | return strings.TrimSpace(token) |
| 2d8a379… | lmata | 66 | } |