Compare commits

..

4 Commits

Author SHA1 Message Date
65a2dc90d0 refactored component names 2026-07-26 10:00:24 +02:00
3b2b5d0735 refined ideas + start.sh rely on .env 2026-07-26 09:59:56 +02:00
d308201803 fix(api): logout's post_logout_redirect_uri uses FrontendURL, not BackendURL
Same class of bug as the OIDC callback fix: handleSessionLogout redirected
Keycloak's end-session flow back to BackendURL+"/", which 404s in a
split-origin deployment (the backend serves no "/" route). Flagged as a
known-deferred question in the callback-redirect design spec; fixing it
now that it's been hit in practice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 23:37:22 +02:00
099e746119 refactor(config): rename GENIUSRUN_PUBLIC_BASE_URL to GENIUSRUN_BACKEND_URL
Now that GENIUSRUN_FRONTEND_URL exists as a separate config value, keeping
the backend's own origin named "PublicBaseURL" invited exactly the kind of
mixup that caused the OIDC callback 404 in the first place. Renamed
consistently: env var, Config.BackendURL, api.SessionConfig.BackendURL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 23:00:35 +02:00
11 changed files with 66 additions and 60 deletions

View File

@@ -36,7 +36,7 @@ Beyond the login gate, every authenticated+authorized OIDC subject maps 1:1 to i
A brand-new OIDC subject with no `users` row yet is routed to a "Create your profile" screen (`frontend/src/CreateProfile.tsx`) instead of the app. `POST /api/setup` (display name only) provisions it — a `users` row, a default `profile` row, the 8-kind workout taxonomy, and an initial `sync_state` row, all in one transaction (`store.ProvisionUser`). Garmin credentials, HR zones, etc. are filled in afterward via the normal Profile screen, same as any fresh install. A brand-new OIDC subject with no `users` row yet is routed to a "Create your profile" screen (`frontend/src/CreateProfile.tsx`) instead of the app. `POST /api/setup` (display name only) provisions it — a `users` row, a default `profile` row, the 8-kind workout taxonomy, and an initial `sync_state` row, all in one transaction (`store.ProvisionUser`). Garmin credentials, HR zones, etc. are filled in afterward via the normal Profile screen, same as any fresh install.
Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_PUBLIC_BASE_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_OIDC_REQUIRED_ROLE`, `GENIUSRUN_SESSION_DURATION` (default `720h`). See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer). Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_BACKEND_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_FRONTEND_URL` (defaults to `GENIUSRUN_BACKEND_URL` -- set this when the frontend and backend are different origins, e.g. local dev), `GENIUSRUN_OIDC_REQUIRED_ROLE`, `GENIUSRUN_SESSION_DURATION` (default `720h`). See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer).
## Repo layout ## Repo layout

View File

@@ -51,7 +51,7 @@ func main() {
Secret: cfg.SessionSecret, Secret: cfg.SessionSecret,
Duration: cfg.SessionDuration, Duration: cfg.SessionDuration,
Secure: cfg.SessionSecure, Secure: cfg.SessionSecure,
PublicBaseURL: cfg.PublicBaseURL, BackendURL: cfg.BackendURL,
FrontendURL: cfg.FrontendURL, FrontendURL: cfg.FrontendURL,
}) })

View File

@@ -27,7 +27,7 @@ var testSessionConfig = SessionConfig{
Secret: []byte("test-session-secret-at-least-32-bytes-long"), Secret: []byte("test-session-secret-at-least-32-bytes-long"),
Duration: time.Hour, Duration: time.Hour,
Secure: false, Secure: false,
PublicBaseURL: "https://geniusrun.example.com", BackendURL: "https://geniusrun.example.com",
FrontendURL: "https://app.geniusrun.example.com", FrontendURL: "https://app.geniusrun.example.com",
} }
@@ -923,3 +923,13 @@ func TestSessionLogout_ClearsSessionCookieAndRedirectsToEndSession(t *testing.T)
t.Fatalf("expected session cookie to be cleared, got %+v", cleared) t.Fatalf("expected session cookie to be cleared, got %+v", cleared)
} }
} }
func TestSessionLogout_PostLogoutRedirectUsesFrontendURL(t *testing.T) {
verifier := &authmock.Verifier{} // EndSessionResult unset: echoes back postLogoutRedirectURL unchanged
s, _ := newTestServerWithAuth(t, verifier)
rec := doJSON(t, s.Router(), http.MethodPost, "/api/session/logout", nil)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != testSessionConfig.FrontendURL+"/" {
t.Fatalf("status = %d, Location = %q, want post_logout_redirect_uri = %q", rec.Code, rec.Header().Get("Location"), testSessionConfig.FrontendURL+"/")
}
}

