package http import ( "context" "encoding/json" "log/slog" "net/http" "sync" "time" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/oauth" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" ) // OAuthHandler handles OAuth-related HTTP endpoints for web UI. type OAuthHandler struct { token string // gateway auth token provStore store.ProviderStore secretStore store.ConfigSecretsStore providerReg *providers.Registry msgBus *bus.MessageBus mu sync.Mutex pending *oauth.PendingLogin // active OAuth flow (if any) } // NewOAuthHandler creates a handler for OAuth endpoints. func NewOAuthHandler(token string, provStore store.ProviderStore, secretStore store.ConfigSecretsStore, providerReg *providers.Registry, msgBus *bus.MessageBus) *OAuthHandler { return &OAuthHandler{ token: token, provStore: provStore, secretStore: secretStore, providerReg: providerReg, msgBus: msgBus, } } // RegisterRoutes registers OAuth routes on the given mux. func (h *OAuthHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/auth/openai/status", h.auth(h.handleStatus)) mux.HandleFunc("POST /v1/auth/openai/start", h.auth(h.handleStart)) mux.HandleFunc("POST /v1/auth/openai/callback", h.auth(h.handleManualCallback)) mux.HandleFunc("POST /v1/auth/openai/logout", h.auth(h.handleLogout)) } func (h *OAuthHandler) auth(next http.HandlerFunc) http.HandlerFunc { return requireAuth(h.token, "", next) } func (h *OAuthHandler) newTokenSource() *oauth.DBTokenSource { return oauth.NewDBTokenSource(h.provStore, h.secretStore, oauth.DefaultProviderName) } func (h *OAuthHandler) handleStatus(w http.ResponseWriter, r *http.Request) { ts := h.newTokenSource() if !ts.Exists(r.Context()) { writeJSON(w, http.StatusOK, map[string]any{"authenticated": false}) return } if _, err := ts.Token(); err != nil { writeJSON(w, http.StatusOK, map[string]any{ "authenticated": false, "error": "token invalid or expired", }) return } writeJSON(w, http.StatusOK, map[string]any{ "authenticated": true, "provider_name": oauth.DefaultProviderName, }) } func (h *OAuthHandler) handleStart(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) h.mu.Lock() defer h.mu.Unlock() // Already authenticated? ts := h.newTokenSource() if ts.Exists(r.Context()) { if _, err := ts.Token(); err == nil { writeJSON(w, http.StatusOK, map[string]any{"status": "already_authenticated"}) return } } // Shut down any previous pending flow to release port 1455 if h.pending != nil { h.pending.Shutdown() h.pending = nil } pending, err := oauth.StartLoginOpenAI() if err != nil { slog.Error("oauth.start", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": i18n.T(locale, i18n.MsgInternalError, "failed to start OAuth flow (is port 1455 available?)"), }) return } h.pending = pending // Wait for callback in background, save token when done go h.waitForCallback(pending) emitAudit(h.msgBus, r, "oauth.login_started", "oauth", "openai") writeJSON(w, http.StatusOK, map[string]any{"auth_url": pending.AuthURL}) } // waitForCallback waits for the OAuth callback and saves the token. func (h *OAuthHandler) waitForCallback(pending *oauth.PendingLogin) { ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) defer cancel() tokenResp, err := pending.Wait(ctx) h.mu.Lock() if h.pending == pending { h.pending = nil } h.mu.Unlock() if err != nil { slog.Warn("oauth.callback failed", "error", err) return } if _, err := h.saveAndRegister(ctx, tokenResp); err != nil { slog.Error("oauth.save_token", "error", err) return } slog.Info("oauth: OpenAI token saved via web UI callback") } func (h *OAuthHandler) handleManualCallback(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) var body struct { RedirectURL string `json:"redirect_url"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.RedirectURL == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "redirect_url")}) return } h.mu.Lock() pending := h.pending h.mu.Unlock() if pending == nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgNoPendingOAuth)}) return } tokenResp, err := pending.ExchangeRedirectURL(body.RedirectURL) if err != nil { slog.Warn("oauth.manual_callback", "error", err) writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } // Shut down the callback server and clear pending pending.Shutdown() h.mu.Lock() if h.pending == pending { h.pending = nil } h.mu.Unlock() providerID, err := h.saveAndRegister(r.Context(), tokenResp) if err != nil { slog.Error("oauth.save_token", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToSaveToken)}) return } slog.Info("oauth: OpenAI token saved via manual callback") writeJSON(w, http.StatusOK, map[string]any{ "authenticated": true, "provider_name": oauth.DefaultProviderName, "provider_id": providerID.String(), }) } func (h *OAuthHandler) handleLogout(w http.ResponseWriter, r *http.Request) { ts := h.newTokenSource() if err := ts.Delete(r.Context()); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if h.providerReg != nil { h.providerReg.Unregister(oauth.DefaultProviderName) } emitAudit(h.msgBus, r, "oauth.logout", "oauth", "openai") writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"}) } // saveAndRegister persists the OAuth result to DB and registers the CodexProvider in-memory. func (h *OAuthHandler) saveAndRegister(ctx context.Context, tokenResp *oauth.OpenAITokenResponse) (uuid.UUID, error) { ts := h.newTokenSource() providerID, err := ts.SaveOAuthResult(ctx, tokenResp) if err != nil { return uuid.Nil, err } // Register CodexProvider in-memory for immediate use if h.providerReg != nil { codex := providers.NewCodexProvider(oauth.DefaultProviderName, ts, "", "") h.providerReg.Register(codex) } return providerID, nil }