package httpapi import ( "net/http" "strings" "time" "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/jmoiron/sqlx" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/auth" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/config" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/httpapi/handlers" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/oidc" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/store" ) // sessionVerifierAdapter wires internal/oidc.Service into auth's // SessionVerifier interface (which uses auth.SessionClaims to stay // import-cycle-free). type sessionVerifierAdapter struct{ s *oidc.Service } func (a sessionVerifierAdapter) VerifySession(raw string) (*auth.SessionClaims, error) { c, err := a.s.VerifySession(raw) if err != nil { return nil, err } return &auth.SessionClaims{Sub: c.Sub, Email: c.Email, Name: c.Name}, nil } // Mount returns the root router with all v2 endpoints wired. Owners of // individual middleware chains: // // - /api/healthz : public (no auth) // - /api/auth/* : OIDC login flow + session // - /api/topics : mixed — list/get optional auth (anon // sees public only); create requires CallerAgent or CallerUser // - /api/topics/{id}/signups : agent-only (CallerAgent) // // Browser-side OIDC and agent-side bearer middlewares co-exist on the // same route by being "optional auth" — if either succeeds, Caller is // attached; otherwise the handler sees anonymous and decides whether // to 401 or fall through to public behavior. func Mount(cfg *config.Config, db *sqlx.DB, oidcSvc *oidc.Service, version string) http.Handler { r := chi.NewRouter() // Boilerplate middleware — these run on every request. r.Use(chimw.RealIP) r.Use(chimw.RequestID) r.Use(chimw.Logger) r.Use(chimw.Recoverer) r.Use(chimw.Timeout(30 * time.Second)) r.Use(cors.Handler(cors.Options{ AllowedOrigins: cfg.CORSAllowOrigins, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "x-dev-bypass"}, ExposedHeaders: []string{}, AllowCredentials: true, MaxAge: 300, })) verifier := sessionVerifierAdapter{s: oidcSvc} // Auth middlewares — composed as "try agent, then user, else pass anonymous". optionalAuth := optionalAuthChain(db, cfg, verifier) requireAgent := auth.AgentAPIKey(db, cfg.AgentAPIKeyPepper) // strict bearer requireAnyAuth := requireAnyAuthChain(db, cfg, verifier) // Handler instances. topicStore := store.NewTopicStore(db) signupStore := store.NewSignupStore(db) campStore := store.NewCampStore(db) roundStore := store.NewRoundStore(db) argStore := store.NewArgumentStore(db) verdictStore := store.NewVerdictStore(db) health := handlers.NewHealthHandler(db, version) topicsH := handlers.NewTopicsHandler(topicStore, campStore) signupsH := handlers.NewSignupsHandler(topicStore, signupStore) argsH := handlers.NewArgumentsHandler(topicStore, campStore, roundStore, argStore) verdictH := handlers.NewVerdictHandler(topicStore, campStore, verdictStore) adminH := handlers.NewAdminHandler(db, cfg.AgentAPIKeyPepper, cfg.DialecticAdminAPIKey) // Cookie Secure: in prod nginx terminates TLS upstream, so requests // hit the backend over plain HTTP. Setting Secure=true on the // cookie would prevent the browser from sending it back. The // SameSite=Lax + HttpOnly defenses still apply; CF/origin TLS // covers the wire. authH := handlers.NewAuthHandler(oidcSvc, "dialectic_session", false) // Routes. r.Route("/api", func(r chi.Router) { r.Get("/healthz", health.Healthz) // OIDC login flow + session — public endpoints (no auth // middleware; they ARE the auth surface). r.Get("/auth/oidc/status", authH.Status) r.Get("/auth/oidc/start", authH.Start) r.Get("/auth/oidc/callback", authH.Callback) r.Post("/auth/oidc/exchange", authH.Exchange) // /auth/me + /auth/logout need an authenticated session to be // meaningful, but /auth/me returns 401 cleanly so SPA can call // it on mount as a "who am i" probe. r.Group(func(r chi.Router) { r.Use(optionalAuth) r.Get("/auth/me", authH.Me) r.Post("/auth/logout", authH.Logout) }) // Topics: list+get optional-auth (visibility-gated by handler); // create+visibility-flip require any auth. r.Group(func(r chi.Router) { r.Use(optionalAuth) r.Get("/topics", topicsH.List) r.Get("/topics/{id}", topicsH.Get) r.Get("/topics/{id}/arguments", argsH.List) r.Get("/topics/{id}/verdict", verdictH.Get) }) r.Group(func(r chi.Router) { r.Use(requireAnyAuth) r.Post("/topics", topicsH.Create) r.Put("/topics/{id}/visibility", topicsH.SetVisibility) }) // Signups, arguments, verdict POST: agent-only. r.Group(func(r chi.Router) { r.Use(requireAgent) r.Post("/topics/{id}/signups", signupsH.Create) r.Post("/topics/{id}/arguments", argsH.Post) r.Post("/topics/{id}/verdict", verdictH.Submit) }) // List signups: any authenticated caller. r.Group(func(r chi.Router) { r.Use(requireAnyAuth) r.Get("/topics/{id}/signups", signupsH.List) }) // Admin: provision an agent api key + per-agent activity summary. // Auth is its own header (x-dialectic-admin-key against env // DIALECTIC_ADMIN_API_KEY), not bearer — admin lifecycle is // separate from agent identity. r.Post("/admin/agent-keys", adminH.ProvisionAgentKey) r.Get("/admin/agents/{id}", adminH.GetAgentSummary) }) return r } // optionalAuthChain: if either auth method succeeds, attach Caller; // otherwise let the request through anonymous. Handlers decide what // to do with anonymous (typically: serve public subset, hide private). func optionalAuthChain(db *sqlx.DB, cfg *config.Config, verifier auth.SessionVerifier) func(http.Handler) http.Handler { agent := auth.AgentAPIKey(db, cfg.AgentAPIKeyPepper) oidcMw := auth.OIDCBrowser(verifier, cfg.IsDev(), cfg.OIDCDevBypassToken, cfg.OIDCOnly) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Bearer present → try agent path; on success it ServeHTTPs next. // On failure it 401s, which we want to demote to "anonymous" for // optional auth. The pattern is: capture the response; if it's // 401, fall through to OIDC; if OIDC also 401s, finally fall // through to next (anonymous). if r.Header.Get("authorization") != "" { rw := &captureWriter{ResponseWriter: w} agent(next).ServeHTTP(rw, r) if rw.status != http.StatusUnauthorized { return } } // Try OIDC (session cookie) — always (no header gate needed, // cookie presence is implicit) so an authenticated browser // always gets its identity attached. if hasSession(r) || r.Header.Get("x-dev-bypass") != "" { rw := &captureWriter{ResponseWriter: w} oidcMw(next).ServeHTTP(rw, r) if rw.status != http.StatusUnauthorized { return } } // Anonymous — call next with no Caller attached. next.ServeHTTP(w, r) }) } } // requireAnyAuthChain: 401 if neither agent nor user auth succeeds. func requireAnyAuthChain(db *sqlx.DB, cfg *config.Config, verifier auth.SessionVerifier) func(http.Handler) http.Handler { agent := auth.AgentAPIKey(db, cfg.AgentAPIKeyPepper) oidcMw := auth.OIDCBrowser(verifier, cfg.IsDev(), cfg.OIDCDevBypassToken, cfg.OIDCOnly) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("authorization") != "" { rw := &captureWriter{ResponseWriter: w} agent(next).ServeHTTP(rw, r) if rw.status != http.StatusUnauthorized { return } } oidcMw(next).ServeHTTP(w, r) }) } } func hasSession(r *http.Request) bool { c, err := r.Cookie("dialectic_session") return err == nil && c != nil && strings.TrimSpace(c.Value) != "" } // captureWriter records the status so the optional-auth chain can // distinguish "401 from inner middleware (try next)" from "actual // response from handler (deliver)". Body bytes are passed through // when status != 401. type captureWriter struct { http.ResponseWriter status int wroteHeader bool suppressing bool } func (c *captureWriter) WriteHeader(s int) { c.status = s c.wroteHeader = true if s == http.StatusUnauthorized { // don't actually write — we may fall through c.suppressing = true return } c.ResponseWriter.WriteHeader(s) } func (c *captureWriter) Write(b []byte) (int, error) { if c.suppressing { // swallow; caller will fall through to next chain step return len(b), nil } if !c.wroteHeader { c.ResponseWriter.WriteHeader(http.StatusOK) c.wroteHeader = true } return c.ResponseWriter.Write(b) }