Files
goclaw/internal/http/oauth.go
T
Goon 75c570e951 feat(security): credentialed exec + HTTP RBAC + API key cache (#197)
- 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
2026-03-15 20:13:18 +07:00

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
}