package authapi import ( "crypto/rand" "encoding/base64" "errors" "sync" "time" "github.com/itworx/pulse/internal/auth" ) const ( defaultFlowTTL = 10 * time.Minute defaultMaxFlows = 1024 flowIDByteLength = 32 ) // flow is the server-side state of one in-progress authorization code flow. Only // an opaque identifier for it ever reaches the browser. type flow struct { authorization auth.Authorization redirect string createdAt time.Time } // flowStore keeps pending flows in memory. It is bounded by TTL and by a maximum // entry count so an unauthenticated caller cannot grow it without limit, and it is // safe for concurrent use. type flowStore struct { mu sync.Mutex flows map[string]flow ttl time.Duration max int } func newFlowStore(ttl time.Duration, max int) *flowStore { if ttl <= 0 { ttl = defaultFlowTTL } if max <= 0 { max = defaultMaxFlows } return &flowStore{flows: make(map[string]flow), ttl: ttl, max: max} } // create stores one pending flow and returns its opaque identifier. Expired entries // are removed first; if the store is still at capacity the oldest entry is dropped // so a flood of abandoned flows cannot deny logins permanently. func (store *flowStore) create(entry flow, now time.Time) (string, error) { id, err := randomFlowID() if err != nil { return "", errors.New("generate authorization flow identifier") } entry.createdAt = now store.mu.Lock() defer store.mu.Unlock() store.purge(now) for len(store.flows) >= store.max && store.evictOldest() { } store.flows[id] = entry return id, nil } // take returns a pending flow and always removes it, so a flow identifier can be // used at most once. An unknown, replayed or expired identifier returns false. func (store *flowStore) take(id string, now time.Time) (flow, bool) { if id == "" { return flow{}, false } store.mu.Lock() defer store.mu.Unlock() entry, ok := store.flows[id] delete(store.flows, id) if !ok || !now.Before(entry.createdAt.Add(store.ttl)) { return flow{}, false } return entry, true } func (store *flowStore) size() int { store.mu.Lock() defer store.mu.Unlock() return len(store.flows) } func (store *flowStore) purge(now time.Time) { for id, entry := range store.flows { if !now.Before(entry.createdAt.Add(store.ttl)) { delete(store.flows, id) } } } func (store *flowStore) evictOldest() bool { oldest := "" var oldestAt time.Time for id, entry := range store.flows { if oldest == "" || entry.createdAt.Before(oldestAt) { oldest, oldestAt = id, entry.createdAt } } if oldest == "" { return false } delete(store.flows, oldest) return true } func randomFlowID() (string, error) { buffer := make([]byte, flowIDByteLength) if _, err := rand.Read(buffer); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(buffer), nil }