2026-07-24 21:36:00 +02:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/http"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-08-04 16:33:26 +02:00
|
|
|
applog "geniusrun/backend/internal/log"
|
|
|
|
|
|
2026-07-24 21:36:00 +02:00
|
|
|
"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
|
2026-08-04 18:17:24 +02:00
|
|
|
// 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
|
2026-07-25 23:00:35 +02:00
|
|
|
// BackendURL is this app's own externally reachable origin (e.g.
|
2026-07-25 23:37:22 +02:00
|
|
|
// "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.
|
2026-07-25 23:00:35 +02:00
|
|
|
BackendURL string
|
2026-07-25 23:37:22 +02:00
|
|
|
// 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.
|
2026-07-25 22:52:15 +02:00
|
|
|
FrontendURL string
|
2026-07-24 21:36:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type sessionMeResponse struct {
|
2026-07-26 12:23:39 +02:00
|
|
|
Name string `json:"name"`
|
|
|
|
|
Email string `json:"email"`
|
|
|
|
|
HasProfile bool `json:"has_profile"`
|
|
|
|
|
DisplayName string `json:"display_name,omitempty"`
|
|
|
|
|
GarminConnected bool `json:"garmin_connected"`
|
2026-07-24 21:36:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-08-04 16:04:18 +02:00
|
|
|
cookie, err := auth.MintTxnCookie(txn, s.SessionConfig.Secret, s.SessionConfig.Secure)
|
2026-07-24 21:36:00 +02:00
|
|
|
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 {
|
2026-08-04 19:59:48 +02:00
|
|
|
applog.App().Warn("missing txn cookie", "error", err)
|
2026-08-04 16:04:18 +02:00
|
|
|
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
2026-07-24 21:36:00 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-04 16:04:18 +02:00
|
|
|
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.SessionConfig.Secure))
|
2026-07-24 21:36:00 +02:00
|
|
|
|
2026-08-04 16:04:18 +02:00
|
|
|
txn, err := auth.ParseTxnCookie(txnCookie, s.SessionConfig.Secret)
|
2026-07-24 21:36:00 +02:00
|
|
|
if err != nil {
|
2026-08-04 19:59:48 +02:00
|
|
|
applog.App().Warn("failed to parse txn cookie", "error", err)
|
2026-08-04 16:04:18 +02:00
|
|
|
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
2026-07-24 21:36:00 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
|
|
|
|
|
if err != nil {
|
2026-08-04 19:59:48 +02:00
|
|
|
applog.App().Error("callback failed (state mismatch, code exchange, or ID-token verification)", "error", err)
|
2026-08-04 16:04:18 +02:00
|
|
|
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
2026-07-24 21:36:00 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !result.Authorized {
|
2026-08-04 16:04:18 +02:00
|
|
|
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=forbidden", http.StatusFound)
|
2026-07-24 21:36:00 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 17:44:37 +02:00
|
|
|
sessionCookie, err := auth.MintSessionCookie(result.Claims, result.IDToken, s.SessionConfig.Secret, s.SessionConfig.Duration, s.SessionConfig.Secure)
|
2026-07-24 21:36:00 +02:00
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
http.SetCookie(w, sessionCookie)
|
2026-08-04 16:04:18 +02:00
|
|
|
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/", http.StatusFound)
|
2026-07-24 21:36:00 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-26 11:55:13 +02:00
|
|
|
// handleSessionLogout clears geniusrun's own session cookie and redirects
|
|
|
|
|
// through Keycloak's end-session endpoint, passing the session's ID token
|
2026-08-04 17:44:37 +02:00
|
|
|
// 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).
|
2026-07-24 21:36:00 +02:00
|
|
|
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
|
2026-07-26 11:55:13 +02:00
|
|
|
claims, _ := auth.ClaimsFromContext(r.Context())
|
2026-07-26 13:30:51 +02:00
|
|
|
s.removeSetupSession(claims.Sub)
|
2026-08-04 17:44:37 +02:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-08-04 16:04:18 +02:00
|
|
|
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.SessionConfig.Secure))
|
2026-08-04 17:44:37 +02:00
|
|
|
http.Redirect(w, r, s.Auth.EndSessionURL(s.SessionConfig.FrontendURL+"/", idToken), http.StatusFound)
|
2026-07-24 21:36:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-07-25 17:55:13 +02:00
|
|
|
resp := sessionMeResponse{Name: claims.Name, Email: claims.Email}
|
|
|
|
|
if u, found := userFromContext(r.Context()); found {
|
|
|
|
|
resp.HasProfile = true
|
2026-08-04 16:11:23 +02:00
|
|
|
resp.DisplayName = u.Name
|
2026-07-26 12:23:39 +02:00
|
|
|
profile, err := s.DB.GetProfile(r.Context(), u.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
resp.GarminConnected = profile.GarminConnectedAt != nil
|
2026-07-25 17:55:13 +02:00
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
2026-07-24 21:36:00 +02:00
|
|
|
}
|