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