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 }