5afff001c3
Add a local PasskeySession that handleTools checks before processing any POST action (enable/disable). If the session hasn't been asserted within the 5-minute TTL, return 403 Forbidden. Changes: - webauthn/session.go: add IsStepUp() convenience method (nil-safe) - webauthn.go: PasskeyHandle holds a *PasskeySession; AssertFinish calls session.Assert() after IPC step-up - main.go: create stepUpSession, pass to handleTools and newPasskeyHandle; handleTools returns 403 if !session.IsStepUp() - handlers_test.go: update TestEnableTool_NoInProcessAuthGate to expect 403; add TestEnableTool_WithAuthGate_RequiresStepUp for the happy path with asserted session; update all 10 call sites
71 lines
2.2 KiB
Go
71 lines
2.2 KiB
Go
package webauthn
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/auth"
|
|
)
|
|
|
|
// PasskeySession implements auth.Session backed by WebAuthn passkey assertion.
|
|
// The session starts at Layer2 (passkey is enrolled, this session exists) and
|
|
// bumps to Layer3 on successful Assert(), which lasts for assertionTTL before
|
|
// decaying back to Layer2.
|
|
//
|
|
// A nil *PasskeySession is a valid zero: it acts like a session with no
|
|
// credentials enrolled (always L2, Assert returns ErrStepUpUnsupported).
|
|
// This mirrors the FloorSession behavior when passkey is not configured.
|
|
type PasskeySession struct {
|
|
mu sync.Mutex
|
|
assertedAt time.Time // zero = not asserted this session
|
|
assertionTTL time.Duration
|
|
}
|
|
|
|
// NewPasskeySession creates a session. The caller chooses the assertion TTL
|
|
// (how long a step-up gesture remains valid). 5 minutes is a sensible default.
|
|
func NewPasskeySession(assertionTTL time.Duration) *PasskeySession {
|
|
if assertionTTL <= 0 {
|
|
assertionTTL = 5 * time.Minute
|
|
}
|
|
return &PasskeySession{assertionTTL: assertionTTL}
|
|
}
|
|
|
|
// CurrentLayer returns L3 if step-up has been asserted within the TTL,
|
|
// otherwise L2 (passkey enrolled, this session proven). A nil receiver
|
|
// returns L2 (no way to reach L3 without a session).
|
|
func (s *PasskeySession) CurrentLayer(_ context.Context, _ auth.Scope) auth.Layer {
|
|
if s == nil {
|
|
return auth.Layer2
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if !s.assertedAt.IsZero() && time.Since(s.assertedAt) < s.assertionTTL {
|
|
return auth.Layer3
|
|
}
|
|
return auth.Layer2
|
|
}
|
|
|
|
// IsStepUp returns true if the session was recently asserted (within TTL).
|
|
func (s *PasskeySession) IsStepUp() bool {
|
|
if s == nil {
|
|
return false
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return !s.assertedAt.IsZero() && time.Since(s.assertedAt) < s.assertionTTL
|
|
}
|
|
|
|
// Assert records a successful step-up gesture. The session bumps to L3 for
|
|
// the assertion TTL. A nil receiver returns ErrStepUpUnsupported.
|
|
func (s *PasskeySession) Assert(_ context.Context, _ auth.Scope) error {
|
|
if s == nil {
|
|
return fmt.Errorf("%w: passkey session not configured", auth.ErrStepUpUnsupported)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.assertedAt = time.Now()
|
|
return nil
|
|
}
|