mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 18:11:00 +00:00
75c570e951
- Secure CLI credential injection via AES-256-GCM encrypted env vars - API key management with fine-grained RBAC scopes - resolveAuth/requireAuth middleware across all 25+ HTTP handlers - In-memory API key cache with TTL, negative caching, pubsub invalidation - Sandbox-first execution (fails if unavailable, no silent fallback) - Credential scrubbing, constant-time token comparison, Admin-only CLI creds - SQL migration 000020: secure_cli_binaries + api_keys tables - 14 unit tests for cache and RBAC with race detector Closes #197
224 lines
6.3 KiB
Go
224 lines
6.3 KiB
Go
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
|
|
}
|