View File

@@ -15,15 +15,19 @@ type SessionConfig struct {
Secret []byte Secret []byte
Duration time.Duration Duration time.Duration
Secure bool Secure bool
// PublicBaseURL is this app's own externally reachable origin (e.g. // BackendURL is this app's own externally reachable origin (e.g.
// "https://geniusrun.example.com", no trailing slash), used to build an // "https://geniusrun.example.com", no trailing slash) -- derives
// absolute post_logout_redirect_uri for the identity provider -- some // OIDCRedirectURL (config.Config), the only thing that must stay pointed
// providers, including Keycloak, require this to be an absolute URL // at the backend itself, since that's where /api/session/callback is
// matching one registered on the client, not a bare relative path. // actually served.
PublicBaseURL string BackendURL string
// FrontendURL is the origin the browser should land on after the OIDC // FrontendURL is the origin the browser should land on after any
// callback (success or failure) -- see config.Config.FrontendURL for why // user-facing redirect: the OIDC callback (success or failure) and the
// this can differ from PublicBaseURL in a split-origin deployment. // 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 FrontendURL string
} }
@@ -87,7 +91,7 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure)) http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure))
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.PublicBaseURL+"/"), http.StatusFound) http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.FrontendURL+"/"), http.StatusFound)
} }
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {

View File

@@ -38,20 +38,20 @@ type Config struct {
MinConfidence float64 MinConfidence float64
IncrementalSyncEvery time.Duration IncrementalSyncEvery time.Duration
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally // OIDC login gate (Keycloak). BackendURL is this app's own externally
// reachable origin (e.g. "https://geniusrun.example.com") -- it derives // reachable origin (e.g. "https://geniusrun.example.com") -- it derives
// OIDCRedirectURL and whether session cookies can be marked Secure, // OIDCRedirectURL and whether session cookies can be marked Secure,
// instead of requiring both to be configured separately and risking them // instead of requiring both to be configured separately and risking them
// drifting out of sync. // drifting out of sync.
PublicBaseURL string BackendURL string
// FrontendURL is the origin the browser should land on after the OIDC // FrontendURL is the origin the browser should land on after the OIDC
// callback (both success and failure) -- e.g. "http://localhost:5173" in // callback (both success and failure) -- e.g. "http://localhost:5173" in
// local dev, where the frontend and backend are different origins // local dev, where the frontend and backend are different origins
// bridged by CORS (see internal/api's corsMiddleware and // bridged by CORS (see internal/api's corsMiddleware and
// frontend/src/api/client.ts's BASE_URL). Defaults to PublicBaseURL when // frontend/src/api/client.ts's BASE_URL). Defaults to BackendURL when
// unset, which is correct for the common production topology where a // unset, which is correct for the common production topology where a
// reverse proxy unifies frontend and backend under one origin. // reverse proxy unifies frontend and backend under one origin.
// PublicBaseURL itself must stay pointed at the backend's own origin // BackendURL itself must stay pointed at the backend's own origin
// regardless -- it derives OIDCRedirectURL and post_logout_redirect_uri, // regardless -- it derives OIDCRedirectURL and post_logout_redirect_uri,
// which must match wherever those routes are actually served. // which must match wherever those routes are actually served.
FrontendURL string FrontendURL string
@@ -75,7 +75,7 @@ func Load() (Config, error) {
GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"), GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"),
MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6), MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6),
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour), IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
PublicBaseURL: strings.TrimRight(os.Getenv("GENIUSRUN_PUBLIC_BASE_URL"), "/"), BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"),
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"), OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"), OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"), OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
@@ -88,10 +88,10 @@ func Load() (Config, error) {
log.Printf("GARMIN_TOKENSTORE not set, defaulting to %q", cfg.GarminTokenStoreRoot) log.Printf("GARMIN_TOKENSTORE not set, defaulting to %q", cfg.GarminTokenStoreRoot)
} }
if cfg.PublicBaseURL == "" { if cfg.BackendURL == "" {
return cfg, fmt.Errorf("GENIUSRUN_PUBLIC_BASE_URL is required (e.g. https://geniusrun.example.com)") return cfg, fmt.Errorf("GENIUSRUN_BACKEND_URL is required (e.g. https://geniusrun.example.com)")
} }
cfg.FrontendURL = strings.TrimRight(getEnvDefault("GENIUSRUN_FRONTEND_URL", cfg.PublicBaseURL), "/") cfg.FrontendURL = strings.TrimRight(getEnvDefault("GENIUSRUN_FRONTEND_URL", cfg.BackendURL), "/")
if cfg.OIDCIssuerURL == "" { if cfg.OIDCIssuerURL == "" {
return cfg, fmt.Errorf("GENIUSRUN_OIDC_ISSUER_URL is required") return cfg, fmt.Errorf("GENIUSRUN_OIDC_ISSUER_URL is required")
} }
@@ -106,8 +106,8 @@ func Load() (Config, error) {
return cfg, fmt.Errorf("GENIUSRUN_SESSION_SECRET is required and must be at least 32 characters") return cfg, fmt.Errorf("GENIUSRUN_SESSION_SECRET is required and must be at least 32 characters")
} }
cfg.SessionSecret = []byte(sessionSecret) cfg.SessionSecret = []byte(sessionSecret)
cfg.OIDCRedirectURL = cfg.PublicBaseURL + "/api/session/callback" cfg.OIDCRedirectURL = cfg.BackendURL + "/api/session/callback"
cfg.SessionSecure = strings.HasPrefix(cfg.PublicBaseURL, "https://") cfg.SessionSecure = strings.HasPrefix(cfg.BackendURL, "https://")
return cfg, nil return cfg, nil
} }

