Files
geniusrun/backend/internal/api/session.go
Christophe Vila 042579a396 refactor(store): single account name on users; rename profile/laps tables
users.display_name becomes users.name and is now the only human-facing
name -- profiles.name is dropped (it duplicated the account name; the
Profile page's Name field now edits users.name through PUT /api/profile,
which carries Name alongside the profiles columns). The profile table
becomes profiles and laps becomes activity_laps, homogeneous with
activity_samples. Onboarding pre-fills the display name from the OIDC
claim's name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:11:23 +02:00

127 lines
4.8 KiB
Go

package api
import (
"log"
"net/http"
"time"
"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 {
log.Printf("session callback: missing txn cookie: %v", 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 {
log.Printf("session callback: failed to parse txn cookie: %v", 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 {
log.Printf("session callback: HandleCallback failed (state mismatch, code exchange, or ID-token verification): %v", 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, 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 (see auth.Claims.IDToken) 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)
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.SessionConfig.Secure))
http.Redirect(w, r, s.Auth.EndSessionURL(s.SessionConfig.FrontendURL+"/", claims.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)
}