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 } func NewAuthHandler(svc *oidc.Service, cookieName string, secure bool) *AuthHandler { if cookieName == "" { cookieName = "dialectic_session" } return &AuthHandler{oidc: svc, cookieName: cookieName, secure: secure} } 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 && c.PostLoginRedirect != "" { base = c.PostLoginRedirect } sep := "#" if strings.Contains(base, "#") { sep = "&" } http.Redirect(w, r, base+sep+"oidc_error="+url.QueryEscape(err.Error()), http.StatusFound) return } sep := "#" if strings.Contains(redirect, "#") { sep = "&" } http.Redirect(w, r, redirect+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