easyai-ai-gateway/apps/api/internal/securityevents/handler.go

186 lines
6.0 KiB
Go

package securityevents
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type Repository interface {
ApplySessionRevoked(context.Context, store.ApplySessionRevokedInput) (store.ApplySecurityEventResult, error)
ApplySecurityEventStreamUpdated(context.Context, string, string, string, string, string, string, time.Time) (bool, error)
ConfirmSecurityEventVerification(context.Context, string, string, string, string, []byte, time.Time) (bool, error)
RecordSecurityEventReceipt(context.Context, string, string, string, string, string) (bool, error)
}
type Handler struct {
verifier *Verifier
repository Repository
issuer string
audience string
streamID string
bearerSecret string
nextSecret string
clock func() time.Time
metrics *Metrics
}
func (h *Handler) SetMetrics(metrics *Metrics) { h.metrics = metrics }
func NewHandler(verifier *Verifier, repository Repository, issuer, audience, streamID, bearerSecret, nextSecret string) (*Handler, error) {
if verifier == nil || repository == nil || issuer == "" || audience == "" || streamID == "" || len(bearerSecret) < 16 {
return nil, errors.New("SSF receiver dependencies and bearer secret are required")
}
return &Handler{verifier: verifier, repository: repository, issuer: issuer, audience: audience, streamID: streamID, bearerSecret: bearerSecret, nextSecret: nextSecret, clock: time.Now}, nil
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
started := h.clock()
outcome := "rejected"
var sessionsDeleted int64
defer func() {
if h.metrics != nil {
h.metrics.ObserveReceipt(outcome, h.clock().Sub(started), sessionsDeleted)
}
}()
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if !h.validAuthorization(r.Header.Get("Authorization")) {
w.Header().Set("WWW-Authenticate", `Bearer realm="ssf-receiver"`)
writeSETError(w, http.StatusUnauthorized, "authentication_failed")
return
}
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mediaType != "application/secevent+jwt" || r.Header.Get("Content-Encoding") != "" {
writeSETError(w, http.StatusBadRequest, "invalid_request")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
payload, err := io.ReadAll(r.Body)
if err != nil || len(payload) == 0 {
writeSETError(w, http.StatusBadRequest, "invalid_request")
return
}
event, err := h.verifier.Verify(r.Context(), string(payload))
if err != nil {
var protocol *protocolError
if errors.As(err, &protocol) {
writeSETError(w, http.StatusBadRequest, protocol.Code)
} else {
writeSETError(w, http.StatusBadRequest, "invalid_request")
}
return
}
switch event.EventType {
case SessionRevokedEventType:
var result store.ApplySecurityEventResult
result, err = h.repository.ApplySessionRevoked(r.Context(), store.ApplySessionRevokedInput{
Issuer: event.Issuer, Audience: event.Audience, JTI: event.JTI, TransactionID: event.TransactionID,
SubjectIssuer: event.SubjectIssuer, TenantID: event.TenantID, Subject: event.Subject, EventTimestamp: event.EventTimestamp,
InitiatingEntity: event.InitiatingEntity,
})
if err == nil {
sessionsDeleted = result.SessionsDeleted
if result.Duplicate {
outcome = "duplicate"
} else {
outcome = "accepted"
}
}
case VerificationEventType:
var confirmed bool
if event.State == "" {
confirmed, err = h.repository.RecordSecurityEventReceipt(r.Context(), h.issuer, h.audience, event.JTI, VerificationEventType, h.streamID)
} else {
stateHash := sha256.Sum256([]byte(event.State))
confirmed, err = h.repository.ConfirmSecurityEventVerification(r.Context(), h.issuer, h.audience, h.streamID, event.JTI, stateHash[:], h.clock().UTC())
}
if err != nil && strings.Contains(err.Error(), "invalid verification state") {
writeSETError(w, http.StatusBadRequest, "invalid_request")
return
}
if err == nil {
if confirmed {
outcome = "accepted"
if h.metrics != nil {
h.metrics.ObserveVerificationAccepted()
}
} else {
outcome = "duplicate"
}
}
case StreamUpdatedEventType:
var inserted bool
inserted, err = h.repository.ApplySecurityEventStreamUpdated(
r.Context(), h.issuer, h.audience, event.JTI, h.streamID, event.StreamStatus, event.Reason, h.clock().UTC(),
)
if err == nil {
if inserted {
outcome = "accepted"
} else {
outcome = "duplicate"
}
}
}
if err != nil {
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusAccepted)
}
func (h *Handler) validAuthorization(value string) bool {
if constantBearerCompare(value, h.bearerSecret) {
return true
}
return h.nextSecret != "" && constantBearerCompare(value, h.nextSecret)
}
func constantBearerCompare(header, secret string) bool {
providedHash := sha256.Sum256([]byte(header))
expectedHash := sha256.Sum256([]byte("Bearer " + secret))
return subtle.ConstantTimeCompare(providedHash[:], expectedHash[:]) == 1
}
func writeSETError(w http.ResponseWriter, status int, code string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Language", "en")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{
"err": code,
"description": setErrorDescription(code),
})
}
func setErrorDescription(code string) string {
switch code {
case "invalid_request":
return "The SET request is malformed or contains invalid claims."
case "invalid_key":
return "The SET signing key or signature is invalid."
case "invalid_issuer":
return "The SET issuer is not accepted by this receiver."
case "invalid_audience":
return "The SET audience does not identify this receiver."
case "authentication_failed":
return "Receiver authentication failed."
case "access_denied":
return "The SET is not permitted for this receiver."
default:
return "The SET request could not be processed."
}
}