ultisuite-backend/internal/api/auth/handlers.go
R3D347HR4Y 525edb188a
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
feat(authentik): enhance OIDC flow with new logout redirect and branding support
- 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.
2026-06-21 00:12:53 +02:00

219 lines
6.4 KiB
Go

package authapi
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/redis/go-redis/v9"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/authentik"
)
const (
FlowEnrollment = "ulti-enrollment"
FlowRecovery = "ulti-recovery"
FlowAuthentication = "default-authentication-flow"
flowSessionCookie = "ulti_flow_session"
flowSessionMaxAge = 20 * time.Minute
)
var allowedFlowSlugs = map[string]struct{}{
FlowEnrollment: {},
FlowRecovery: {},
FlowAuthentication: {},
}
// Handler exposes public Authentik flow executor endpoints for custom auth UI.
type Handler struct {
flows *authentik.FlowSessionStore
limiter *FlowRateLimiter
appURL string
oidcClientID string
oidc *oidcBridge
}
func NewHandler(baseURL, appURL, oidcIssuer, oidcClientID, oidcClientSecret string, rdb *redis.Client) *Handler {
return &Handler{
flows: authentik.NewFlowSessionStore(baseURL, rdb),
limiter: NewFlowRateLimiter(rdb),
appURL: appURL,
oidcClientID: strings.TrimSpace(oidcClientID),
oidc: newOIDCBridge(buildOIDCBridgeConfig(oidcIssuer, oidcClientID, oidcClientSecret, appURL, baseURL)),
}
}
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/flows/authentication/context", h.PrepareOAuthFlowContext)
r.Post("/flows/{slug}/start", h.StartFlow)
r.Post("/flows/{slug}/respond", h.RespondFlow)
r.Get("/flows/complete", h.CompleteFlow)
r.Post("/flows/complete", h.CompleteFlow)
return r
}
type flowStartResponse struct {
SessionID string `json:"sessionId"`
Challenge authentik.FlowChallenge `json:"challenge"`
Done bool `json:"done"`
Denied bool `json:"denied"`
}
type flowRespondRequest struct {
Payload map[string]any `json:"payload"`
}
func (h *Handler) StartFlow(w http.ResponseWriter, r *http.Request) {
if !h.limiter.Allow(r) {
apiresponse.WriteError(w, r, http.StatusTooManyRequests, apiresponse.CodeRateLimited, "too many flow start attempts", nil)
return
}
slug, ok := validateFlowSlug(w, r, chi.URLParam(r, "slug"))
if !ok {
return
}
query := strings.TrimSpace(r.URL.Query().Get("query"))
sessionID, challenge, err := h.flows.Start(r.Context(), slug, query)
if err != nil {
apiresponse.WriteError(w, r, http.StatusBadGateway, "flow_start_failed", err.Error(), nil)
return
}
done, denied := authentik.FlowDone(challenge)
if !done {
setFlowSessionCookie(w, sessionID)
if cookies, err := h.flows.SessionCookies(r.Context(), sessionID); err == nil {
forwardFlowCookies(w, r, cookies)
}
} else {
clearFlowSessionCookie(w)
}
writeFlowJSON(w, http.StatusOK, flowStartResponse{
SessionID: sessionID,
Challenge: challenge,
Done: done,
Denied: denied,
})
}
func (h *Handler) RespondFlow(w http.ResponseWriter, r *http.Request) {
slug, ok := validateFlowSlug(w, r, chi.URLParam(r, "slug"))
if !ok {
return
}
sessionID := readFlowSessionCookie(r)
if sessionID == "" {
apiresponse.WriteError(w, r, http.StatusUnauthorized, "flow_session_missing", "flow session cookie required", nil)
return
}
var req flowRespondRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, "invalid_json", "invalid request body", nil)
return
}
if req.Payload == nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, "invalid_payload", "payload required", nil)
return
}
query := strings.TrimSpace(r.URL.Query().Get("query"))
challenge, err := h.flows.Respond(r.Context(), sessionID, slug, query, req.Payload)
if err != nil {
if errors.Is(err, authentik.ErrFlowSessionNotFound) {
clearFlowSessionCookie(w)
apiresponse.WriteError(w, r, http.StatusGone, "flow_session_expired", "flow session expired; restart the flow", nil)
return
}
if errors.Is(err, authentik.ErrFlowSessionSlugMismatch) {
apiresponse.WriteError(w, r, http.StatusConflict, "flow_session_mismatch", "flow slug does not match active session", nil)
return
}
if errors.Is(err, authentik.ErrFlowSessionAlreadyCompleted) {
apiresponse.WriteError(w, r, http.StatusConflict, "flow_already_completed", "flow already completed", nil)
return
}
apiresponse.WriteError(w, r, http.StatusBadGateway, "flow_respond_failed", err.Error(), nil)
return
}
done, denied := authentik.FlowDone(challenge)
if done {
if slug == FlowAuthentication {
if denied {
clearFlowSessionCookie(w)
h.flows.Delete(r.Context(), sessionID)
} else if cookies, err := h.flows.SessionCookies(r.Context(), sessionID); err == nil {
// Push authenticated Authentik session to browser before OIDC bridge.
forwardFlowCookies(w, r, cookies)
}
} else {
clearFlowSessionCookie(w)
h.flows.Delete(r.Context(), sessionID)
}
} else if cookies, err := h.flows.SessionCookies(r.Context(), sessionID); err == nil {
forwardFlowCookies(w, r, cookies)
}
writeFlowJSON(w, http.StatusOK, flowStartResponse{
SessionID: sessionID,
Challenge: challenge,
Done: done,
Denied: denied,
})
}
func validateFlowSlug(w http.ResponseWriter, r *http.Request, slug string) (string, bool) {
slug = strings.TrimSpace(slug)
if slug == "" {
apiresponse.WriteError(w, r, http.StatusBadRequest, "invalid_flow", "flow slug required", nil)
return "", false
}
if _, ok := allowedFlowSlugs[slug]; !ok {
apiresponse.WriteError(w, r, http.StatusNotFound, "flow_not_allowed", "flow slug not allowed", nil)
return "", false
}
return slug, true
}
func setFlowSessionCookie(w http.ResponseWriter, sessionID string) {
http.SetCookie(w, &http.Cookie{
Name: flowSessionCookie,
Value: sessionID,
Path: "/api/v1/auth/flows",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(flowSessionMaxAge.Seconds()),
})
}
func clearFlowSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: flowSessionCookie,
Value: "",
Path: "/api/v1/auth/flows",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
func readFlowSessionCookie(r *http.Request) string {
c, err := r.Cookie(flowSessionCookie)
if err != nil || c == nil {
return ""
}
return strings.TrimSpace(c.Value)
}
func writeFlowJSON(w http.ResponseWriter, status int, payload flowStartResponse) {
apiresponse.WriteJSON(w, status, payload)
}