mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 12:10:53 +00:00
7d744eb4f2
Replace file-based OAuth token storage with DB-backed storage using llm_providers (access token) + config_secrets (refresh token). - Store: Add Settings JSONB field, chatgpt_oauth provider type - OAuth: DBTokenSource backed by provider + secrets stores - HTTP: oauth.go uses DB stores + registers provider in-memory - Providers: chatgpt_oauth support in registerInMemory/registerProvidersFromDB - Config: Remove HasOAuthToken, revert envFallback→envStr - CLI: auth commands call HTTP API on running gateway - Split codex.go (478→189 LOC) into codex.go + codex_build.go + codex_types.go - Frontend: Remove fake OAUTH_PROVIDER_ID, use real DB-backed providers - Tests: Rewrite with mock stores, fix SSE mock servers
229 lines
6.6 KiB
Go
229 lines
6.6 KiB
Go
package oauth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
const (
|
|
// DefaultProviderName is the provider name for ChatGPT OAuth.
|
|
DefaultProviderName = "openai-codex"
|
|
|
|
// refreshTokenSecretKey is the config_secrets key for the refresh token.
|
|
refreshTokenSecretKey = "oauth.openai-codex.refresh_token"
|
|
|
|
// refreshMargin is how early before expiry we refresh the token.
|
|
refreshMargin = 5 * time.Minute
|
|
)
|
|
|
|
// OAuthSettings is stored in llm_providers.settings JSONB (non-sensitive metadata).
|
|
type OAuthSettings struct {
|
|
ExpiresAt int64 `json:"expires_at"` // unix timestamp
|
|
Scopes string `json:"scopes,omitempty"`
|
|
}
|
|
|
|
// DBTokenSource provides a valid access token backed by the llm_providers + config_secrets tables.
|
|
// Implements providers.TokenSource.
|
|
type DBTokenSource struct {
|
|
providerStore store.ProviderStore
|
|
secretsStore store.ConfigSecretsStore
|
|
providerName string
|
|
|
|
mu sync.Mutex
|
|
cachedToken string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// NewDBTokenSource creates a DB-backed token source.
|
|
func NewDBTokenSource(provStore store.ProviderStore, secretsStore store.ConfigSecretsStore, providerName string) *DBTokenSource {
|
|
return &DBTokenSource{
|
|
providerStore: provStore,
|
|
secretsStore: secretsStore,
|
|
providerName: providerName,
|
|
}
|
|
}
|
|
|
|
// Token returns a valid access token, refreshing if expired or about to expire.
|
|
func (ts *DBTokenSource) Token() (string, error) {
|
|
ts.mu.Lock()
|
|
defer ts.mu.Unlock()
|
|
|
|
// Use cached token if still valid
|
|
if ts.cachedToken != "" && time.Until(ts.expiresAt) > refreshMargin {
|
|
return ts.cachedToken, nil
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Load from DB if not cached
|
|
if ts.cachedToken == "" {
|
|
p, err := ts.providerStore.GetProviderByName(ctx, ts.providerName)
|
|
if err != nil {
|
|
return "", fmt.Errorf("load oauth provider %q: %w", ts.providerName, err)
|
|
}
|
|
ts.cachedToken = p.APIKey
|
|
|
|
var settings OAuthSettings
|
|
if len(p.Settings) > 0 {
|
|
_ = json.Unmarshal(p.Settings, &settings)
|
|
}
|
|
if settings.ExpiresAt > 0 {
|
|
ts.expiresAt = time.Unix(settings.ExpiresAt, 0)
|
|
}
|
|
}
|
|
|
|
// Refresh if expired or expiring soon
|
|
if time.Until(ts.expiresAt) < refreshMargin {
|
|
if err := ts.refresh(ctx); err != nil {
|
|
// If refresh fails but we still have a token, return it (might still work)
|
|
if ts.cachedToken != "" {
|
|
slog.Warn("oauth token refresh failed, using existing token", "error", err)
|
|
return ts.cachedToken, nil
|
|
}
|
|
return "", fmt.Errorf("refresh oauth token: %w", err)
|
|
}
|
|
}
|
|
|
|
return ts.cachedToken, nil
|
|
}
|
|
|
|
// refresh gets the refresh token from config_secrets, calls RefreshOpenAIToken, and updates DB.
|
|
func (ts *DBTokenSource) refresh(ctx context.Context) error {
|
|
refreshToken, err := ts.secretsStore.Get(ctx, refreshTokenSecretKey)
|
|
if err != nil {
|
|
return fmt.Errorf("get refresh token: %w", err)
|
|
}
|
|
|
|
slog.Info("refreshing OpenAI OAuth token")
|
|
newToken, err := RefreshOpenAIToken(refreshToken)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update cached values
|
|
ts.cachedToken = newToken.AccessToken
|
|
ts.expiresAt = time.Now().Add(time.Duration(newToken.ExpiresIn) * time.Second)
|
|
|
|
// Update provider api_key (access token) in DB
|
|
p, err := ts.providerStore.GetProviderByName(ctx, ts.providerName)
|
|
if err != nil {
|
|
return fmt.Errorf("get provider for update: %w", err)
|
|
}
|
|
|
|
settings := OAuthSettings{
|
|
ExpiresAt: ts.expiresAt.Unix(),
|
|
}
|
|
settingsJSON, _ := json.Marshal(settings)
|
|
|
|
if err := ts.providerStore.UpdateProvider(ctx, p.ID, map[string]any{
|
|
"api_key": newToken.AccessToken,
|
|
"settings": json.RawMessage(settingsJSON),
|
|
}); err != nil {
|
|
slog.Warn("failed to persist refreshed access token", "error", err)
|
|
}
|
|
|
|
// Update refresh token if a new one was issued
|
|
if newToken.RefreshToken != "" {
|
|
if err := ts.secretsStore.Set(ctx, refreshTokenSecretKey, newToken.RefreshToken); err != nil {
|
|
slog.Warn("failed to persist new refresh token", "error", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SaveOAuthResult persists OAuth tokens after a successful exchange.
|
|
// Creates or updates the provider in llm_providers and stores refresh token in config_secrets.
|
|
// Returns the provider ID.
|
|
func (ts *DBTokenSource) SaveOAuthResult(ctx context.Context, tokenResp *OpenAITokenResponse) (uuid.UUID, error) {
|
|
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
|
settings := OAuthSettings{
|
|
ExpiresAt: expiresAt.Unix(),
|
|
Scopes: tokenResp.Scope,
|
|
}
|
|
settingsJSON, _ := json.Marshal(settings)
|
|
|
|
// Update cache
|
|
ts.mu.Lock()
|
|
ts.cachedToken = tokenResp.AccessToken
|
|
ts.expiresAt = expiresAt
|
|
ts.mu.Unlock()
|
|
|
|
// Check if provider already exists
|
|
existing, err := ts.providerStore.GetProviderByName(ctx, ts.providerName)
|
|
if err == nil {
|
|
// Update existing provider
|
|
if err := ts.providerStore.UpdateProvider(ctx, existing.ID, map[string]any{
|
|
"api_key": tokenResp.AccessToken,
|
|
"settings": json.RawMessage(settingsJSON),
|
|
"enabled": true,
|
|
}); err != nil {
|
|
return uuid.Nil, fmt.Errorf("update provider: %w", err)
|
|
}
|
|
|
|
// Save refresh token
|
|
if tokenResp.RefreshToken != "" {
|
|
if err := ts.secretsStore.Set(ctx, refreshTokenSecretKey, tokenResp.RefreshToken); err != nil {
|
|
return uuid.Nil, fmt.Errorf("save refresh token: %w", err)
|
|
}
|
|
}
|
|
|
|
return existing.ID, nil
|
|
}
|
|
|
|
// Create new provider
|
|
p := &store.LLMProviderData{
|
|
Name: ts.providerName,
|
|
DisplayName: "ChatGPT (OAuth)",
|
|
ProviderType: store.ProviderChatGPTOAuth,
|
|
APIBase: "https://chatgpt.com/backend-api",
|
|
APIKey: tokenResp.AccessToken,
|
|
Enabled: true,
|
|
Settings: settingsJSON,
|
|
}
|
|
if err := ts.providerStore.CreateProvider(ctx, p); err != nil {
|
|
return uuid.Nil, fmt.Errorf("create provider: %w", err)
|
|
}
|
|
|
|
// Save refresh token
|
|
if tokenResp.RefreshToken != "" {
|
|
if err := ts.secretsStore.Set(ctx, refreshTokenSecretKey, tokenResp.RefreshToken); err != nil {
|
|
return uuid.Nil, fmt.Errorf("save refresh token: %w", err)
|
|
}
|
|
}
|
|
|
|
return p.ID, nil
|
|
}
|
|
|
|
// Delete removes the OAuth provider from DB and its refresh token from config_secrets.
|
|
func (ts *DBTokenSource) Delete(ctx context.Context) error {
|
|
ts.mu.Lock()
|
|
ts.cachedToken = ""
|
|
ts.expiresAt = time.Time{}
|
|
ts.mu.Unlock()
|
|
|
|
// Delete refresh token from config_secrets
|
|
_ = ts.secretsStore.Delete(ctx, refreshTokenSecretKey)
|
|
|
|
// Delete provider from llm_providers
|
|
p, err := ts.providerStore.GetProviderByName(ctx, ts.providerName)
|
|
if err != nil {
|
|
return nil // already gone
|
|
}
|
|
return ts.providerStore.DeleteProvider(ctx, p.ID)
|
|
}
|
|
|
|
// Exists checks if an OAuth provider exists and has a valid token.
|
|
func (ts *DBTokenSource) Exists(ctx context.Context) bool {
|
|
p, err := ts.providerStore.GetProviderByName(ctx, ts.providerName)
|
|
return err == nil && p.APIKey != ""
|
|
}
|