Files
ITWorx-Pulse-Public/internal/authapi/flowstore.go
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

117 lines
2.8 KiB
Go

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
}