246 lines
8 KiB
Go
246 lines
8 KiB
Go
|
|
// Package httpapi mounts the approval-service HTTP endpoints.
|
||
|
|
package httpapi
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"log/slog"
|
||
|
|
"net/http"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"basicstack.de/proofedmesh/services/approval-service/internal/auth"
|
||
|
|
"basicstack.de/proofedmesh/services/approval-service/internal/content"
|
||
|
|
"basicstack.de/proofedmesh/services/approval-service/internal/service"
|
||
|
|
"basicstack.de/proofedmesh/services/approval-service/internal/sessions"
|
||
|
|
"basicstack.de/proofedmesh/services/approval-service/internal/signer"
|
||
|
|
"basicstack.de/proofedmesh/services/approval-service/internal/store"
|
||
|
|
"basicstack.de/proofedmesh/services/approval-service/internal/webauthn"
|
||
|
|
pkgapi "basicstack.de/proofedmesh/services/approval-service/pkg/httpapi"
|
||
|
|
)
|
||
|
|
|
||
|
|
// AuthResolver resolves the authenticated principal from the request.
|
||
|
|
// Prod wires it to the Pocket-ID cookie middleware; tests inject a
|
||
|
|
// fixed principal via a shim.
|
||
|
|
type AuthResolver interface {
|
||
|
|
Resolve(r *http.Request) (auth.Principal, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
// AuthFunc adapts a plain function to AuthResolver.
|
||
|
|
type AuthFunc func(r *http.Request) (auth.Principal, error)
|
||
|
|
|
||
|
|
// Resolve calls f.
|
||
|
|
func (f AuthFunc) Resolve(r *http.Request) (auth.Principal, error) { return f(r) }
|
||
|
|
|
||
|
|
// Server is the HTTP handler for the approval-service.
|
||
|
|
type Server struct {
|
||
|
|
Auth AuthResolver
|
||
|
|
Sessions *sessions.Manager
|
||
|
|
Orchestrator *service.Orchestrator
|
||
|
|
Logger *slog.Logger
|
||
|
|
Now func() time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
// Mount registers the three API routes plus /healthz on mux.
|
||
|
|
func (s *Server) Mount(mux *http.ServeMux) {
|
||
|
|
mux.HandleFunc("POST /api/v1/sessions", s.handleCreateSession)
|
||
|
|
mux.HandleFunc("POST /api/v1/approvals/preview", s.handlePreview)
|
||
|
|
mux.HandleFunc("POST /api/v1/approvals/confirm", s.handleConfirm)
|
||
|
|
mux.HandleFunc("GET /healthz", s.handleHealth)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
w.WriteHeader(http.StatusOK)
|
||
|
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) {
|
||
|
|
p, err := s.resolve(r)
|
||
|
|
if err != nil {
|
||
|
|
writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
// POST /sessions has no body in Phase 1; the Pocket-ID principal
|
||
|
|
// alone identifies the user.
|
||
|
|
sess, err := s.Sessions.StartSession(r.Context(), p.Sub)
|
||
|
|
if err != nil {
|
||
|
|
s.logError("start session failed", err)
|
||
|
|
writeErr(w, http.StatusInternalServerError, "session_create_failed", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusCreated, pkgapi.CreateSessionResponse{
|
||
|
|
SessionID: sess.ID,
|
||
|
|
UserSub: sess.UserSub,
|
||
|
|
ActiveUntil: sess.ActiveUntil,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
|
||
|
|
p, err := s.resolve(r)
|
||
|
|
if err != nil {
|
||
|
|
writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
var req pkgapi.PreviewRequest
|
||
|
|
if err := decodeJSON(r, &req); err != nil {
|
||
|
|
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if req.SessionID == "" {
|
||
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "session_id is required")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if len(req.Content) == 0 {
|
||
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "content is required")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
sess, err := s.Sessions.GetActiveSession(r.Context(), req.SessionID, p.Sub)
|
||
|
|
switch {
|
||
|
|
case errors.Is(err, store.ErrNotFound):
|
||
|
|
writeErr(w, http.StatusNotFound, "session_not_found", "session_id unknown")
|
||
|
|
return
|
||
|
|
case errors.Is(err, sessions.ErrSessionExpired):
|
||
|
|
writeErr(w, http.StatusForbidden, "session_expired", "approval session expired")
|
||
|
|
return
|
||
|
|
case err != nil:
|
||
|
|
s.logError("session lookup failed", err)
|
||
|
|
writeErr(w, http.StatusInternalServerError, "session_lookup_failed", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
bind, err := content.Bind(req.Content)
|
||
|
|
if err != nil {
|
||
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "content: "+err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
previewID, err := s.Orchestrator.NewID("pv")
|
||
|
|
if err != nil {
|
||
|
|
writeErr(w, http.StatusInternalServerError, "id_gen_failed", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
now := s.now()
|
||
|
|
pv := store.Preview{
|
||
|
|
ID: previewID,
|
||
|
|
SessionID: sess.ID,
|
||
|
|
UserSub: p.Sub,
|
||
|
|
ContentHash: bind.ContentHash,
|
||
|
|
ContentCanonical: bind.ContentCanonical,
|
||
|
|
Salt: bind.Salt,
|
||
|
|
Nonce: bind.Nonce,
|
||
|
|
VerificationDigits: bind.VerificationDigits,
|
||
|
|
ExpiresAt: now.Add(s.Sessions.ChallengeTTL),
|
||
|
|
CreatedAt: now,
|
||
|
|
}
|
||
|
|
if err := s.Orchestrator.Store.SavePreview(r.Context(), pv); err != nil {
|
||
|
|
s.logError("save preview failed", err)
|
||
|
|
writeErr(w, http.StatusInternalServerError, "save_preview_failed", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, pkgapi.PreviewResponse{
|
||
|
|
PreviewID: pv.ID,
|
||
|
|
ContentHash: pv.ContentHash,
|
||
|
|
VerificationDigits: pv.VerificationDigits,
|
||
|
|
WebauthnChallenge: base64.RawURLEncoding.EncodeToString(bind.Challenge),
|
||
|
|
ExpiresAt: pv.ExpiresAt,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
|
||
|
|
p, err := s.resolve(r)
|
||
|
|
if err != nil {
|
||
|
|
writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
var req pkgapi.ConfirmRequest
|
||
|
|
if err := decodeJSON(r, &req); err != nil {
|
||
|
|
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if req.PreviewID == "" {
|
||
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "preview_id is required")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
assertion := webauthn.Assertion(req.Assertion)
|
||
|
|
res, err := s.Orchestrator.Confirm(r.Context(), req.PreviewID, assertion, p.Sub)
|
||
|
|
if err != nil {
|
||
|
|
s.mapConfirmError(w, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, pkgapi.ConfirmResponse{
|
||
|
|
ApprovalID: res.ApprovalID,
|
||
|
|
AttestationRef: res.AttestationRef,
|
||
|
|
LogIndex: res.LogIndex,
|
||
|
|
TLogStatus: string(res.TLogStatus),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) mapConfirmError(w http.ResponseWriter, err error) {
|
||
|
|
switch {
|
||
|
|
case errors.Is(err, store.ErrNotFound):
|
||
|
|
writeErr(w, http.StatusNotFound, "preview_not_found", "preview_id unknown")
|
||
|
|
case errors.Is(err, store.ErrAlreadyConsumed):
|
||
|
|
writeErr(w, http.StatusConflict, "preview_consumed", "preview already consumed")
|
||
|
|
case errors.Is(err, service.ErrChallengeExpired):
|
||
|
|
writeErr(w, http.StatusForbidden, "challenge_expired", "approval challenge expired")
|
||
|
|
case errors.Is(err, service.ErrContentHashMismatch):
|
||
|
|
writeErr(w, http.StatusBadRequest, "content_hash_mismatch", err.Error())
|
||
|
|
case errors.Is(err, webauthn.ErrChallengeMismatch):
|
||
|
|
writeErr(w, http.StatusBadRequest, "content_hash_mismatch", err.Error())
|
||
|
|
case errors.Is(err, webauthn.ErrCredentialUnknown):
|
||
|
|
writeErr(w, http.StatusUnauthorized, "credential_unknown", "credential_id not registered")
|
||
|
|
case errors.Is(err, webauthn.ErrAssertionInvalid):
|
||
|
|
writeErr(w, http.StatusUnauthorized, "assertion_invalid", "webauthn assertion verification failed")
|
||
|
|
case errors.Is(err, signer.ErrSignerUnavailable):
|
||
|
|
writeErr(w, http.StatusBadGateway, "signer_unavailable", err.Error())
|
||
|
|
default:
|
||
|
|
s.logError("confirm failed", err)
|
||
|
|
writeErr(w, http.StatusInternalServerError, "confirm_failed", err.Error())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) resolve(r *http.Request) (auth.Principal, error) {
|
||
|
|
if s.Auth == nil {
|
||
|
|
return auth.Principal{}, errors.New("no auth resolver configured")
|
||
|
|
}
|
||
|
|
return s.Auth.Resolve(r)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) now() time.Time {
|
||
|
|
if s.Now != nil {
|
||
|
|
return s.Now()
|
||
|
|
}
|
||
|
|
return time.Now()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) logError(msg string, err error) {
|
||
|
|
if s.Logger != nil {
|
||
|
|
s.Logger.Error("approval-service: "+msg, "err", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func decodeJSON(r *http.Request, v any) error {
|
||
|
|
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
||
|
|
dec.DisallowUnknownFields()
|
||
|
|
if err := dec.Decode(v); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
w.WriteHeader(status)
|
||
|
|
_ = json.NewEncoder(w).Encode(v)
|
||
|
|
}
|
||
|
|
|
||
|
|
func writeErr(w http.ResponseWriter, status int, code, msg string) {
|
||
|
|
writeJSON(w, status, pkgapi.Error{Error: pkgapi.ErrorBody{Code: code, Message: msg}})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Context sanity: ensure we never return a canceled error as a 500 just
|
||
|
|
// because the client disconnected mid-flight.
|
||
|
|
var _ context.Context = context.Background()
|