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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user