106 lines
2.8 KiB
Go
106 lines
2.8 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
|
|
"git.unkin.net/unkin/encapi/internal/auth"
|
|
)
|
|
|
|
type scope int
|
|
|
|
const (
|
|
scopeTesting scope = iota + 1
|
|
scopeFull
|
|
)
|
|
|
|
type scopeKey struct{}
|
|
|
|
// requireWrite authenticates a write: the static token or a JWT with the full
|
|
// audience grants scopeFull, a JWT with the testing audience grants
|
|
// scopeTesting, which only routes registered with allowTesting accept.
|
|
func (s *Server) requireWrite(allowTesting bool) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sc, ok := s.authenticate(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if sc == scopeTesting && !allowTesting {
|
|
writeError(w, http.StatusForbidden, "token audience not permitted on this route")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), scopeKey{}, sc)))
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *Server) authenticate(w http.ResponseWriter, r *http.Request) (scope, bool) {
|
|
a := s.auth
|
|
if a.WriteToken == "" && a.OIDC == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "writes disabled: neither ENCAPI_WRITE_TOKEN nor ENCAPI_OIDC_ISSUER set")
|
|
return 0, false
|
|
}
|
|
presented := bearer(r)
|
|
if presented == "" {
|
|
writeError(w, http.StatusUnauthorized, "invalid or missing write token")
|
|
return 0, false
|
|
}
|
|
if a.WriteToken != "" && subtle.ConstantTimeCompare([]byte(presented), []byte(a.WriteToken)) == 1 {
|
|
return scopeFull, true
|
|
}
|
|
if a.OIDC != nil {
|
|
tok, err := a.OIDC.Verify(r.Context(), presented)
|
|
if errors.Is(err, auth.ErrIssuerUnavailable) {
|
|
writeError(w, http.StatusServiceUnavailable, "token issuer unavailable")
|
|
return 0, false
|
|
}
|
|
if err == nil {
|
|
sc := scopeTesting
|
|
if slices.Contains(tok.Audience, a.Audience) {
|
|
sc = scopeFull
|
|
}
|
|
slog.Info("bearer token accepted", "sub", tok.Subject, "aud", tok.Audience, "method", r.Method, "path", r.URL.Path)
|
|
return sc, true
|
|
}
|
|
slog.Info("bearer token rejected", "error", err)
|
|
}
|
|
writeError(w, http.StatusUnauthorized, "invalid or missing write token")
|
|
return 0, false
|
|
}
|
|
|
|
func bearer(r *http.Request) string {
|
|
if h := r.Header.Get("Authorization"); h != "" {
|
|
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
|
|
return after
|
|
}
|
|
}
|
|
return r.Header.Get("token")
|
|
}
|
|
|
|
func structuredLogger(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
|
defer func() {
|
|
slog.Info("request",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"status", ww.Status(),
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
|
"remote", r.RemoteAddr,
|
|
"request_id", middleware.GetReqID(r.Context()),
|
|
)
|
|
}()
|
|
next.ServeHTTP(ww, r)
|
|
})
|
|
}
|