diff --git a/db.go b/db.go
index 0315da8..0ec6716 100644
--- a/db.go
+++ b/db.go
@@ -140,6 +140,11 @@ func migrate(db *sql.DB) error {
department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE,
PRIMARY KEY (participant_id, department_id)
);
+
+ CREATE TABLE IF NOT EXISTS sso_nonces (
+ nonce TEXT PRIMARY KEY,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
`)
return err
}
@@ -1350,6 +1355,27 @@ func (app *App) listOpenShiftsForDept(deptID int) ([]Shift, error) {
ORDER BY s.day, s.position, s.start_time`, deptID)
}
+// --- SSO Nonces ---
+
+func (app *App) createSSONonce(nonce string) error {
+ _, err := app.db.Exec(`INSERT INTO sso_nonces (nonce) VALUES (?)`, nonce)
+ return err
+}
+
+func (app *App) consumeSSONonce(nonce string) (bool, error) {
+ res, err := app.db.Exec(
+ `DELETE FROM sso_nonces WHERE nonce = ? AND created_at > datetime('now', '-10 minutes')`, nonce)
+ if err != nil {
+ return false, err
+ }
+ n, _ := res.RowsAffected()
+ return n > 0, nil
+}
+
+func (app *App) cleanExpiredNonces() {
+ app.db.Exec(`DELETE FROM sso_nonces WHERE created_at < datetime('now', '-10 minutes')`)
+}
+
// --- Helpers ---
func now() string {
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index a1fa253..f680143 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -1,6 +1,6 @@
@@ -45,5 +67,28 @@
{loading ? 'Signing in…' : 'Sign in'}
+ {#if ssoEnabled}
+
or
+
+ {ssoLoading ? 'Redirecting…' : 'Log in with Discourse'}
+
+ {/if}
+
+
diff --git a/frontend/src/pages/Settings.svelte b/frontend/src/pages/Settings.svelte
index 2f6ee8e..bc87738 100644
--- a/frontend/src/pages/Settings.svelte
+++ b/frontend/src/pages/Settings.svelte
@@ -26,6 +26,8 @@
let eventEndDate = $state('')
let eventTimezone = $state('')
const timezones = Intl.supportedValuesOf('timeZone')
+ let discourseSSOUrl = $state('')
+ let discourseSSOSecret = $state('')
let shiftSignupsOpen = $state(false)
let togglingSignups = $state(false)
@@ -49,6 +51,8 @@
baseURL = s.base_url ?? ''
noteLabel = s.volunteer_note_label ?? 'Additional note'
noteRequired = s.volunteer_note_required ?? false
+ discourseSSOUrl = s.discourse_sso_url ?? ''
+ discourseSSOSecret = ''
shiftSignupsOpen = s.shift_signups_open ?? false
} catch (err) {
error = err.message
@@ -89,14 +93,17 @@
smtp_host: smtpHost,
smtp_port: smtpPort,
smtp_user: smtpUser,
- smtp_password: smtpPassword, // empty = keep existing
+ smtp_password: smtpPassword,
smtp_from: smtpFrom,
smtp_from_name: smtpFromName,
base_url: baseURL,
volunteer_note_label: noteLabel,
volunteer_note_required: noteRequired,
+ discourse_sso_url: discourseSSOUrl,
+ discourse_sso_secret: discourseSSOSecret,
})
smtpPassword = ''
+ discourseSSOSecret = ''
success = 'Settings saved.'
} catch (err) {
error = err.message
@@ -240,6 +247,24 @@
+
+ Discourse SSO
+
+ Enable DiscourseConnect SSO so users can log in with their Discourse account.
+ Set the same secret in your Discourse admin under Connect > discourse connect secret.
+
+
+
{saving ? 'Saving…' : 'Save Settings'}
diff --git a/handle_settings.go b/handle_settings.go
index 2c0c991..5da8084 100644
--- a/handle_settings.go
+++ b/handle_settings.go
@@ -27,6 +27,14 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) {
noteLabel = "Additional note"
}
+ var ssoURL, ssoSecret string
+ app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_url'`).Scan(&ssoURL)
+ app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_secret'`).Scan(&ssoSecret)
+ maskedSSOSecret := ""
+ if ssoSecret != "" {
+ maskedSSOSecret = "***"
+ }
+
writeJSON(w, map[string]any{
"smtp_host": cfg.Host,
"smtp_port": cfg.Port,
@@ -38,6 +46,8 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) {
"volunteer_note_label": noteLabel,
"volunteer_note_required": noteRequired == "true",
"shift_signups_open": signupsOpen == "true",
+ "discourse_sso_url": ssoURL,
+ "discourse_sso_secret": maskedSSOSecret,
})
}
@@ -49,7 +59,7 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
}
keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url",
- "volunteer_note_label", "volunteer_note_required"}
+ "volunteer_note_label", "volunteer_note_required", "discourse_sso_url", "discourse_sso_secret"}
for _, k := range keys {
v, ok := body[k]
if !ok {
@@ -58,7 +68,7 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var val string
switch vv := v.(type) {
case string:
- if k == "smtp_password" && (vv == "" || vv == "***") {
+ if (k == "smtp_password" || k == "discourse_sso_secret") && (vv == "" || vv == "***") {
continue
}
val = vv
diff --git a/handle_sso.go b/handle_sso.go
new file mode 100644
index 0000000..e97c887
--- /dev/null
+++ b/handle_sso.go
@@ -0,0 +1,190 @@
+package main
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+func (app *App) getSSOConfig() (ssoURL, ssoSecret string) {
+ app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_url'`).Scan(&ssoURL)
+ app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_secret'`).Scan(&ssoSecret)
+ return
+}
+
+func (app *App) handleSSOEnabled(w http.ResponseWriter, r *http.Request) {
+ ssoURL, ssoSecret := app.getSSOConfig()
+ writeJSON(w, map[string]bool{"enabled": ssoURL != "" && ssoSecret != ""})
+}
+
+func (app *App) getBaseURL() string {
+ if app.baseURL != "" {
+ return app.baseURL
+ }
+ var u string
+ app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&u)
+ return u
+}
+
+func (app *App) handleSSOInit(w http.ResponseWriter, r *http.Request) {
+ ssoURL, ssoSecret := app.getSSOConfig()
+ if ssoURL == "" || ssoSecret == "" {
+ writeError(w, "SSO not configured", http.StatusNotFound)
+ return
+ }
+
+ baseURL := app.getBaseURL()
+ if baseURL == "" {
+ writeError(w, "base_url must be configured for SSO", http.StatusBadRequest)
+ return
+ }
+
+ b := make([]byte, 32)
+ rand.Read(b)
+ nonce := hex.EncodeToString(b)
+
+ app.cleanExpiredNonces()
+ if err := app.createSSONonce(nonce); err != nil {
+ writeError(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ returnURL := strings.TrimRight(baseURL, "/") + "/api/sso/callback"
+
+ payload := fmt.Sprintf("nonce=%s&return_sso_url=%s", url.QueryEscape(nonce), url.QueryEscape(returnURL))
+ encoded := base64.StdEncoding.EncodeToString([]byte(payload))
+
+ mac := hmac.New(sha256.New, []byte(ssoSecret))
+ mac.Write([]byte(encoded))
+ sig := hex.EncodeToString(mac.Sum(nil))
+
+ redirect := fmt.Sprintf("%s/session/sso_provider?sso=%s&sig=%s",
+ strings.TrimRight(ssoURL, "/"), url.QueryEscape(encoded), url.QueryEscape(sig))
+
+ writeJSON(w, map[string]string{"redirect_url": redirect})
+}
+
+func (app *App) handleSSOCallback(w http.ResponseWriter, r *http.Request) {
+ baseURL := app.getBaseURL()
+
+ ssoRedirectError := func(msg string) {
+ if baseURL != "" {
+ http.Redirect(w, r, strings.TrimRight(baseURL, "/")+"/#sso_error="+url.QueryEscape(msg), http.StatusFound)
+ } else {
+ writeError(w, msg, http.StatusBadRequest)
+ }
+ }
+
+ _, ssoSecret := app.getSSOConfig()
+ if ssoSecret == "" {
+ ssoRedirectError("SSO not configured")
+ return
+ }
+
+ ssoParam := r.URL.Query().Get("sso")
+ sigParam := r.URL.Query().Get("sig")
+ if ssoParam == "" || sigParam == "" {
+ ssoRedirectError("Invalid SSO response")
+ return
+ }
+
+ mac := hmac.New(sha256.New, []byte(ssoSecret))
+ mac.Write([]byte(ssoParam))
+ expectedSig := hex.EncodeToString(mac.Sum(nil))
+ if !hmac.Equal([]byte(expectedSig), []byte(sigParam)) {
+ ssoRedirectError("Invalid SSO signature")
+ return
+ }
+
+ decoded, err := base64.StdEncoding.DecodeString(ssoParam)
+ if err != nil {
+ ssoRedirectError("Invalid SSO payload")
+ return
+ }
+
+ vals, err := url.ParseQuery(string(decoded))
+ if err != nil {
+ ssoRedirectError("Invalid SSO payload")
+ return
+ }
+
+ nonce := vals.Get("nonce")
+ valid, err := app.consumeSSONonce(nonce)
+ if err != nil || !valid {
+ ssoRedirectError("SSO session expired. Please try again.")
+ return
+ }
+
+ email := strings.ToLower(vals.Get("email"))
+ if email == "" {
+ ssoRedirectError("No email in SSO response")
+ return
+ }
+
+ name := vals.Get("name")
+ if name == "" {
+ name = vals.Get("username")
+ }
+
+ user, _, err := app.getLoginParticipant(email)
+ if err != nil {
+ ssoRedirectError("Login failed. Please try again.")
+ return
+ }
+
+ if user == nil {
+ p, err := app.getParticipantByEmail(email)
+ if err != nil {
+ ssoRedirectError("Login failed. Please try again.")
+ return
+ }
+ if p != nil {
+ if _, err := app.db.Exec(
+ `UPDATE participants SET login_enabled = 1, updated_at = ? WHERE id = ?`,
+ now(), p.ID,
+ ); err != nil {
+ ssoRedirectError("Login failed. Please try again.")
+ return
+ }
+ user, err = app.getUser(p.ID)
+ if err != nil {
+ ssoRedirectError("Login failed. Please try again.")
+ return
+ }
+ }
+ }
+
+ if user == nil {
+ if name == "" {
+ name = strings.Split(email, "@")[0]
+ }
+ res, err := app.db.Exec(
+ `INSERT INTO participants (email, preferred_name, login_enabled, updated_at) VALUES (?, ?, 1, ?)`,
+ email, name, now(),
+ )
+ if err != nil {
+ ssoRedirectError("Login failed. Please try again.")
+ return
+ }
+ id, _ := res.LastInsertId()
+ user, err = app.getUser(int(id))
+ if err != nil || user == nil {
+ ssoRedirectError("Login failed. Please try again.")
+ return
+ }
+ }
+
+ token, err := app.signToken(user)
+ if err != nil {
+ ssoRedirectError("Login failed. Please try again.")
+ return
+ }
+
+ http.Redirect(w, r, strings.TrimRight(baseURL, "/")+"/#sso_token="+url.QueryEscape(token), http.StatusFound)
+}
diff --git a/main.go b/main.go
index f775bb6..186362c 100644
--- a/main.go
+++ b/main.go
@@ -164,6 +164,9 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "staffing"))
// Public endpoints — no JWT required.
+ mux.HandleFunc("GET /api/public/sso-enabled", app.handleSSOEnabled)
+ mux.HandleFunc("GET /api/sso/init", app.handleSSOInit)
+ mux.HandleFunc("GET /api/sso/callback", app.handleSSOCallback)
mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig)
mux.HandleFunc("POST /api/public/signup", app.handlePublicSignup)
mux.HandleFunc("POST /api/public/confirm", app.handleConfirmEmail)