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"` } 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) } // 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()) http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure)) http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.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.DisplayName } writeJSON(w, http.StatusOK, resp) }