package authentik import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "sync" "time" "github.com/redis/go-redis/v9" ) const ( defaultFlowSessionTTL = 20 * time.Minute flowSessionKeyPrefix = "auth_flow_session:" ) type storedFlowSession struct { Slug string `json:"slug"` Cookies []SerializedCookie `json:"cookies"` CreatedAt time.Time `json:"createdAt"` Completed bool `json:"completed,omitempty"` } type flowSessionEntry struct { slug string cookies []SerializedCookie createdAt time.Time completed bool } // FlowSessionStore keeps Authentik flow executor sessions (memory + optional KeyDB). type FlowSessionStore struct { mu sync.Mutex baseURL string ttl time.Duration rdb *redis.Client items map[string]*flowSessionEntry } func NewFlowSessionStore(baseURL string, rdb *redis.Client) *FlowSessionStore { return &FlowSessionStore{ baseURL: baseURL, ttl: defaultFlowSessionTTL, rdb: rdb, items: make(map[string]*flowSessionEntry), } } func (s *FlowSessionStore) Start(ctx context.Context, slug, query string) (sessionID string, challenge FlowChallenge, err error) { executor, err := NewFlowExecutor(s.baseURL, slug) if err != nil { return "", nil, err } challenge, err = executor.GetChallenge(ctx, query) if err != nil { return "", nil, err } id, err := randomSessionID() if err != nil { return "", nil, err } done, _ := FlowDone(challenge) if !done { entry := &flowSessionEntry{ slug: slug, cookies: executor.ExportCookies(), createdAt: time.Now(), } if err := s.save(ctx, id, entry); err != nil { return "", nil, err } } return id, challenge, nil } func (s *FlowSessionStore) Respond(ctx context.Context, sessionID, slug, query string, payload map[string]any) (FlowChallenge, error) { entry, err := s.load(ctx, sessionID) if err != nil { return nil, err } if entry.slug != slug { return nil, ErrFlowSessionSlugMismatch } if entry.completed { return nil, ErrFlowSessionAlreadyCompleted } executor, err := RestoreFlowExecutor(s.baseURL, slug, entry.cookies) if err != nil { return nil, err } challenge, err := executor.PostResponse(ctx, query, payload) if err != nil { return nil, err } entry.cookies = executor.ExportCookies() done, denied := FlowDone(challenge) if done && !denied { entry.completed = true if err := s.save(ctx, sessionID, entry); err != nil { return nil, err } return challenge, nil } if err := s.save(ctx, sessionID, entry); err != nil { return nil, err } return challenge, nil } // CompleteSession returns cookies from a completed flow and removes the session. func (s *FlowSessionStore) CompleteSession(ctx context.Context, sessionID, slug string) ([]SerializedCookie, error) { entry, err := s.load(ctx, sessionID) if err != nil { return nil, err } if entry.slug != slug { return nil, ErrFlowSessionSlugMismatch } if !entry.completed { return nil, ErrFlowSessionNotCompleted } cookies := entry.cookies s.delete(ctx, sessionID) return cookies, nil } // SessionCookies returns current Authentik cookies for an active session. func (s *FlowSessionStore) SessionCookies(ctx context.Context, sessionID string) ([]SerializedCookie, error) { entry, err := s.load(ctx, sessionID) if err != nil { return nil, err } return entry.cookies, nil } func (s *FlowSessionStore) Delete(ctx context.Context, sessionID string) { s.delete(ctx, sessionID) } func (s *FlowSessionStore) save(ctx context.Context, sessionID string, entry *flowSessionEntry) error { s.mu.Lock() s.items[sessionID] = entry s.mu.Unlock() if s.rdb == nil { return nil } stored := storedFlowSession{ Slug: entry.slug, Cookies: entry.cookies, CreatedAt: entry.createdAt, Completed: entry.completed, } raw, err := json.Marshal(stored) if err != nil { return err } return s.rdb.Set(ctx, flowSessionKeyPrefix+sessionID, raw, s.ttl).Err() } func (s *FlowSessionStore) load(ctx context.Context, sessionID string) (*flowSessionEntry, error) { if s.rdb != nil { raw, err := s.rdb.Get(ctx, flowSessionKeyPrefix+sessionID).Bytes() if err != nil { if errors.Is(err, redis.Nil) { return nil, ErrFlowSessionNotFound } return nil, err } var stored storedFlowSession if err := json.Unmarshal(raw, &stored); err != nil { return nil, err } if time.Since(stored.CreatedAt) > s.ttl { s.delete(ctx, sessionID) return nil, ErrFlowSessionNotFound } entry := &flowSessionEntry{ slug: stored.Slug, cookies: stored.Cookies, createdAt: stored.CreatedAt, completed: stored.Completed, } s.mu.Lock() s.items[sessionID] = entry s.mu.Unlock() return entry, nil } s.mu.Lock() entry, ok := s.items[sessionID] s.mu.Unlock() if !ok { return nil, ErrFlowSessionNotFound } if time.Since(entry.createdAt) > s.ttl { s.delete(ctx, sessionID) return nil, ErrFlowSessionNotFound } return entry, nil } func (s *FlowSessionStore) delete(ctx context.Context, sessionID string) { s.mu.Lock() delete(s.items, sessionID) s.mu.Unlock() if s.rdb != nil { _ = s.rdb.Del(ctx, flowSessionKeyPrefix+sessionID).Err() } } func randomSessionID() (string, error) { buf := make([]byte, 24) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil }