Files
goclaw/internal/oauth/token.go
T
Luan Vu 7d744eb4f2 refactor(oauth): DB-backed token storage, split codex.go, remove file-based artifacts (#65)
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
2026-03-07 00:15:30 +07:00

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 != ""
}