session.idle_timeout (minutes, default 15) joins the app-config registry and drives the onboarding Garmin session eviction, distinct from session.duration (the login cookie lifetime in hours). The raw Keycloak ID token no longer rides in auth.Claims through every request context: it's minted into the session cookie separately and read back only by the logout handler via IDTokenFromSessionCookie. OnboardingWizard uses the type-imported FormEvent<HTMLFormElement> instead of the React.FormEvent namespace alias. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
136 lines
5.3 KiB
Go
136 lines
5.3 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
|
|
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.FromContext(r.Context()).Warn("session callback: 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.FromContext(r.Context()).Warn("session callback: 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.FromContext(r.Context()).Error("session 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)
|
|
}
|