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}) }