mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 22:14:37 +00:00
Browser pairing authenticates users as RoleOperator, but every provider and OAuth route was wrapped in requireAuth(RoleAdmin). The /setup flow reads GET /v1/providers, so paired browsers got 403 and hung on the setup page. Split provider/OAuth route auth by operation: - providers.go: GET list/get/models/status/embedding/codex-activity now use a readAuth wrapper (method-derived min → Viewer); POST/PUT/DELETE and reconnect/verify stay Admin. Responses already mask API keys and queries stay tenant-scoped, so no secrets are exposed. - oauth.go: GET status/quota use readAuth (they return only connection state and quota, never tokens); start/callback/logout stay Admin (preserving the #450 admin-only-management decision for mutations). Updates the two auth tests to assert the new contract: an Operator can read providers/OAuth status (200) but still cannot mutate them (403).
417 lines
13 KiB
Go
417 lines
13 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// OAuthHandler handles OAuth-related HTTP endpoints for web UI.
|
|
//
|
|
// Limitation: only one OAuth flow can run at a time because the local callback
|
|
// server binds to a fixed port (1455). A second flow attempt within the 6-minute
|
|
// window returns HTTP 409. This affects all providers, not just the one being
|
|
// authenticated.
|
|
type OAuthHandler struct {
|
|
provStore store.ProviderStore
|
|
secretStore store.ConfigSecretsStore
|
|
providerReg *providers.Registry
|
|
msgBus *bus.MessageBus
|
|
|
|
mu sync.Mutex
|
|
pending map[string]*pendingOAuthFlow
|
|
activeFlowKey string // only one active flow at a time (fixed callback port)
|
|
}
|
|
|
|
type pendingOAuthFlow struct {
|
|
login *oauth.PendingLogin
|
|
cancel context.CancelFunc
|
|
flowKey string
|
|
tenantID uuid.UUID
|
|
userID string
|
|
providerName string
|
|
displayName string
|
|
apiBase string
|
|
}
|
|
|
|
// NewOAuthHandler creates a handler for OAuth endpoints.
|
|
func NewOAuthHandler(provStore store.ProviderStore, secretStore store.ConfigSecretsStore, providerReg *providers.Registry, msgBus *bus.MessageBus) *OAuthHandler {
|
|
return &OAuthHandler{
|
|
provStore: provStore,
|
|
secretStore: secretStore,
|
|
providerReg: providerReg,
|
|
msgBus: msgBus,
|
|
pending: make(map[string]*pendingOAuthFlow),
|
|
}
|
|
}
|
|
|
|
// RegisterRoutes registers OAuth routes on the given mux.
|
|
func (h *OAuthHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
// Read-only status/quota — needed by /setup for browser-paired Operators
|
|
// (issue #1075). Managing OAuth (start/callback/logout) stays Admin-only.
|
|
mux.HandleFunc("GET /v1/auth/chatgpt/{provider}/status", h.readAuth(h.handleStatus))
|
|
mux.HandleFunc("GET /v1/auth/chatgpt/{provider}/quota", h.readAuth(h.handleQuota))
|
|
mux.HandleFunc("POST /v1/auth/chatgpt/{provider}/start", h.auth(h.handleStart))
|
|
mux.HandleFunc("POST /v1/auth/chatgpt/{provider}/callback", h.auth(h.handleManualCallback))
|
|
mux.HandleFunc("POST /v1/auth/chatgpt/{provider}/logout", h.auth(h.handleLogout))
|
|
|
|
mux.HandleFunc("GET /v1/auth/openai/status", h.readAuth(h.handleStatus))
|
|
mux.HandleFunc("GET /v1/auth/openai/quota", h.readAuth(h.handleQuota))
|
|
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))
|
|
}
|
|
|
|
// auth requires RoleAdmin for OAuth management (start/callback/logout).
|
|
// Since #450 these mutating flows are admin-only; #1075 keeps that for writes
|
|
// but splits reads out via readAuth so setup can check connection status.
|
|
func (h *OAuthHandler) auth(next http.HandlerFunc) http.HandlerFunc {
|
|
return requireAuth(permissions.RoleAdmin, next)
|
|
}
|
|
|
|
// readAuth gates read-only OAuth status/quota at the method-derived minimum
|
|
// (GET→Viewer). Responses expose only connection state and quota, never tokens.
|
|
func (h *OAuthHandler) readAuth(next http.HandlerFunc) http.HandlerFunc {
|
|
return requireAuth("", next)
|
|
}
|
|
|
|
func oauthTenantID(ctx context.Context) uuid.UUID {
|
|
if tid := store.TenantIDFromContext(ctx); tid != uuid.Nil {
|
|
return tid
|
|
}
|
|
return store.MasterTenantID
|
|
}
|
|
|
|
func oauthProviderName(r *http.Request) string {
|
|
if provider := r.PathValue("provider"); provider != "" {
|
|
return provider
|
|
}
|
|
return oauth.DefaultProviderName
|
|
}
|
|
|
|
func oauthFlowKey(ctx context.Context, providerName string) string {
|
|
return oauthTenantID(ctx).String() + ":" + store.UserIDFromContext(ctx) + ":" + providerName
|
|
}
|
|
|
|
func (h *OAuthHandler) newTokenSource(ctx context.Context, providerName, displayName, apiBase string) *oauth.DBTokenSource {
|
|
return oauth.NewDBTokenSource(h.provStore, h.secretStore, providerName).
|
|
WithTenantID(oauthTenantID(ctx)).
|
|
WithProviderMeta(displayName, apiBase)
|
|
}
|
|
|
|
func (h *OAuthHandler) ensureOAuthProviderName(ctx context.Context, providerName string) error {
|
|
if h.provStore == nil {
|
|
return nil
|
|
}
|
|
p, err := h.provStore.GetProviderByName(ctx, providerName)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if p.ProviderType != store.ProviderChatGPTOAuth {
|
|
return &oauth.ProviderTypeConflictError{
|
|
ProviderName: providerName,
|
|
ProviderType: p.ProviderType,
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeOAuthProviderConflict(w http.ResponseWriter, err error) bool {
|
|
var conflict *oauth.ProviderTypeConflictError
|
|
if !errors.As(err, &conflict) {
|
|
return false
|
|
}
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
|
return true
|
|
}
|
|
|
|
func (h *OAuthHandler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
providerName := oauthProviderName(r)
|
|
if !isValidSlug(providerName) {
|
|
locale := extractLocale(r)
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "provider")})
|
|
return
|
|
}
|
|
if err := h.ensureOAuthProviderName(r.Context(), providerName); err != nil {
|
|
writeOAuthProviderConflict(w, err)
|
|
return
|
|
}
|
|
|
|
ts := h.newTokenSource(r.Context(), providerName, "", "")
|
|
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": providerName,
|
|
})
|
|
}
|
|
|
|
func (h *OAuthHandler) handleStart(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
providerName := oauthProviderName(r)
|
|
if !isValidSlug(providerName) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "provider")})
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
DisplayName string `json:"display_name"`
|
|
APIBase string `json:"api_base"`
|
|
}
|
|
if r.ContentLength > 0 {
|
|
if !bindJSON(w, r, locale, &body) {
|
|
return
|
|
}
|
|
}
|
|
if err := h.ensureOAuthProviderName(r.Context(), providerName); err != nil {
|
|
writeOAuthProviderConflict(w, err)
|
|
return
|
|
}
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
// Already authenticated?
|
|
ts := h.newTokenSource(r.Context(), providerName, body.DisplayName, body.APIBase)
|
|
if ts.Exists(r.Context()) {
|
|
if _, err := ts.Token(); err == nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "already_authenticated",
|
|
"provider_name": providerName,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
flowKey := oauthFlowKey(r.Context(), providerName)
|
|
if pending := h.pending[flowKey]; pending != nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"auth_url": pending.login.AuthURL,
|
|
"provider_name": providerName,
|
|
})
|
|
return
|
|
}
|
|
if h.activeFlowKey != "" && h.activeFlowKey != flowKey {
|
|
writeJSON(w, http.StatusConflict, map[string]string{
|
|
"error": "another OAuth flow is already active on this server",
|
|
})
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
waitCtx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
|
|
flow := &pendingOAuthFlow{
|
|
login: pending,
|
|
cancel: cancel,
|
|
flowKey: flowKey,
|
|
tenantID: oauthTenantID(r.Context()),
|
|
userID: store.UserIDFromContext(r.Context()),
|
|
providerName: providerName,
|
|
displayName: body.DisplayName,
|
|
apiBase: body.APIBase,
|
|
}
|
|
h.pending[flowKey] = flow
|
|
h.activeFlowKey = flowKey
|
|
|
|
// Wait for callback in background, save token when done
|
|
go h.waitForCallback(waitCtx, flow)
|
|
|
|
emitAudit(h.msgBus, r, "oauth.login_started", "oauth", "openai")
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"auth_url": pending.AuthURL,
|
|
"provider_name": providerName,
|
|
})
|
|
}
|
|
|
|
// waitForCallback waits for the OAuth callback and saves the token.
|
|
func (h *OAuthHandler) waitForCallback(ctx context.Context, flow *pendingOAuthFlow) {
|
|
tokenResp, err := flow.login.Wait(ctx)
|
|
|
|
h.mu.Lock()
|
|
if h.pending[flow.flowKey] == flow {
|
|
delete(h.pending, flow.flowKey)
|
|
}
|
|
if h.activeFlowKey == flow.flowKey {
|
|
h.activeFlowKey = ""
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) {
|
|
slog.Info("oauth flow canceled", "provider", flow.providerName)
|
|
return
|
|
}
|
|
slog.Warn("oauth.callback failed", "error", err)
|
|
return
|
|
}
|
|
|
|
saveCtx := store.WithTenantID(context.Background(), flow.tenantID)
|
|
if _, err := h.saveAndRegister(saveCtx, flow.providerName, flow.displayName, flow.apiBase, tokenResp); err != nil {
|
|
slog.Error("oauth.save_token", "error", err)
|
|
return
|
|
}
|
|
|
|
slog.Info("oauth: OpenAI token saved via web UI callback", "provider", flow.providerName)
|
|
}
|
|
|
|
func (h *OAuthHandler) handleManualCallback(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
providerName := oauthProviderName(r)
|
|
if !isValidSlug(providerName) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "provider")})
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
RedirectURL string `json:"redirect_url"`
|
|
}
|
|
if !bindJSON(w, r, locale, &body) {
|
|
return
|
|
}
|
|
if body.RedirectURL == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "redirect_url")})
|
|
return
|
|
}
|
|
if err := h.ensureOAuthProviderName(r.Context(), providerName); err != nil {
|
|
writeOAuthProviderConflict(w, err)
|
|
return
|
|
}
|
|
|
|
h.mu.Lock()
|
|
pending := h.pending[oauthFlowKey(r.Context(), providerName)]
|
|
h.mu.Unlock()
|
|
|
|
if pending == nil || pending.providerName != providerName || pending.tenantID != oauthTenantID(r.Context()) || pending.userID != store.UserIDFromContext(r.Context()) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgNoPendingOAuth)})
|
|
return
|
|
}
|
|
|
|
tokenResp, err := pending.login.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.cancel()
|
|
pending.login.Shutdown()
|
|
h.mu.Lock()
|
|
if h.pending[pending.flowKey] == pending {
|
|
delete(h.pending, pending.flowKey)
|
|
}
|
|
if h.activeFlowKey == pending.flowKey {
|
|
h.activeFlowKey = ""
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
providerID, err := h.saveAndRegister(r.Context(), providerName, pending.displayName, pending.apiBase, tokenResp)
|
|
if err != nil {
|
|
if writeOAuthProviderConflict(w, err) {
|
|
return
|
|
}
|
|
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": providerName,
|
|
"provider_id": providerID.String(),
|
|
})
|
|
}
|
|
|
|
func (h *OAuthHandler) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
providerName := oauthProviderName(r)
|
|
if !isValidSlug(providerName) {
|
|
locale := extractLocale(r)
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "provider")})
|
|
return
|
|
}
|
|
if err := h.ensureOAuthProviderName(r.Context(), providerName); err != nil {
|
|
writeOAuthProviderConflict(w, err)
|
|
return
|
|
}
|
|
|
|
ts := h.newTokenSource(r.Context(), providerName, "", "")
|
|
if err := ts.Delete(r.Context()); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if h.providerReg != nil {
|
|
tid := store.TenantIDFromContext(r.Context())
|
|
if tid == uuid.Nil {
|
|
tid = store.MasterTenantID
|
|
}
|
|
h.providerReg.UnregisterForTenant(tid, providerName)
|
|
}
|
|
|
|
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, providerName, displayName, apiBase string, tokenResp *oauth.OpenAITokenResponse) (uuid.UUID, error) {
|
|
ts := h.newTokenSource(ctx, providerName, displayName, apiBase)
|
|
providerID, err := ts.SaveOAuthResult(ctx, tokenResp)
|
|
if err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
|
|
// Register CodexProvider in-memory for immediate use
|
|
if h.providerReg != nil {
|
|
tid := oauthTenantID(ctx)
|
|
providerAPIBase := apiBase
|
|
codex := providers.NewCodexProvider(providerName, ts, providerAPIBase, "")
|
|
if h.provStore != nil {
|
|
providerCtx := store.WithTenantID(ctx, tid)
|
|
if providerData, err := h.provStore.GetProviderByName(providerCtx, providerName); err == nil {
|
|
if providerData.APIBase != "" {
|
|
providerAPIBase = providerData.APIBase
|
|
codex = providers.NewCodexProvider(providerName, ts, providerAPIBase, "")
|
|
}
|
|
if oauthSettings := store.ParseChatGPTOAuthProviderSettings(providerData.Settings); oauthSettings != nil {
|
|
codex.WithRoutingDefaults(oauthSettings.CodexPool.Strategy, oauthSettings.CodexPool.ExtraProviderNames)
|
|
}
|
|
}
|
|
}
|
|
h.providerReg.RegisterForTenant(tid, codex)
|
|
}
|
|
|
|
return providerID, nil
|
|
}
|