View File

@@ -8,14 +8,14 @@ import (
func setRequiredEnv(t *testing.T) { func setRequiredEnv(t *testing.T) {
t.Helper() t.Helper()
t.Setenv("GENIUSRUN_PUBLIC_BASE_URL", "https://geniusrun.example.com") t.Setenv("GENIUSRUN_BACKEND_URL", "https://geniusrun.example.com")
t.Setenv("GENIUSRUN_OIDC_ISSUER_URL", "https://keycloak.example.com/realms/myrealm") t.Setenv("GENIUSRUN_OIDC_ISSUER_URL", "https://keycloak.example.com/realms/myrealm")
t.Setenv("GENIUSRUN_OIDC_CLIENT_ID", "geniusrun") t.Setenv("GENIUSRUN_OIDC_CLIENT_ID", "geniusrun")
t.Setenv("GENIUSRUN_OIDC_CLIENT_SECRET", "client-secret") t.Setenv("GENIUSRUN_OIDC_CLIENT_SECRET", "client-secret")
t.Setenv("GENIUSRUN_SESSION_SECRET", "a-session-secret-that-is-at-least-32-bytes-long") t.Setenv("GENIUSRUN_SESSION_SECRET", "a-session-secret-that-is-at-least-32-bytes-long")
} }
func TestLoad_DerivesOIDCSettingsFromPublicBaseURL(t *testing.T) { func TestLoad_DerivesOIDCSettingsFromBackendURL(t *testing.T) {
setRequiredEnv(t) setRequiredEnv(t)
cfg, err := Load() cfg, err := Load()
@@ -38,7 +38,7 @@ func TestLoad_DerivesOIDCSettingsFromPublicBaseURL(t *testing.T) {
func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) { func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
setRequiredEnv(t) setRequiredEnv(t)
t.Setenv("GENIUSRUN_PUBLIC_BASE_URL", "http://localhost:8080") t.Setenv("GENIUSRUN_BACKEND_URL", "http://localhost:8080")
cfg, err := Load() cfg, err := Load()
if err != nil { if err != nil {
@@ -54,7 +54,7 @@ func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
func TestLoad_MissingRequiredOIDCVars(t *testing.T) { func TestLoad_MissingRequiredOIDCVars(t *testing.T) {
cases := []string{ cases := []string{
"GENIUSRUN_PUBLIC_BASE_URL", "GENIUSRUN_BACKEND_URL",
"GENIUSRUN_OIDC_ISSUER_URL", "GENIUSRUN_OIDC_ISSUER_URL",
"GENIUSRUN_OIDC_CLIENT_ID", "GENIUSRUN_OIDC_CLIENT_ID",
"GENIUSRUN_OIDC_CLIENT_SECRET", "GENIUSRUN_OIDC_CLIENT_SECRET",
@@ -134,7 +134,7 @@ func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
} }
} }
func TestLoad_FrontendURLDefaultsToPublicBaseURL(t *testing.T) { func TestLoad_FrontendURLDefaultsToBackendURL(t *testing.T) {
setRequiredEnv(t) setRequiredEnv(t)
t.Setenv("GENIUSRUN_FRONTEND_URL", "") t.Setenv("GENIUSRUN_FRONTEND_URL", "")
@@ -142,8 +142,8 @@ func TestLoad_FrontendURLDefaultsToPublicBaseURL(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
if cfg.FrontendURL != cfg.PublicBaseURL { if cfg.FrontendURL != cfg.BackendURL {
t.Errorf("FrontendURL = %q, want it to default to PublicBaseURL %q", cfg.FrontendURL, cfg.PublicBaseURL) t.Errorf("FrontendURL = %q, want it to default to BackendURL %q", cfg.FrontendURL, cfg.BackendURL)
} }
} }

View File

@@ -2,17 +2,8 @@
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
# GARMIN_WRAPPER_PYTHON is optional (config.Load() defaults it to "python3" set -a
# on PATH). Export it yourself only if you want a specific interpreter, e.g. source .env
# a local venv with garminconnect installed. set +a
export GENIUSRUN_DB_PATH="${GENIUSRUN_DB_PATH:-geniusrun.db}"
# OIDC login gate -- no safe default exists for a client secret or a
# session-signing key, so these must come from your own shell/CI secrets.
export GENIUSRUN_PUBLIC_BASE_URL="${GENIUSRUN_PUBLIC_BASE_URL:?set GENIUSRUN_PUBLIC_BASE_URL, e.g. https://geniusrun.example.com}"
export GENIUSRUN_OIDC_ISSUER_URL="${GENIUSRUN_OIDC_ISSUER_URL:?set GENIUSRUN_OIDC_ISSUER_URL, e.g. https://keycloak.example.com/realms/myrealm}"
export GENIUSRUN_OIDC_CLIENT_ID="${GENIUSRUN_OIDC_CLIENT_ID:?set GENIUSRUN_OIDC_CLIENT_ID}"
export GENIUSRUN_OIDC_CLIENT_SECRET="${GENIUSRUN_OIDC_CLIENT_SECRET:?set GENIUSRUN_OIDC_CLIENT_SECRET}"
export GENIUSRUN_SESSION_SECRET="${GENIUSRUN_SESSION_SECRET:?set GENIUSRUN_SESSION_SECRET to a random string >=32 characters}"
exec go run ./cmd/geniusrund exec go run ./cmd/geniusrund

View File

@@ -8,9 +8,10 @@ for that history) and remove it from here once a spec exists.
## Backlog ## Backlog
- add dedicated workouts in database (instead of hard linking them in activities) - improve first connection page: in CreateProfile.tsx, in addition to the display name, we should directly ask garmin login/password and manage MFA from there, so the synchronization experience in profile page will be easier later on
- better UX for MFA management - profile deletion: add a way to delete our profile with a dedicated button on the profile page (which by extension will also logout the user, to be consistent with the logic that login will trigger the profile creation)
- better UX for activities/workout download (progress bar, error management) - dedicated workouts section: add workouts table in database and create a dedicated page in UI (use an "objective" icon)
- improve activities/workouts download: make it modal, add progress bar, error management
- better UX when backend is not available (instead of "TypeError failed to fetch") - better UX when backend is not available (instead of "TypeError failed to fetch")
- better UX for activities (list layout without the graphs, detailed view with different graphs for each phase incl. delta with workout) - better UX for activities (list layout without the graphs, detailed view with different graphs for each phase incl. delta with workout)
- add icon to indicate if an activity has an associated workout - add icon to indicate if an activity has an associated workout

View File

@@ -1,15 +1,15 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, BASE_URL } from "./api/client"; import { api, BASE_URL } from "./api/client";
import "./App.css"; import "./App.css";
import { Dashboard } from "./pages/Dashboard"; import { Activities } from "./pages/Activities";
import { Analysis } from "./pages/Analysis";
import { Plan } from "./pages/Plan"; import { Plan } from "./pages/Plan";
import { Profile } from "./pages/Profile"; import { Profile } from "./pages/Profile";
import { ReviewQueue } from "./pages/ReviewQueue";
import type { SessionInfo } from "./types/api"; import type { SessionInfo } from "./types/api";
const TABS = [ const TABS = [
{ key: "activities", label: "Activities", icon: "☰", Component: ReviewQueue }, { key: "activities", label: "Activities", icon: "☰", Component: Activities },
{ key: "dashboard", label: "Progression", icon: "", Component: Dashboard }, { key: "analysis", label: "Analysis", icon: "🔍", Component: Analysis },
{ key: "plan", label: "Training plan", icon: "✎", Component: Plan }, { key: "plan", label: "Training plan", icon: "✎", Component: Plan },
] as const; ] as const;
@@ -19,7 +19,7 @@ function App({ session }: { session: SessionInfo }) {
const [tab, setTab] = useState<TabKey>("activities"); const [tab, setTab] = useState<TabKey>("activities");
// Profile isn't a tab: it's reached via the profile name in the top-right // Profile isn't a tab: it's reached via the profile name in the top-right
// corner instead, since (for now, single-profile) it's account settings, // corner instead, since (for now, single-profile) it's account settings,
// not a content view alongside Activities/Progression/Training plan. // not a content view alongside Activities/Analysis/Training plan.
const [showProfile, setShowProfile] = useState(false); const [showProfile, setShowProfile] = useState(false);
const [profileName, setProfileName] = useState<string | null>(null); const [profileName, setProfileName] = useState<string | null>(null);

View File

@@ -155,7 +155,7 @@ function matchesFilter(item: ReviewQueueItem, filter: string): boolean {
return item.WorkoutKindID === Number(filter); return item.WorkoutKindID === Number(filter);
} }
export function ReviewQueue() { export function Activities() {
const [items, setItems] = useState<ReviewQueueItem[]>([]); const [items, setItems] = useState<ReviewQueueItem[]>([]);
const [grandTotal, setGrandTotal] = useState(0); // unfiltered count, for the "All" pill const [grandTotal, setGrandTotal] = useState(0); // unfiltered count, for the "All" pill
const [nextCursor, setNextCursor] = useState<string | null>(null); const [nextCursor, setNextCursor] = useState<string | null>(null);

View File

@@ -12,7 +12,7 @@ const METRICS: { key: ProgressionMetric; label: string }[] = [
{ key: "efficiency_factor", label: "Efficiency Factor" }, { key: "efficiency_factor", label: "Efficiency Factor" },
]; ];
export function Dashboard() { export function Analysis() {
const [kinds, setKinds] = useState<WorkoutKind[]>([]); const [kinds, setKinds] = useState<WorkoutKind[]>([]);
const [selectedKindId, setSelectedKindId] = useState<number | null>(null); const [selectedKindId, setSelectedKindId] = useState<number | null>(null);
const [metric, setMetric] = useState<ProgressionMetric>("pace"); const [metric, setMetric] = useState<ProgressionMetric>("pace");