Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/lint-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Lint & Test

on:
pull_request:
types: [opened, synchronize, reopened]

permissions:
contents: read

jobs:
lint-test:
name: lint-test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- uses: KooshaPari/phenotypeActions/actions/lint-test@main
29 changes: 23 additions & 6 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,26 @@ _bmad-output/*
*.bak
# Local worktree shelves (canonical checkout must stay clean)
PROJECT-wtrees/
.worktrees/
cli-proxy-api-plus-integration-test
boardsync
releasebatch
.cache
worktrees/

# Added by Spec Kitty CLI (auto-managed)
.opencode/
.windsurf/
.qwen/
.augment/
.roo/
.amazonq/
.github/copilot/
.kittify/.dashboard


# AI tool artifacts
.claude/
.codex/
.cursor/
.gemini/
.kittify/
.kilocode/
.github/prompts/
.github/copilot-instructions.md
.claudeignore
.llmignore
129 changes: 129 additions & 0 deletions pkg/llmproxy/auth/base/token_storage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Package base provides a shared foundation for OAuth2 token storage across all
// LLM proxy authentication providers. It centralises the common Save/Load/Clear
// file-I/O operations so that individual provider packages only need to embed
// BaseTokenStorage and add their own provider-specific fields.
package base

import (
"encoding/json"
"fmt"
"os"
"path/filepath"

"github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/misc"
)

// BaseTokenStorage holds the fields and file-I/O methods that every provider
// token struct shares. Provider-specific structs embed a *BaseTokenStorage
// (or a copy by value) and extend it with their own fields.
type BaseTokenStorage struct {
// AccessToken is the OAuth2 bearer token used to authenticate API requests.
AccessToken string `json:"access_token"`

// RefreshToken is used to obtain a new access token when the current one expires.
RefreshToken string `json:"refresh_token,omitempty"`

// Email is the account e-mail address associated with this token.
Email string `json:"email,omitempty"`

// Type is the provider identifier (e.g. "claude", "codex", "kimi").
// Each provider sets this before saving so that callers can identify
// which authentication provider a credential file belongs to.
Type string `json:"type"`

// FilePath is the on-disk path used by Save/Load/Clear. It is not
// serialised to JSON; it is populated at runtime from the caller-supplied
// authFilePath argument.
FilePath string `json:"-"`
}

// GetAccessToken returns the OAuth2 access token.
func (b *BaseTokenStorage) GetAccessToken() string { return b.AccessToken }

// GetRefreshToken returns the OAuth2 refresh token.
func (b *BaseTokenStorage) GetRefreshToken() string { return b.RefreshToken }

// GetEmail returns the e-mail address associated with the token.
func (b *BaseTokenStorage) GetEmail() string { return b.Email }

// GetType returns the provider type string.
func (b *BaseTokenStorage) GetType() string { return b.Type }

// Save serialises v (the outer provider struct that embeds BaseTokenStorage)
// to the file at authFilePath using an atomic write (write to a temp file,
// then rename). The directory is created if it does not already exist.
//
// v must be JSON-marshallable. Passing the provider struct rather than
// BaseTokenStorage itself ensures that all provider-specific fields are
// persisted alongside the base fields.
func (b *BaseTokenStorage) Save(authFilePath string, v any) error {
safePath, err := misc.ResolveSafeFilePath(authFilePath)
if err != nil {
return fmt.Errorf("base token storage: invalid file path: %w", err)
}
misc.LogSavingCredentials(safePath)

if err = os.MkdirAll(filepath.Dir(safePath), 0o700); err != nil {
return fmt.Errorf("base token storage: create directory: %w", err)
}

// Write to a temporary file in the same directory, then rename so that
// a concurrent reader never observes a partially-written file.
tmpFile, err := os.CreateTemp(filepath.Dir(safePath), ".tmp-token-*")
if err != nil {
return fmt.Errorf("base token storage: create temp file: %w", err)
}
tmpPath := tmpFile.Name()

writeErr := json.NewEncoder(tmpFile).Encode(v)
closeErr := tmpFile.Close()

if writeErr != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("base token storage: encode token: %w", writeErr)
}
if closeErr != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("base token storage: close temp file: %w", closeErr)
}

if err = os.Rename(tmpPath, safePath); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("base token storage: rename temp file: %w", err)
}
return nil
}

// Load reads the JSON file at authFilePath and unmarshals it into v.
// v should be a pointer to the outer provider struct so that all fields
// are populated.
func (b *BaseTokenStorage) Load(authFilePath string, v any) error {
safePath, err := misc.ResolveSafeFilePath(authFilePath)
if err != nil {
return fmt.Errorf("base token storage: invalid file path: %w", err)
}

data, err := os.ReadFile(safePath)
if err != nil {
return fmt.Errorf("base token storage: read token file: %w", err)
}

if err = json.Unmarshal(data, v); err != nil {
return fmt.Errorf("base token storage: unmarshal token: %w", err)
}
return nil
}

// Clear removes the token file at authFilePath. It returns nil if the file
// does not exist (idempotent delete).
func (b *BaseTokenStorage) Clear(authFilePath string) error {
safePath, err := misc.ResolveSafeFilePath(authFilePath)
if err != nil {
return fmt.Errorf("base token storage: invalid file path: %w", err)
}

if err = os.Remove(safePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("base token storage: remove token file: %w", err)
}
return nil
}
15 changes: 9 additions & 6 deletions pkg/llmproxy/auth/claude/anthropic_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"strings"
"time"

"github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/config"
"github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config"

Check failure on line 16 in pkg/llmproxy/auth/claude/anthropic_auth.go

View workflow job for this annotation

GitHub Actions / lint-test

no required module provides package github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config; to add it:

Check failure on line 16 in pkg/llmproxy/auth/claude/anthropic_auth.go

View workflow job for this annotation

GitHub Actions / Analyze (Go) (go)

no required module provides package github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config; to add it:

Check failure on line 16 in pkg/llmproxy/auth/claude/anthropic_auth.go

View workflow job for this annotation

GitHub Actions / build

no required module provides package github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config; to add it:
"github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/base"
log "github.com/sirupsen/logrus"
)

Expand Down Expand Up @@ -293,11 +294,13 @@
// - *ClaudeTokenStorage: A new token storage instance
func (o *ClaudeAuth) CreateTokenStorage(bundle *ClaudeAuthBundle) *ClaudeTokenStorage {
storage := &ClaudeTokenStorage{
AccessToken: bundle.TokenData.AccessToken,
RefreshToken: bundle.TokenData.RefreshToken,
LastRefresh: bundle.LastRefresh,
Email: bundle.TokenData.Email,
Expire: bundle.TokenData.Expire,
BaseTokenStorage: base.BaseTokenStorage{
AccessToken: bundle.TokenData.AccessToken,
RefreshToken: bundle.TokenData.RefreshToken,
Email: bundle.TokenData.Email,
},
LastRefresh: bundle.LastRefresh,
Expire: bundle.TokenData.Expire,
}

return storage
Expand Down
70 changes: 5 additions & 65 deletions pkg/llmproxy/auth/claude/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,58 +4,23 @@
package claude

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/misc"
"github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/base"
)

func sanitizeTokenFilePath(authFilePath string) (string, error) {
trimmed := strings.TrimSpace(authFilePath)
if trimmed == "" {
return "", fmt.Errorf("token file path is empty")
}
cleaned := filepath.Clean(trimmed)
parts := strings.FieldsFunc(cleaned, func(r rune) bool {
return r == '/' || r == '\\'
})
for _, part := range parts {
if part == ".." {
return "", fmt.Errorf("invalid token file path")
}
}
absPath, err := filepath.Abs(cleaned)
if err != nil {
return "", fmt.Errorf("failed to resolve token file path: %w", err)
}
return absPath, nil
}

// ClaudeTokenStorage stores OAuth2 token information for Anthropic Claude API authentication.
// It maintains compatibility with the existing auth system while adding Claude-specific fields
// for managing access tokens, refresh tokens, and user account information.
type ClaudeTokenStorage struct {
base.BaseTokenStorage

// IDToken is the JWT ID token containing user claims and identity information.
IDToken string `json:"id_token"`

// AccessToken is the OAuth2 access token used for authenticating API requests.
AccessToken string `json:"access_token"`

// RefreshToken is used to obtain new access tokens when the current one expires.
RefreshToken string `json:"refresh_token"`

// LastRefresh is the timestamp of the last token refresh operation.
LastRefresh string `json:"last_refresh"`

// Email is the Anthropic account email address associated with this token.
Email string `json:"email"`

// Type indicates the authentication provider type, always "claude" for this storage.
Type string `json:"type"`

// Expire is the timestamp when the current access token expires.
Expire string `json:"expired"`
}
Expand All @@ -70,34 +35,9 @@ type ClaudeTokenStorage struct {
// Returns:
// - error: An error if the operation fails, nil otherwise
func (ts *ClaudeTokenStorage) SaveTokenToFile(authFilePath string) error {
safePath, err := misc.ResolveSafeFilePath(authFilePath)
if err != nil {
return fmt.Errorf("invalid token file path: %w", err)
}
misc.LogSavingCredentials(safePath)
ts.Type = "claude"
safePath, err = sanitizeTokenFilePath(authFilePath)
if err != nil {
return err
}

// Create directory structure if it doesn't exist
if err = os.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
return fmt.Errorf("failed to create directory: %v", err)
}

// Create the token file
f, err := os.Create(safePath)
if err != nil {
return fmt.Errorf("failed to create token file: %w", err)
}
defer func() {
_ = f.Close()
}()

// Encode and write the token data as JSON
if err = json.NewEncoder(f).Encode(ts); err != nil {
return fmt.Errorf("failed to write token to file: %w", err)
if err := ts.Save(authFilePath, ts); err != nil {
return fmt.Errorf("claude token: %w", err)
}
return nil
}
2 changes: 1 addition & 1 deletion pkg/llmproxy/auth/claude/utls_transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import (
"strings"
"sync"

pkgconfig "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config"
tls "github.com/refraction-networking/utls"
pkgconfig "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/config"
log "github.com/sirupsen/logrus"
"golang.org/x/net/http2"
"golang.org/x/net/proxy"
Expand Down
19 changes: 11 additions & 8 deletions pkg/llmproxy/auth/codex/openai_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import (
"strings"
"time"

"github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/config"
"github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config"
"github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/base"
"github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/util"
log "github.com/sirupsen/logrus"
)
Expand Down Expand Up @@ -257,13 +258,15 @@ func (o *CodexAuth) RefreshTokens(ctx context.Context, refreshToken string) (*Co
// It populates the storage struct with token data, user information, and timestamps.
func (o *CodexAuth) CreateTokenStorage(bundle *CodexAuthBundle) *CodexTokenStorage {
storage := &CodexTokenStorage{
IDToken: bundle.TokenData.IDToken,
AccessToken: bundle.TokenData.AccessToken,
RefreshToken: bundle.TokenData.RefreshToken,
AccountID: bundle.TokenData.AccountID,
LastRefresh: bundle.LastRefresh,
Email: bundle.TokenData.Email,
Expire: bundle.TokenData.Expire,
BaseTokenStorage: base.BaseTokenStorage{
AccessToken: bundle.TokenData.AccessToken,
RefreshToken: bundle.TokenData.RefreshToken,
Email: bundle.TokenData.Email,
},
IDToken: bundle.TokenData.IDToken,
AccountID: bundle.TokenData.AccountID,
LastRefresh: bundle.LastRefresh,
Expire: bundle.TokenData.Expire,
}

return storage
Expand Down
Loading
Loading