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 // PublicBaseURL is this app's own externally reachable origin (e.g. // "https://geniusrun.example.com", no trailing slash), used to build an // absolute post_logout_redirect_uri for the identity provider -- some // providers, including Keycloak, require this to be an absolute URL // matching one registered on the client, not a bare relative path. PublicBaseURL string // FrontendURL is the origin the browser should land on after the OIDC // callback (success or failure) -- see config.Config.FrontendURL for why // this can differ from PublicBaseURL 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"` } 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.Session.Secret, s.Session.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.Session.FrontendURL+"/?auth_error=failed", http.StatusFound) return } http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.Session.Secure)) txn, err := auth.ParseTxnCookie(txnCookie, s.Session.Secret) if err != nil { log.Printf("session callback: failed to parse txn cookie: %v", err) http.Redirect(w, r, s.Session.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.Session.FrontendURL+"/?auth_error=failed", http.StatusFound) return } if !result.Authorized { http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=forbidden", http.StatusFound) return } sessionCookie, err := auth.MintSessionCookie(result.Claims, s.Session.Secret, s.Session.Duration, s.Session.Secure) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } http.SetCookie(w, sessionCookie) http.Redirect(w, r, s.Session.FrontendURL+"/", http.StatusFound) } func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure)) http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.PublicBaseURL+"/"), 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.DisplayName } writeJSON(w, http.StatusOK, resp) }