ProofedMesh/services/approval-service/internal/auth/context.go

63 lines
1.9 KiB
Go
Raw Permalink Normal View History

approval-service: Phase-1 content-binding approval service (DEV-608) New services/approval-service with the Session → Preview → Confirm pipeline required by DEV-606/DEV-608: * POST /api/v1/sessions starts a Pocket-ID-bound approval session. * POST /api/v1/approvals/preview renders content server-side, computes H_content = sha256(canonical JSON), stores the (nonce, salt, canonical, challenge) tuple and returns the human verification digits plus the WebAuthn challenge = nonce || H_content. * POST /api/v1/approvals/confirm verifies the WebAuthn assertion against the stored challenge, DSSE-signs the Approval statement via the signing-service (key_ref=trust-api/approval-signer-2026-q4), submits to the transparency-log, and returns {approval_id, attestation_ref, log_index}. TLog failures leave the row at attestation-pending for the reconciler. * db/trust/migrations/0004_approval_sessions.{up,down}.sql: new tables approval_sessions, approval_previews, approvals (schema matches the Phase 1 store contract). * internal/content: canonical JSON + H_content + 6-digit verification code (byte-compatible with signing-service/internal/canonical). * internal/webauthn: stdlib-only Phase-1 verifier; wire shape stays WebAuthn-compatible for the Phase-2 swap to go-webauthn/webauthn. * internal/signer, internal/tlog: HTTP clients mapping 5xx / connection errors to typed ErrSignerUnavailable / ErrTLogUnavailable so the HTTP layer can return 502 for signing failures and persist attestation-pending on TLog failure. * internal/service.Orchestrator ties the pipeline together and persists the Approval BEFORE the TLog submit (never return a log_index for an un-persisted approval). Tests cover: happy path, tampered H_content → 400, expired challenge → 403, signer 5xx + conn-refused → 502, TLog failure → row stays attestation-pending, duplicate confirm → 409, session expired → 403. go vet clean, go test ./... passes. Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 19:42:06 +00:00
// Package auth carries the authenticated principal across the HTTP stack
// so handlers do not re-decode the Pocket-ID session cookie on each call.
// The Pocket-ID verification lives in services/admin-api/internal/auth; the
// approval-service accepts a Principal already stamped by the gateway or
// its own future middleware wrap (Phase 2).
package auth
import (
"context"
"errors"
)
type ctxKey int
const (
principalKey ctxKey = iota
sessionKey
)
// Principal is the subject identifier (Pocket-ID `sub`) of the browser user
// driving the approval. Attestations will carry it as `performed_by.sub`.
type Principal struct {
Sub string
Groups []string
}
// ErrNoPrincipal is returned by MustPrincipal when the request context did
// not carry an authenticated principal.
var ErrNoPrincipal = errors.New("auth: request is not authenticated")
// WithPrincipal returns a copy of ctx carrying p.
func WithPrincipal(ctx context.Context, p Principal) context.Context {
return context.WithValue(ctx, principalKey, p)
}
// FromContext returns the Principal stamped by WithPrincipal.
func FromContext(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(principalKey).(Principal)
return p, ok
}
// MustFromContext is a convenience that returns ErrNoPrincipal when no
// principal is present.
func MustFromContext(ctx context.Context) (Principal, error) {
p, ok := FromContext(ctx)
if !ok || p.Sub == "" {
return Principal{}, ErrNoPrincipal
}
return p, nil
}
// WithSession binds the Pocket-ID `sid` claim to ctx. Approval-sessions
// scope themselves to the browser login via this value.
func WithSession(ctx context.Context, sid string) context.Context {
return context.WithValue(ctx, sessionKey, sid)
}
// SessionFromContext returns the browser sid carried in ctx.
func SessionFromContext(ctx context.Context) (string, bool) {
s, ok := ctx.Value(sessionKey).(string)
return s, ok && s != ""
}