package handlers import ( "encoding/json" "errors" "net/http" "net/url" "strings" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/auth" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/oidc" ) // AuthHandler implements OIDC login + session endpoints. Mirrors // Fabric.Backend.Center's OidcController surface: // // GET /api/auth/oidc/status — { enabled }; SPA polls before showing Login // GET /api/auth/oidc/start — 302 to IdP authorize URL // GET /api/auth/oidc/callback — IdP redirects here; we 302 to SPA with #oidc_ticket // POST /api/auth/oidc/exchange — SPA trades ticket for session cookie + user // GET /api/auth/me — current session user (401 if anon) // POST /api/auth/logout — clears the session cookie type AuthHandler struct { oidc *oidc.Service cookieName string secure bool allowedHosts []string // for open-redirect protection on post_login_redirect } // NewAuthHandler wires the OIDC HTTP surface. `allowedHosts` is the // allow-list of hostnames the callback may 302 the browser to (sourced // from cfg.CORSAllowOrigins — the same origins we already trust for // CORS, by definition. A relative path always passes regardless. func NewAuthHandler(svc *oidc.Service, cookieName string, secure bool, allowedHosts []string) *AuthHandler { if cookieName == "" { cookieName = "dialectic_session" } return &AuthHandler{oidc: svc, cookieName: cookieName, secure: secure, allowedHosts: allowedHosts} } // safeRedirectBase returns `raw` if it's a relative path OR an absolute // URL whose host appears in the allow-list. Otherwise it returns "/" so // the open-redirect attack (attacker-set PostLoginRedirect points to // evil.com) is neutered — the worst the SPA sees is its own root with // an error fragment, never an external bounce. // // Why we need this: oidc_config.post_login_redirect is set via the // admin cli. An admin-key compromise OR a misconfiguration that points // it at an external domain would otherwise let any /oidc/callback // error path redirect the user there with `?oidc_error=...` attached, // usable for phishing ("dialectic login failed: please re-enter your // password at evil.com/login"). func (h *AuthHandler) safeRedirectBase(raw string) string { if raw == "" { return "/" } // Relative paths are always safe (same-origin by definition). if strings.HasPrefix(raw, "/") && !strings.HasPrefix(raw, "//") { return raw } u, err := url.Parse(raw) if err != nil || u.Host == "" { return "/" } // Walk the allow-list. cors.AllowedOrigins entries look like // "https://dialectic.hangman-lab.top" — parse host out + compare. for _, origin := range h.allowedHosts { o, err := url.Parse(origin) if err == nil && o.Host != "" && strings.EqualFold(o.Host, u.Host) { return raw } } return "/" } func (h *AuthHandler) Status(w http.ResponseWriter, r *http.Request) { enabled, err := h.oidc.IsEnabled(r.Context()) if err != nil { http.Error(w, "oidc status: "+err.Error(), http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]any{"enabled": enabled}) } func (h *AuthHandler) Start(w http.ResponseWriter, r *http.Request) { u, err := h.oidc.BuildAuthorizeURL(r.Context()) if err != nil { http.Error(w, "oidc start: "+err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, u, http.StatusFound) } func (h *AuthHandler) Callback(w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get("code") state := r.URL.Query().Get("state") ticket, redirect, err := h.oidc.HandleCallback(r.Context(), code, state) if err != nil { // Bounce to the SPA with an error fragment so the user sees // something useful instead of a 500 page mid-login. c, _ := h.oidc.GetConfig(r.Context()) base := "/" if c != nil { base = h.safeRedirectBase(c.PostLoginRedirect) } sep := "#" if strings.Contains(base, "#") { sep = "&" } http.Redirect(w, r, base+sep+"oidc_error="+url.QueryEscape(err.Error()), http.StatusFound) return } // Validate the success-path redirect too — HandleCallback returned // PostLoginRedirect from the same DB row, so the same open-redirect // risk applies on the happy path. safe := h.safeRedirectBase(redirect) sep := "#" if strings.Contains(safe, "#") { sep = "&" } http.Redirect(w, r, safe+sep+"oidc_ticket="+url.QueryEscape(ticket), http.StatusFound) } type exchangeBody struct { Ticket string `json:"ticket"` } func (h *AuthHandler) Exchange(w http.ResponseWriter, r *http.Request) { var b exchangeBody if err := json.NewDecoder(r.Body).Decode(&b); err != nil { http.Error(w, "bad body", http.StatusBadRequest) return } jwtStr, exp, user, err := h.oidc.ExchangeTicket(b.Ticket) if err != nil { http.Error(w, "exchange: "+err.Error(), http.StatusUnauthorized) return } // HTTP-only session cookie. SameSite=Lax so it survives the OIDC // redirect chain; Secure when behind HTTPS (always in prod). http.SetCookie(w, &http.Cookie{ Name: h.cookieName, Value: jwtStr, Path: "/", Expires: exp, HttpOnly: true, Secure: h.secure, SameSite: http.SameSiteLaxMode, }) writeJSON(w, http.StatusOK, map[string]any{ "user": map[string]any{ "id": user.Sub, "email": user.Email, "name": user.Name, }, "expires_at": exp, }) } func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { caller := auth.FromContext(r.Context()) if caller.Kind == "" { http.Error(w, "not authenticated", http.StatusUnauthorized) return } writeJSON(w, http.StatusOK, map[string]any{ "id": caller.ID, "email": caller.Email, "name": caller.Name, }) } func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { // Clear the cookie by setting an expired one with the same name + path. http.SetCookie(w, &http.Cookie{ Name: h.cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: h.secure, SameSite: http.SameSiteLaxMode, }) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // silence unused errors import if no upstream calls — keep so future // handlers can build typed error responses. var _ = errors.New