- Added a new blueprint for OIDC logout that invalidates the Authentik session and redirects to a specified landing page. - Introduced custom CSS and JS files for branding, improving the visual integration of Authentik flows. - Updated Nginx configuration to serve the new branding assets and handle specific routes for signup and password recovery. - Enhanced the flow completion logic to support OIDC bridge functionality, including session management and redirect handling. - Implemented unit tests for the new OIDC bridge and flow context functionalities to ensure reliability.
325 lines
8.8 KiB
Go
325 lines
8.8 KiB
Go
package authentik
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"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"`
|
|
OAuthCallback string `json:"oauthCallback,omitempty"`
|
|
}
|
|
|
|
type flowSessionEntry struct {
|
|
slug string
|
|
cookies []SerializedCookie
|
|
createdAt time.Time
|
|
completed bool
|
|
oauthCallback string
|
|
}
|
|
|
|
// 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()
|
|
challenge, err = s.advanceHeadlessStages(ctx, executor, query, challenge)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entry.cookies = executor.ExportCookies()
|
|
|
|
if SessionAuthenticated(entry.cookies) {
|
|
entry.completed = true
|
|
// If the flow's terminal redirect is an OIDC authorize continuation, follow it within the
|
|
// live authenticated jar to capture the authorization code (the only reliable way to bridge
|
|
// an API-driven session to OIDC; replaying the session cookie elsewhere is rejected).
|
|
if to, ok := challenge["to"].(string); ok && isOAuthAuthorizeRedirect(to) {
|
|
callback, capErr := executor.CaptureOAuthCallback(ctx, to)
|
|
if capErr != nil {
|
|
slog.Warn("capture oauth callback failed", "err", capErr.Error())
|
|
} else {
|
|
entry.oauthCallback = callback
|
|
slog.Info("captured oauth callback", "ok", callback != "")
|
|
}
|
|
}
|
|
if err := s.save(ctx, sessionID, entry); err != nil {
|
|
return nil, err
|
|
}
|
|
if FlowComponent(challenge) != "xak-flow-redirect" {
|
|
challenge = FlowChallenge{"component": "xak-flow-redirect"}
|
|
}
|
|
return challenge, nil
|
|
}
|
|
|
|
done, denied := FlowDone(challenge)
|
|
if done && !denied {
|
|
return nil, fmt.Errorf("authentik flow finished without authenticated session")
|
|
}
|
|
if err := s.save(ctx, sessionID, entry); err != nil {
|
|
return nil, err
|
|
}
|
|
return challenge, nil
|
|
}
|
|
|
|
func (s *FlowSessionStore) advanceHeadlessStages(ctx context.Context, executor *FlowExecutor, query string, challenge FlowChallenge) (FlowChallenge, error) {
|
|
const maxSteps = 8
|
|
for step := 0; step < maxSteps; step++ {
|
|
if SessionAuthenticated(executor.ExportCookies()) {
|
|
return challenge, nil
|
|
}
|
|
if _, denied := FlowDone(challenge); denied {
|
|
return challenge, nil
|
|
}
|
|
|
|
switch FlowComponent(challenge) {
|
|
case "ak-stage-user-login":
|
|
var err error
|
|
challenge, err = executor.PostResponse(ctx, query, map[string]any{"component": "ak-stage-user-login"})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
case "xak-flow-redirect":
|
|
// Resume pending stages before following terminal redirects (/auth/ resets session).
|
|
next, err := executor.GetChallenge(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if comp := FlowComponent(next); comp != "" && comp != "xak-flow-redirect" {
|
|
challenge = next
|
|
continue
|
|
}
|
|
if to, ok := challenge["to"].(string); ok && isTerminalFlowRedirect(to) {
|
|
loginChallenge, loginErr := executor.PostResponse(ctx, query, map[string]any{"component": "ak-stage-user-login"})
|
|
if loginErr == nil {
|
|
challenge = loginChallenge
|
|
continue
|
|
}
|
|
return challenge, nil
|
|
}
|
|
if to, ok := challenge["to"].(string); ok && strings.TrimSpace(to) != "" {
|
|
if err := executor.FollowFlowRedirect(ctx, to); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
next, err = executor.GetChallenge(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
challenge = next
|
|
default:
|
|
return challenge, nil
|
|
}
|
|
}
|
|
return challenge, nil
|
|
}
|
|
|
|
func isOAuthAuthorizeRedirect(to string) bool {
|
|
return strings.Contains(to, "/application/o/authorize")
|
|
}
|
|
|
|
func isTerminalFlowRedirect(to string) bool {
|
|
to = strings.TrimSpace(to)
|
|
if to == "" || to == "/auth/" || to == "/auth" {
|
|
return true
|
|
}
|
|
return strings.HasSuffix(strings.TrimSuffix(to, "/"), "/auth")
|
|
}
|
|
|
|
// CompleteSession returns cookies from a completed flow and removes the session.
|
|
func (s *FlowSessionStore) CompleteSession(ctx context.Context, sessionID, slug string) ([]SerializedCookie, error) {
|
|
cookies, _, err := s.CompleteSessionOAuth(ctx, sessionID, slug)
|
|
return cookies, err
|
|
}
|
|
|
|
// CompleteSessionOAuth returns the completed flow's cookies plus any captured OIDC callback URL
|
|
// (carrying the authorization code) and removes the session.
|
|
func (s *FlowSessionStore) CompleteSessionOAuth(ctx context.Context, sessionID, slug string) ([]SerializedCookie, string, 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
|
|
callback := entry.oauthCallback
|
|
s.delete(ctx, sessionID)
|
|
return cookies, callback, 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,
|
|
OAuthCallback: entry.oauthCallback,
|
|
}
|
|
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,
|
|
oauthCallback: stored.OAuthCallback,
|
|
}
|
|
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
|
|
}
|