mirror of
https://github.com/AmanTahiliani/FHIR-Sandbox.git
synced 2026-08-07 19:56:17 -04:00
chore: remove DB and binary from repo, add .gitignore to exclude artifacts
This commit is contained in:
257
app/handlers/auth.go
Normal file
257
app/handlers/auth.go
Normal file
@@ -0,0 +1,257 @@
|
||||
// auth.go handles the OAuth2 authorization callback, token exchange,
|
||||
// FHIR resource fetching, user upsert, and session creation.
|
||||
//
|
||||
// Flow (continued from launch.go):
|
||||
// 1. EHR calls GET /auth-redirect?code=<auth_code>&state=<state_token>
|
||||
// 2. Recover launch context from the state store (validates state, prevents CSRF).
|
||||
// 3. Fetch the EHR's token endpoint from SMART discovery.
|
||||
// 4. Exchange the authorization code for an access token.
|
||||
// 5. Fetch the Patient FHIR resource using the access token.
|
||||
// 6. Resolve the practitioner from the token response. The SMART spec allows
|
||||
// the practitioner to appear in two places — we handle both:
|
||||
// a. tokenResp.Practitioner — a bare FHIR ID (some EHRs)
|
||||
// b. tokenResp.User — a relative reference "Practitioner/<id>" (SmartHealthIT)
|
||||
// 7. Upsert both users into the database.
|
||||
// 8. Create a server-side session for the HCP and set the session cookie.
|
||||
// 9. Render the patient dashboard.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
|
||||
)
|
||||
|
||||
// HandleAuthRedirect processes the SMART on FHIR authorization callback.
|
||||
// GET /auth-redirect?code=<authorization_code>&state=<state_token>
|
||||
func (h *Handler) HandleAuthRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("handlers: auth redirect query=%v", r.URL.Query())
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
if code == "" || state == "" {
|
||||
h.renderError(w, http.StatusBadRequest, "Missing code or state parameter.")
|
||||
return
|
||||
}
|
||||
|
||||
// Recover and validate the launch context from the server-side state store.
|
||||
// This is the CSRF protection — the state token is single-use and time-limited.
|
||||
lc, ok := globalStateStore.get(state)
|
||||
if !ok {
|
||||
h.renderError(w, http.StatusBadRequest, "Invalid or expired state parameter.")
|
||||
return
|
||||
}
|
||||
|
||||
// Confirm the EHR is still registered (config could theoretically change).
|
||||
ehrConfig := h.cfg.EHRByURL(lc.ISS)
|
||||
if ehrConfig == nil {
|
||||
h.renderError(w, http.StatusBadRequest, "Unregistered FHIR server.")
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch the SMART discovery document to get the token endpoint.
|
||||
smartCfg, err := fhir.GetSmartConfiguration(lc.ISS)
|
||||
if err != nil {
|
||||
log.Printf("handlers: SMART discovery failed for iss=%q: %v", lc.ISS, err)
|
||||
h.renderError(w, http.StatusBadGateway, "Unable to fetch SMART configuration.")
|
||||
return
|
||||
}
|
||||
|
||||
if smartCfg.TokenEndpoint == "" {
|
||||
h.renderError(w, http.StatusBadGateway, "SMART configuration missing token_endpoint.")
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange the authorization code for an access token.
|
||||
tokenResp, err := exchangeCode(
|
||||
smartCfg.TokenEndpoint,
|
||||
ehrConfig.ClientID,
|
||||
ehrConfig.ClientSecret,
|
||||
h.cfg.SMART.RedirectURL,
|
||||
code,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("handlers: token exchange failed: %v", err)
|
||||
h.renderError(w, http.StatusBadGateway, "Failed to exchange authorization code for token.")
|
||||
return
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken == "" {
|
||||
h.renderError(w, http.StatusBadGateway, "Token response missing access_token.")
|
||||
return
|
||||
}
|
||||
if tokenResp.Patient == "" {
|
||||
h.renderError(w, http.StatusBadGateway, "Token response missing patient context.")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("handlers: token response patient=%q practitioner=%q user=%q",
|
||||
tokenResp.Patient, tokenResp.Practitioner, tokenResp.User)
|
||||
|
||||
// Build a typed FHIR client for subsequent resource calls.
|
||||
fhirClient := fhir.NewClient(lc.ISS, tokenResp.AccessToken)
|
||||
|
||||
// --- Fetch and upsert the Patient ---
|
||||
patient, err := fhirClient.GetPatient(tokenResp.Patient)
|
||||
if err != nil {
|
||||
log.Printf("handlers: fetch Patient/%s failed: %v", tokenResp.Patient, err)
|
||||
h.renderError(w, http.StatusBadGateway, "Failed to fetch patient details.")
|
||||
return
|
||||
}
|
||||
|
||||
patientUser := fhir.ExtractUserFromPatient(patient, lc.ISS)
|
||||
patientInternalID, err := h.store.UpsertUser(patientUser)
|
||||
if err != nil {
|
||||
log.Printf("handlers: upsert patient failed: %v", err)
|
||||
h.renderError(w, http.StatusInternalServerError, "Failed to persist patient record.")
|
||||
return
|
||||
}
|
||||
log.Printf("handlers: upserted patient fhir_id=%s internal_id=%s", patient.ID, patientInternalID)
|
||||
|
||||
// --- Resolve the practitioner FHIR ID ---
|
||||
// The SMART spec allows the practitioner to be communicated in several ways:
|
||||
// 1. tokenResp.Practitioner — a bare FHIR resource ID
|
||||
// 2. tokenResp.User — a relative reference (e.g. "Practitioner/123")
|
||||
// 3. id_token.fhirUser — a relative or absolute URL (OIDC standard)
|
||||
//
|
||||
// We check them in order of specificity.
|
||||
practitionerFHIRID := tokenResp.Practitioner
|
||||
if practitionerFHIRID == "" {
|
||||
// Try the legacy "user" field.
|
||||
practitionerFHIRID = parsePractitionerFromUserField(tokenResp.User)
|
||||
}
|
||||
if practitionerFHIRID == "" && tokenResp.IDToken != "" {
|
||||
// Try the OIDC fhirUser claim.
|
||||
fhirUserClaim := fhir.ParseFHIRUserFromIDToken(tokenResp.IDToken)
|
||||
log.Printf("handlers: inspecting id_token fhirUser=%q", fhirUserClaim)
|
||||
practitionerFHIRID = parsePractitionerFromUserField(fhirUserClaim)
|
||||
}
|
||||
|
||||
// --- Resolve the Practitioner or fallback to Patient for the session ---
|
||||
var sessionUserID string
|
||||
var practitionerUser *models.User
|
||||
|
||||
if practitionerFHIRID != "" {
|
||||
practitioner, err := fhirClient.GetPractitioner(practitionerFHIRID)
|
||||
if err != nil {
|
||||
// Non-fatal: log and continue.
|
||||
log.Printf("handlers: fetch Practitioner/%s failed (non-fatal): %v", practitionerFHIRID, err)
|
||||
} else {
|
||||
practitionerUser = fhir.ExtractUserFromPractitioner(practitioner, lc.ISS)
|
||||
practInternalID, err := h.store.UpsertUser(practitionerUser)
|
||||
if err != nil {
|
||||
log.Printf("handlers: upsert practitioner failed: %v", err)
|
||||
h.renderError(w, http.StatusInternalServerError, "Failed to persist practitioner record.")
|
||||
return
|
||||
}
|
||||
log.Printf("handlers: upserted practitioner fhir_id=%s internal_id=%s", practitioner.ID, practInternalID)
|
||||
sessionUserID = practInternalID
|
||||
}
|
||||
}
|
||||
|
||||
// If no practitioner was resolved, fallback to the patient's identity to
|
||||
// establish a session (common in patient-facing or testing flows).
|
||||
if sessionUserID == "" {
|
||||
log.Printf("handlers: no practitioner identity found — falling back to patient identity for session")
|
||||
sessionUserID = patientInternalID
|
||||
practitionerUser = patientUser // For the UI to show who is "logged in"
|
||||
}
|
||||
|
||||
// --- Create session ---
|
||||
if sessionUserID != "" {
|
||||
sess, err := h.store.CreateSession(sessionUserID, tokenResp.Patient, tokenResp.AccessToken, tokenResp.IDToken, tokenResp.Scope, lc.ISS, 8*time.Hour)
|
||||
if err != nil {
|
||||
log.Printf("handlers: create session failed: %v", err)
|
||||
h.renderError(w, http.StatusInternalServerError, "Failed to create session.")
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: sess.ID,
|
||||
Path: "/",
|
||||
MaxAge: SessionTTL,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
log.Printf("handlers: session created id=%s for practitioner user_id=%s", sess.ID, sessionUserID)
|
||||
}
|
||||
|
||||
// --- Redirect to the stable dashboard ---
|
||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// parsePractitionerFromUserField extracts a bare Practitioner FHIR ID from a
|
||||
// SMART "user" claim or fhirUser OIDC claim.
|
||||
// The input may be:
|
||||
// - A bare ID (if the context implies it): "123"
|
||||
// - A relative reference: "Practitioner/123"
|
||||
// - An absolute FHIR URL: "https://ehr.com/fhir/Practitioner/123"
|
||||
// Returns an empty string if the value is not a Practitioner reference.
|
||||
func parsePractitionerFromUserField(user string) string {
|
||||
// If it's a URL, take the path part.
|
||||
if strings.HasPrefix(user, "http") {
|
||||
u, err := url.Parse(user)
|
||||
if err == nil {
|
||||
user = u.Path
|
||||
}
|
||||
}
|
||||
|
||||
// Remove leading slashes if any.
|
||||
user = strings.TrimLeft(user, "/")
|
||||
|
||||
const prefix = "Practitioner/"
|
||||
// We check for the prefix anywhere in the path to handle potential sub-paths.
|
||||
if idx := strings.Index(user, prefix); idx != -1 {
|
||||
return strings.TrimPrefix(user[idx:], prefix)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// exchangeCode performs the OAuth2 authorization_code token exchange.
|
||||
// Returns an error on any network failure or non-200 HTTP status.
|
||||
func exchangeCode(tokenEndpoint, clientID, clientSecret, redirectURI, code string) (*fhir.TokenResponse, error) {
|
||||
formData := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {redirectURI},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, tokenEndpoint, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(clientID, clientSecret)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read token response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp fhir.TokenResponse
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("decode token response: %w", err)
|
||||
}
|
||||
return &tokenResp, nil
|
||||
}
|
||||
89
app/handlers/dashboard.go
Normal file
89
app/handlers/dashboard.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/middleware"
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
|
||||
)
|
||||
|
||||
// HandleDashboard renders the stable patient dashboard.
|
||||
// All clinical data is read from the local database; no live FHIR calls are
|
||||
// made here. Use POST /dashboard/sync to refresh data from the EHR.
|
||||
//
|
||||
// GET /dashboard
|
||||
func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
sess := middleware.SessionFromContext(r.Context())
|
||||
practitionerUser := middleware.UserFromContext(r.Context())
|
||||
|
||||
if sess == nil || practitionerUser == nil {
|
||||
h.handleUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ehrURL := sess.EHRURL
|
||||
patientID := sess.PatientFHIRID
|
||||
|
||||
// Fetch patient demographics from the FHIR server. This is a cheap single
|
||||
// resource call and keeps the patient card always current.
|
||||
fhirClient := fhir.NewClient(ehrURL, sess.AccessToken)
|
||||
patient, err := fhirClient.GetPatient(patientID)
|
||||
if err != nil {
|
||||
log.Printf("handlers: dashboard fetch Patient/%s failed: %v", patientID, err)
|
||||
h.renderError(w, http.StatusBadGateway, "Failed to fetch patient details from the FHIR server.")
|
||||
return
|
||||
}
|
||||
patientUser := fhir.ExtractUserFromPatient(patient, ehrURL)
|
||||
|
||||
// Read clinical data from the local database.
|
||||
observations, err := h.store.ListObservations(patientID, ehrURL)
|
||||
if err != nil {
|
||||
log.Printf("handlers: dashboard ListObservations Patient/%s: %v", patientID, err)
|
||||
// Non-fatal; render with empty slice.
|
||||
}
|
||||
|
||||
conditions, err := h.store.ListConditions(patientID, ehrURL)
|
||||
if err != nil {
|
||||
log.Printf("handlers: dashboard ListConditions Patient/%s: %v", patientID, err)
|
||||
}
|
||||
|
||||
docRefs, err := h.store.ListDocumentReferences(patientID, ehrURL)
|
||||
if err != nil {
|
||||
log.Printf("handlers: dashboard ListDocumentReferences Patient/%s: %v", patientID, err)
|
||||
}
|
||||
|
||||
latestSync, err := h.store.LatestSync(patientID, ehrURL)
|
||||
if err != nil {
|
||||
log.Printf("handlers: dashboard LatestSync Patient/%s: %v", patientID, err)
|
||||
}
|
||||
|
||||
h.render(w, "dashboard.html", dashboardData{
|
||||
Patient: patientUser,
|
||||
Practitioner: practitionerUser,
|
||||
RawPatient: patient,
|
||||
Observations: observations,
|
||||
Conditions: conditions,
|
||||
DocumentReferences: docRefs,
|
||||
LatestSync: latestSync,
|
||||
Session: sess,
|
||||
})
|
||||
}
|
||||
|
||||
// dashboardData is the view model passed to the dashboard template.
|
||||
type dashboardData struct {
|
||||
Patient *models.User
|
||||
Practitioner *models.User
|
||||
RawPatient *fhir.Patient
|
||||
Observations []models.Observation
|
||||
Conditions []models.Condition
|
||||
DocumentReferences []models.DocumentReference
|
||||
LatestSync *models.PatientSync
|
||||
Session *models.Session
|
||||
}
|
||||
|
||||
// handleUnauthorized redirects to root for dashboard requests.
|
||||
func (h *Handler) handleUnauthorized(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
156
app/handlers/handler.go
Normal file
156
app/handlers/handler.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Package handlers contains all HTTP handler implementations for the platform.
|
||||
//
|
||||
// Handler design:
|
||||
// - All handlers are methods on *Handler, which aggregates all dependencies
|
||||
// (store, config, templateFS). This avoids package-level globals and makes
|
||||
// dependencies explicit and testable.
|
||||
// - Handlers do not perform FHIR API calls directly; they delegate to the
|
||||
// fhir package. This keeps HTTP concerns separate from FHIR protocol logic.
|
||||
// - Templates are parsed per-render as a (base.html + page.html) pair.
|
||||
// This is the correct Go html/template pattern for layout inheritance:
|
||||
// a single global template.Set with multiple files all defining "content"
|
||||
// blocks will have the last-parsed definition win, causing incorrect renders.
|
||||
// Per-render parsing is cheap (microseconds) and completely correct.
|
||||
// - Each handler file handles one logical concern:
|
||||
// handler.go — shared Handler type and constructor
|
||||
// launch.go — SMART EHR launch initiation
|
||||
// auth.go — OAuth2 callback, token exchange, user upsert, session creation
|
||||
// logout.go — session invalidation
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/config"
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/db"
|
||||
)
|
||||
|
||||
const (
|
||||
// SessionCookieName is the name of the HttpOnly session cookie.
|
||||
SessionCookieName = "session_id"
|
||||
|
||||
// SessionTTL is how long a session remains valid after SMART launch.
|
||||
SessionTTL = 8 * 60 * 60 // 8 hours in seconds
|
||||
)
|
||||
|
||||
// Handler is the central handler struct. All HTTP handlers are methods on it.
|
||||
// It holds all dependencies so they can be injected in tests.
|
||||
type Handler struct {
|
||||
store *db.Store
|
||||
cfg *config.AppConfig
|
||||
templateFS fs.FS
|
||||
funcMap template.FuncMap
|
||||
}
|
||||
|
||||
// New creates a Handler with all dependencies wired in.
|
||||
// templateFS must be an fs.FS rooted so that "base.html", "dashboard.html",
|
||||
// etc. are directly accessible (i.e. pass an fs.Sub of the embed.FS).
|
||||
func New(store *db.Store, cfg *config.AppConfig, templateFS fs.FS, funcMap template.FuncMap) *Handler {
|
||||
return &Handler{
|
||||
store: store,
|
||||
cfg: cfg,
|
||||
templateFS: templateFS,
|
||||
funcMap: funcMap,
|
||||
}
|
||||
}
|
||||
|
||||
// render parses base.html + the named page file and executes the combined
|
||||
// template set, using the page filename as the entry point.
|
||||
//
|
||||
// Go's html/template block/define system works correctly when each page
|
||||
// is parsed together with base.html in a fresh template.Template — the
|
||||
// page's {{define "content"}} overrides the {{block "content"}} in base.html
|
||||
// without conflicting with other pages' definitions.
|
||||
func (h *Handler) render(w http.ResponseWriter, page string, data interface{}) {
|
||||
tmpl, err := template.New("").Funcs(h.funcMap).ParseFS(h.templateFS, "base.html", page)
|
||||
if err != nil {
|
||||
log.Printf("handlers: parse template %q: %v", page, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
|
||||
log.Printf("handlers: execute template %q: %v", page, err)
|
||||
}
|
||||
}
|
||||
|
||||
// renderError writes a clean HTML error page.
|
||||
func (h *Handler) renderError(w http.ResponseWriter, code int, message string) {
|
||||
data := struct {
|
||||
Code int
|
||||
Message string
|
||||
}{code, message}
|
||||
tmpl, err := template.New("").Funcs(h.funcMap).ParseFS(h.templateFS, "base.html", "error.html")
|
||||
if err != nil {
|
||||
log.Printf("handlers: parse error template: %v", err)
|
||||
http.Error(w, message, code)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
|
||||
log.Printf("handlers: execute error template: %v", err)
|
||||
http.Error(w, message, code)
|
||||
}
|
||||
}
|
||||
|
||||
// generateState creates a cryptographically secure random state token.
|
||||
// This replaces the naive iss+launchID concatenation in the original code.
|
||||
func generateState() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// templateFuncs returns the custom template function map.
|
||||
// Defined here so it is available to both main.go (for wiring) and
|
||||
// handler tests.
|
||||
func TemplateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"formatDate": func(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return t.Format("January 2, 2006")
|
||||
},
|
||||
"formatDateTime": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return t.UTC().Format("Jan 2, 2006 15:04 UTC")
|
||||
},
|
||||
"derefFloat64": func(p *float64) float64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
},
|
||||
"titleCase": func(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
}
|
||||
if len(s) == 1 {
|
||||
return string(s[0] - 32)
|
||||
}
|
||||
return string(s[0]-32) + s[1:]
|
||||
},
|
||||
"orDash": func(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
}
|
||||
return s
|
||||
},
|
||||
}
|
||||
}
|
||||
177
app/handlers/launch.go
Normal file
177
app/handlers/launch.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// launch.go handles the SMART on FHIR EHR launch initiation sequence.
|
||||
//
|
||||
// Flow:
|
||||
// 1. EHR calls GET /launch?iss=<fhir_base>&launch=<opaque_token>
|
||||
// 2. This handler validates iss against the registered EHR list.
|
||||
// 3. Fetches the SMART discovery document (.well-known/smart-configuration).
|
||||
// 4. Generates a cryptographically secure state token.
|
||||
// 5. Stores launch context (iss + launch token) in a short-lived in-memory
|
||||
// map keyed by state, so the callback can recover context without
|
||||
// encoding sensitive values in the state URL parameter.
|
||||
// 6. Redirects the browser to the EHR's authorization_endpoint.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
|
||||
)
|
||||
|
||||
// launchState holds the OAuth2 launch context that must survive the
|
||||
// browser redirect round-trip. It is keyed by a random state token.
|
||||
type launchState struct {
|
||||
ISS string
|
||||
LaunchID string
|
||||
StoredAt time.Time
|
||||
UsedAt time.Time // Added to track when the state was first consumed.
|
||||
}
|
||||
|
||||
// stateStore is a short-lived, in-memory map from state token → launchState.
|
||||
// Entries are expired after 10 minutes to limit memory growth from abandoned
|
||||
// launch flows. In a multi-instance deployment this should be replaced with
|
||||
// a shared store (e.g., Redis).
|
||||
type stateStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]launchState
|
||||
}
|
||||
|
||||
var globalStateStore = &stateStore{
|
||||
entries: make(map[string]launchState),
|
||||
}
|
||||
|
||||
// set stores a launch state entry.
|
||||
func (ss *stateStore) set(token string, ls launchState) {
|
||||
ss.mu.Lock()
|
||||
defer ss.mu.Unlock()
|
||||
// Opportunistically evict stale entries on every write.
|
||||
ss.evict()
|
||||
ss.entries[token] = ls
|
||||
}
|
||||
|
||||
// get retrieves a state entry. Returns false if not found or expired.
|
||||
// Implements a 10-second grace period for duplicate requests (common in some
|
||||
// browsers/environments) after the first consumption.
|
||||
func (ss *stateStore) get(token string) (launchState, bool) {
|
||||
ss.mu.Lock()
|
||||
defer ss.mu.Unlock()
|
||||
ls, ok := ss.entries[token]
|
||||
if !ok {
|
||||
return launchState{}, false
|
||||
}
|
||||
|
||||
// If already used more than 10 seconds ago, consider it fully consumed.
|
||||
if !ls.UsedAt.IsZero() && time.Since(ls.UsedAt) > 10*time.Second {
|
||||
delete(ss.entries, token)
|
||||
return launchState{}, false
|
||||
}
|
||||
|
||||
if time.Since(ls.StoredAt) > 10*time.Minute {
|
||||
delete(ss.entries, token)
|
||||
return launchState{}, false
|
||||
}
|
||||
|
||||
// Mark as used but don't delete yet to allow for race conditions/double-requests.
|
||||
if ls.UsedAt.IsZero() {
|
||||
ls.UsedAt = time.Now()
|
||||
ss.entries[token] = ls
|
||||
}
|
||||
|
||||
return ls, true
|
||||
}
|
||||
|
||||
// evict removes entries older than 10 minutes. Must be called with ss.mu held.
|
||||
func (ss *stateStore) evict() {
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
for k, v := range ss.entries {
|
||||
if v.StoredAt.Before(cutoff) {
|
||||
delete(ss.entries, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRoot serves the application home page.
|
||||
func (h *Handler) HandleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
h.renderError(w, http.StatusNotFound, "Page not found.")
|
||||
return
|
||||
}
|
||||
h.render(w, "index.html", nil)
|
||||
}
|
||||
|
||||
// HandleLaunch processes the SMART EHR launch initiation.
|
||||
// GET /launch?iss=<fhir_base_url>&launch=<opaque_launch_token>
|
||||
func (h *Handler) HandleLaunch(w http.ResponseWriter, r *http.Request) {
|
||||
launchID := r.URL.Query().Get("launch")
|
||||
iss := r.URL.Query().Get("iss")
|
||||
|
||||
log.Printf("handlers: launch request iss=%q launch=%q", iss, launchID)
|
||||
|
||||
if launchID == "" || iss == "" {
|
||||
h.renderError(w, http.StatusBadRequest, "Missing required parameters: iss and launch.")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate iss is a well-formed URI before doing anything with it.
|
||||
if _, err := url.ParseRequestURI(iss); err != nil {
|
||||
h.renderError(w, http.StatusBadRequest, "Invalid FHIR server URL (iss).")
|
||||
return
|
||||
}
|
||||
|
||||
// Confirm this EHR is registered.
|
||||
ehrConfig := h.cfg.EHRByURL(iss)
|
||||
if ehrConfig == nil {
|
||||
log.Printf("handlers: unregistered EHR iss=%q", iss)
|
||||
h.renderError(w, http.StatusBadRequest, "Unregistered FHIR server.")
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch SMART discovery document.
|
||||
smartCfg, err := fhir.GetSmartConfiguration(iss)
|
||||
if err != nil {
|
||||
log.Printf("handlers: SMART discovery failed for iss=%q: %v", iss, err)
|
||||
h.renderError(w, http.StatusBadGateway, "Unable to fetch SMART configuration from FHIR server.")
|
||||
return
|
||||
}
|
||||
|
||||
if smartCfg.AuthorizationEndpoint == "" {
|
||||
h.renderError(w, http.StatusBadGateway, "SMART configuration missing authorization_endpoint.")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a secure random state token.
|
||||
state, err := generateState()
|
||||
if err != nil {
|
||||
log.Printf("handlers: state generation failed: %v", err)
|
||||
h.renderError(w, http.StatusInternalServerError, "Internal error.")
|
||||
return
|
||||
}
|
||||
|
||||
// Store the launch context server-side, keyed by state.
|
||||
globalStateStore.set(state, launchState{
|
||||
ISS: strings.TrimRight(iss, "/"),
|
||||
LaunchID: launchID,
|
||||
StoredAt: time.Now(),
|
||||
})
|
||||
|
||||
// Build the authorization URL.
|
||||
scopes := strings.Join(h.cfg.SMART.Scopes, " ")
|
||||
authURL := fmt.Sprintf(
|
||||
"%s?response_type=code&client_id=%s&redirect_uri=%s&launch=%s&scope=%s&state=%s&aud=%s",
|
||||
smartCfg.AuthorizationEndpoint,
|
||||
url.QueryEscape(ehrConfig.ClientID),
|
||||
url.QueryEscape(h.cfg.SMART.RedirectURL),
|
||||
url.QueryEscape(launchID),
|
||||
url.QueryEscape(scopes),
|
||||
url.QueryEscape(state),
|
||||
url.QueryEscape(iss),
|
||||
)
|
||||
|
||||
log.Printf("handlers: redirecting to authorization endpoint for EHR %q", ehrConfig.Name)
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
32
app/handlers/logout.go
Normal file
32
app/handlers/logout.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// logout.go handles session invalidation and user logout.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HandleLogout invalidates the current session and redirects to the home page.
|
||||
// POST /logout
|
||||
func (h *Handler) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(SessionCookieName)
|
||||
if err == nil && cookie.Value != "" {
|
||||
if delErr := h.store.DeleteSession(cookie.Value); delErr != nil {
|
||||
log.Printf("handlers: logout delete session %s: %v", cookie.Value, delErr)
|
||||
} else {
|
||||
log.Printf("handlers: logged out session %s", cookie.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the cookie in the browser regardless of DB outcome.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
98
app/handlers/sync.go
Normal file
98
app/handlers/sync.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/middleware"
|
||||
)
|
||||
|
||||
// HandleSync performs a live FHIR pull for Observations, Conditions, and
|
||||
// DocumentReferences for the session's patient, upserts all results into the
|
||||
// database, records a PatientSync event, then redirects back to GET /dashboard.
|
||||
//
|
||||
// POST /dashboard/sync
|
||||
func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
sess := middleware.SessionFromContext(r.Context())
|
||||
if sess == nil {
|
||||
h.handleUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ehrURL := sess.EHRURL
|
||||
patientID := sess.PatientFHIRID
|
||||
client := fhir.NewClient(ehrURL, sess.AccessToken)
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Fetch Observations
|
||||
// -----------------------------------------------------------------
|
||||
rawObs, err := client.GetObservations(patientID)
|
||||
if err != nil {
|
||||
log.Printf("handlers: sync GetObservations for Patient/%s: %v", patientID, err)
|
||||
// Non-fatal; continue with whatever we got.
|
||||
}
|
||||
|
||||
obsCount := 0
|
||||
for i := range rawObs {
|
||||
m := fhir.ExtractObservation(&rawObs[i], patientID, ehrURL)
|
||||
if _, err := h.store.UpsertObservation(m); err != nil {
|
||||
log.Printf("handlers: sync UpsertObservation fhir_id=%s: %v", m.FHIRID, err)
|
||||
continue
|
||||
}
|
||||
obsCount++
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Fetch Conditions
|
||||
// -----------------------------------------------------------------
|
||||
rawConds, err := client.GetConditions(patientID)
|
||||
if err != nil {
|
||||
log.Printf("handlers: sync GetConditions for Patient/%s: %v", patientID, err)
|
||||
}
|
||||
|
||||
condCount := 0
|
||||
for i := range rawConds {
|
||||
m := fhir.ExtractCondition(&rawConds[i], patientID, ehrURL)
|
||||
if _, err := h.store.UpsertCondition(m); err != nil {
|
||||
log.Printf("handlers: sync UpsertCondition fhir_id=%s: %v", m.FHIRID, err)
|
||||
continue
|
||||
}
|
||||
condCount++
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Fetch DocumentReferences
|
||||
// -----------------------------------------------------------------
|
||||
rawDocs, err := client.GetDocumentReferences(patientID)
|
||||
if err != nil {
|
||||
log.Printf("handlers: sync GetDocumentReferences for Patient/%s: %v", patientID, err)
|
||||
}
|
||||
|
||||
docCount := 0
|
||||
for i := range rawDocs {
|
||||
m := fhir.ExtractDocumentReference(&rawDocs[i], patientID, ehrURL)
|
||||
if _, err := h.store.UpsertDocumentReference(m); err != nil {
|
||||
log.Printf("handlers: sync UpsertDocumentReference fhir_id=%s: %v", m.FHIRID, err)
|
||||
continue
|
||||
}
|
||||
docCount++
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Record the sync event
|
||||
// -----------------------------------------------------------------
|
||||
if _, err := h.store.RecordSync(patientID, ehrURL, obsCount, condCount, docCount); err != nil {
|
||||
log.Printf("handlers: sync RecordSync Patient/%s: %v", patientID, err)
|
||||
}
|
||||
|
||||
log.Printf("handlers: sync complete for Patient/%s — obs=%d cond=%d docs=%d",
|
||||
patientID, obsCount, condCount, docCount)
|
||||
|
||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||
}
|
||||
Reference in New Issue
Block a user