go-bananas/internal/httpapi/auth_handlers.go

283 lines
10 KiB
Go
Raw Permalink Normal View History

2026-10-07 15:11:35 +02:00
package httpapi
import (
"context"
"errors"
"net/http"
"time"
"github.com/urpi/go-bananas/internal/auth"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/store"
"github.com/urpi/go-bananas/internal/tokenengine"
)
// AuthHandlers serves the browser-facing authentication routes and the session
// introspection the admin UI needs.
type AuthHandlers struct {
authenticator *auth.Authenticator
// store is used only to read the caller's own interface preferences, which
// are reported alongside the session so the UI can preselect a theme from the
// same round trip that establishes who the caller is. It is nil in tests that
// do not exercise preferences.
store *store.Store
log Logger
config config.Config
}
// NewAuthHandlers builds the auth handler set.
func NewAuthHandlers(authenticator *auth.Authenticator, st *store.Store, log Logger, cfg config.Config) *AuthHandlers {
return &AuthHandlers{authenticator: authenticator, store: st, log: log, config: cfg}
}
// Login starts the OIDC login flow.
func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
// Sanitised here as well as in the state store, so that the value that is
// reflected back to the provider-side cookie is already known to be local.
returnTo := auth.SafeReturnTo(r.URL.Query().Get("returnTo"))
if h.authenticator == nil || !h.authenticator.ModeConfigured() {
writeError(w, r, http.StatusNotFound, CodeNotConfigured,
"this deployment does not use interactive login", nil)
return
}
if err := h.authenticator.Begin(r.Context(), r, w, returnTo); err != nil {
h.log.Error("begin login", "error", err)
writeError(w, r, http.StatusBadGateway, CodeInternal,
"could not start the login flow with the identity provider", nil)
}
}
// Callback completes the OIDC login flow.
func (h *AuthHandlers) Callback(w http.ResponseWriter, r *http.Request) {
if h.authenticator == nil || !h.authenticator.ModeConfigured() {
writeError(w, r, http.StatusNotFound, CodeNotConfigured,
"this deployment does not use interactive login", nil)
return
}
login, err := h.authenticator.Complete(r.Context(), r)
if err != nil {
// A failed login is a normal occurrence, not a server fault: the user may
// have declined consent or the state cookie may have expired. Log it and
// show a clear message rather than a stack trace.
if errors.Is(err, domain.ErrUnauthorized) {
h.log.Info("login rejected", "reason", err.Error(), "ip", clientIPFrom(r.Context()))
writeError(w, r, http.StatusUnauthorized, CodeUnauthorized,
"the login could not be completed: "+cleanError(err), nil)
return
}
h.log.Error("complete login", "error", err)
writeError(w, r, http.StatusBadGateway, CodeInternal,
"the identity provider returned an unexpected response", nil)
return
}
// The login transaction is single-use; discard it so a replayed callback
// cannot reuse the PKCE verifier.
h.authenticator.ClearState(w)
if err := h.authenticator.IssueSession(r.Context(), w, login.Identity); err != nil {
h.log.Error("issue session", "error", err)
writeError(w, r, http.StatusInternalServerError, CodeInternal,
"could not establish a session", nil)
return
}
h.log.Info("login succeeded",
"subject", login.Identity.Subject,
"email", login.Identity.Email,
"roles", login.Identity.Roles,
"ip", clientIPFrom(r.Context()),
)
// The destination comes from the transaction cookie, which was sanitised when
// the login began and signed, so it cannot have been influenced by whatever
// the provider chose to append to the callback URL.
//nolint:gosec // G710: the value is not the raw request parameter. It is the
// sanitised destination the signed transaction cookie carried, and SafeReturnTo
// is the only way to produce one: it rejects absolute URLs, protocol-relative
// URLs, backslashes and control characters, and falls back to "/" otherwise.
http.Redirect(w, r, auth.SafeReturnTo(login.ReturnTo), http.StatusFound)
}
// Logout ends the session and, when an OIDC provider is configured, the provider
// session too.
func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) {
if h.authenticator != nil {
identity := mustIdentity(r.Context())
h.authenticator.ClearSession(w)
if identity.Method != "anonymous" {
h.log.Info("logout", "subject", identity.Subject, "ip", clientIPFrom(r.Context()))
}
}
if h.authenticator == nil || !h.authenticator.ProviderEnabled() {
http.Redirect(w, r, "/", http.StatusFound)
return
}
target := h.authenticator.LogoutURL(h.config.PostLogoutRedirectURL())
if target == "" {
http.Redirect(w, r, "/", http.StatusFound)
return
}
http.Redirect(w, r, target, http.StatusFound)
}
// Me returns the current session, if any.
//
// It always answers 200 with an explicit authenticated flag rather than 401 when
// there is no session, because "not logged in yet" is the normal state on first
// load and is not an error the UI should have to handle as one.
func (h *AuthHandlers) Me(w http.ResponseWriter, r *http.Request) {
identity, err := identityFrom(r.Context())
authenticated := err == nil && identity != nil && identity.Method != "anonymous"
payload := map[string]any{
"authenticated": authenticated,
"mode": h.authenticator.Mode(),
}
if authenticated {
payload["identity"] = identity
payload["permissions"] = permissionsFor(identity)
// Reported with the session because the UI needs it to decide what to
// paint on the first frame. A failure is reported as no choice rather than
// as an error: a theme is a preference, not something worth refusing a
// login over.
if h.store != nil {
if prefs, err := h.store.Preferences(r.Context(), identity.Subject); err != nil {
h.log.Warn("read preferences for session", "subject", identity.Subject, "error", err)
payload["preferences"] = domain.UserPreferences{}
} else {
payload["preferences"] = prefs
}
}
}
if h.authenticator != nil && h.authenticator.DevBypassEnabled() {
payload["devBypass"] = true
}
if h.authenticator != nil && h.authenticator.ProviderEnabled() {
// Deliberately this service's /auth/login rather than the provider's
// authorization endpoint. A direct link to the provider would skip the
// transaction cookie and the PKCE verifier, so the callback could not be
// validated and the login would fail.
payload["loginUrl"] = h.config.Path("/auth/login") + "?returnTo=%2F"
}
writeJSON(w, r, http.StatusOK, payload)
}
// Config returns the client-visible configuration the UI needs before login.
func (h *AuthHandlers) Config(w http.ResponseWriter, r *http.Request) {
payload := map[string]any{
"mode": h.authenticator.Mode(),
"issuer": h.config.OIDCIssuer,
"runtimeBase": h.config.CDNBasis(),
"publicBase": h.config.Origin(),
"basePath": h.config.BasePath,
"runtimeApiVersion": tokenengine.RuntimeAPIVersion,
"variablePrefix": tokenengine.VariablePrefix,
"defaultTheme": "",
}
writeJSON(w, r, http.StatusOK, payload)
}
// permissionsFor describes what the identity may do, so the UI can hide controls
// the API would reject anyway. The server still enforces every check: this is a
// convenience, not a security boundary.
func permissionsFor(identity *domain.Identity) map[string]bool {
role := identity.HighestRole()
return map[string]bool{
"read": role.AtLeast(domain.RoleViewer),
"write": role.AtLeast(domain.RoleEditor),
"publish": role.AtLeast(domain.RoleEditor),
"delete": role.AtLeast(domain.RoleAdmin),
"manageKeys": role.AtLeast(domain.RoleAdmin),
"manageUsers": role.AtLeast(domain.RoleAdmin),
"manageSettings": role.AtLeast(domain.RoleAdmin),
}
}
// -------------------------------------------------------- health probes --
// HealthHandlers serves the liveness and readiness probes.
type HealthHandlers struct {
store *store.Store
cache *ThemeCache
version string
config config.Config
// started is used to report uptime.
started time.Time
}
// NewHealthHandlers builds the probe handlers.
func NewHealthHandlers(st *store.Store, cache *ThemeCache, version string, cfg config.Config) *HealthHandlers {
return &HealthHandlers{
store: st,
cache: cache,
version: version,
config: cfg,
started: time.Now(),
}
}
// Live is the liveness probe. It answers as long as the process can serve HTTP at
// all, and deliberately does not touch the database: a liveness probe that failed
// on a slow database would have the orchestrator restart a process that is
// perfectly healthy.
func (h *HealthHandlers) Live(w http.ResponseWriter, r *http.Request) {
writeJSON(w, r, http.StatusOK, map[string]any{
"status": "ok",
"version": h.version,
"uptime": time.Since(h.started).Round(time.Second).String(),
})
}
// Ready is the readiness probe. It reports whether the service can actually serve
// traffic, which means the database must answer within a bounded time.
func (h *HealthHandlers) Ready(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
checks := map[string]any{}
ready := true
if err := h.store.Ping(ctx); err != nil {
ready = false
checks["database"] = map[string]any{"status": "error", "error": cleanError(err)}
} else {
entry := map[string]any{"status": "ok"}
if h.store.InMemory() {
// An in-memory database is legitimate in a test or a throwaway
// container but never in production, so it is surfaced loudly rather
// than silently accepted.
entry["warning"] = "database is in-memory; all data is lost on restart"
}
checks["database"] = entry
}
if stats, err := h.store.Stats(ctx); err == nil {
checks["themes"] = map[string]any{
"status": "ok",
"count": stats.Themes,
"databaseBytes": stats.DatabaseSize,
}
}
if h.cache != nil {
checks["cache"] = map[string]any{"status": "ok", "entries": h.cache.Stats().Entries}
}
status := http.StatusOK
state := "ready"
if !ready {
status = http.StatusServiceUnavailable
state = "not ready"
}
writeJSON(w, r, status, map[string]any{"status": state, "checks": checks})
}