Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
142 lines
5.6 KiB
Go
142 lines
5.6 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
applog "geniusrun/backend/internal/log"
|
|
|
|
"geniusrun/backend/internal/auth"
|
|
)
|
|
|
|
// SessionConfig configures how the app-login session cookie is minted and
|
|
// validated. Secure should mirror config.Config.SessionSecure (true once
|
|
// the app is served over HTTPS).
|
|
type SessionConfig struct {
|
|
Secret []byte
|
|
Duration time.Duration
|
|
// SetupTimeout evicts an unfinished onboarding Garmin setup session
|
|
// once idle this long (app-config key session.setup_timeout, minutes;
|
|
// distinct from Duration, the login cookie lifetime). Mandatory --
|
|
// there is no code fallback; the default lives in the DB, seeded at
|
|
// startup.
|
|
SetupTimeout time.Duration
|
|
Secure bool
|
|
// BackendURL is this app's own externally reachable origin (e.g.
|
|
// "https://geniusrun.example.com", no trailing slash) -- derives
|
|
// OIDCRedirectURL (config.Config), the only thing that must stay pointed
|
|
// at the backend itself, since that's where /api/session/callback is
|
|
// actually served.
|
|
BackendURL string
|
|
// FrontendURL is the origin the browser should land on after any
|
|
// user-facing redirect: the OIDC callback (success or failure) and the
|
|
// post_logout_redirect_uri sent to the identity provider on logout. Some
|
|
// providers, including Keycloak, require an absolute URL matching one
|
|
// registered on the client, not a bare relative path -- see
|
|
// config.Config.FrontendURL for why this can differ from BackendURL in a
|
|
// split-origin deployment.
|
|
FrontendURL string
|
|
}
|
|
|
|
type sessionMeResponse struct {
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
HasProfile bool `json:"has_profile"`
|
|
DisplayName string `json:"display_name,omitempty"`
|
|
GarminConnected bool `json:"garmin_connected"`
|
|
}
|
|
|
|
func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
|
|
authURL, txn, err := s.Auth.BeginLogin()
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, err.Error())
|
|
return
|
|
}
|
|
cookie, err := auth.MintTxnCookie(txn, s.SessionConfig.Secret, s.SessionConfig.Secure)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
http.SetCookie(w, cookie)
|
|
http.Redirect(w, r, authURL, http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
|
txnCookie, err := r.Cookie(auth.TxnCookieName)
|
|
if err != nil {
|
|
applog.App("api.Server", "handleSessionCallback").Warn("missing txn cookie", "error", err)
|
|
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
|
return
|
|
}
|
|
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.SessionConfig.Secure))
|
|
|
|
txn, err := auth.ParseTxnCookie(txnCookie, s.SessionConfig.Secret)
|
|
if err != nil {
|
|
applog.App("api.Server", "handleSessionCallback").Warn("failed to parse txn cookie", "error", err)
|
|
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
|
|
if err != nil {
|
|
applog.App("api.Server", "handleSessionCallback").Error("callback failed (state mismatch, code exchange, or ID-token verification)", "error", err)
|
|
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
|
return
|
|
}
|
|
if !result.Authorized {
|
|
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=forbidden", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
sessionCookie, err := auth.MintSessionCookie(result.Claims, result.IDToken, s.SessionConfig.Secret, s.SessionConfig.Duration, s.SessionConfig.Secure)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
http.SetCookie(w, sessionCookie)
|
|
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/", http.StatusFound)
|
|
}
|
|
|
|
// handleSessionLogout clears geniusrun's own session cookie and redirects
|
|
// through Keycloak's end-session endpoint, passing the session's ID token
|
|
// as id_token_hint (read back from the cookie via
|
|
// auth.IDTokenFromSessionCookie -- it deliberately doesn't ride in Claims)
|
|
// so Keycloak can skip its own logout-confirmation prompt -- otherwise a
|
|
// user could cancel out of it and land back on the app with a Keycloak SSO
|
|
// session but no geniusrun profile (already deleted, in the
|
|
// profile-deletion case this exists for).
|
|
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
|
|
claims, _ := auth.ClaimsFromContext(r.Context())
|
|
s.removeSetupSession(claims.Sub)
|
|
// Best-effort: an unreadable cookie just means logging out without the
|
|
// hint, at worst showing Keycloak's own confirmation screen.
|
|
var idToken string
|
|
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil {
|
|
idToken, _ = auth.IDTokenFromSessionCookie(cookie, s.SessionConfig.Secret)
|
|
}
|
|
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.SessionConfig.Secure))
|
|
http.Redirect(w, r, s.Auth.EndSessionURL(s.SessionConfig.FrontendURL+"/", idToken), http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
|
if !ok {
|
|
// Unreachable in practice -- RequireSession already 401s before this
|
|
// handler runs -- but fail closed rather than panic if that ever changes.
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
resp := sessionMeResponse{Name: claims.Name, Email: claims.Email}
|
|
if u, found := userFromContext(r.Context()); found {
|
|
resp.HasProfile = true
|
|
resp.DisplayName = u.Name
|
|
profile, err := s.DB.GetProfile(r.Context(), u.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
resp.GarminConnected = profile.GarminConnectedAt != nil
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|