Compare commits

...

7 Commits

7 changed files with 365 additions and 34 deletions
+9 -3
View File
@@ -5,7 +5,6 @@ Reverse proxy that lets OpenCode (and similar tools) use a Claude subscription i
## Prerequisites
- Go 1.26+
- **Claude Code CLI** — installed and logged in (`claude auth login`). The proxy reads the OAuth token from `~/.claude/.credentials.json`.
Optional: [Nix](https://nixos.org/) flake for dev shell (`nix develop`).
@@ -17,8 +16,15 @@ cp config.example.yaml config.yaml
Edit `config.yaml`:
- `api_keys` — key(s) your clients use to authenticate with the proxy
- `claude_credentials` — path to your Claude credentials file
- `claude_binary` — path to `claude` binary (used on startup to capture request fingerprint)
- `claude_binary` — optional path to `claude` binary (used for request fingerprinting via sniff only)
## Authentication
On first run, if no credentials are found at `~/.claude/.credentials.json`, the proxy starts an OAuth login flow in your browser. Credentials are stored at `~/.claude/.credentials.json` (the same file Claude Code CLI uses). On subsequent runs, existing credentials are reused and refreshed automatically.
If running headlessly (SSH/server), the authorization URL is printed to stdout and you can paste the authorization code manually.
If you've already logged in with Claude Code CLI, the proxy will use the same credentials.
## Build and run
-1
View File
@@ -1,7 +1,6 @@
port: 8082
api_keys:
- "your-proxy-api-key"
claude_credentials: "~/.claude/.credentials.json"
claude_binary: "claude"
sanitize:
Generated
+3 -3
View File
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1775423009,
"narHash": "sha256-vPKLpjhIVWdDrfiUM8atW6YkIggCEKdSAlJPzzhkQlw=",
"lastModified": 1775710090,
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "68d8aa3d661f0e6bd5862291b5bb263b2a6595c9",
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
"type": "github"
},
"original": {
+275
View File
@@ -0,0 +1,275 @@
package auth
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
const (
authURL = "https://claude.com/cai/oauth/authorize"
manualRedirect = "https://platform.claude.com/oauth/code/callback"
)
func base64URLEncode(data []byte) string {
return base64.RawURLEncoding.EncodeToString(data)
}
func generateCodeVerifier() string {
buf := make([]byte, 32)
_, _ = rand.Read(buf)
return base64URLEncode(buf)
}
func generateCodeChallenge(verifier string) string {
h := sha256.Sum256([]byte(verifier))
return base64URLEncode(h[:])
}
func generateState() string {
buf := make([]byte, 32)
_, _ = rand.Read(buf)
return base64URLEncode(buf)
}
func buildAuthURL(port int, codeChallenge, state string) string {
u, _ := url.Parse(authURL)
q := u.Query()
q.Set("client_id", clientID)
q.Set("response_type", "code")
q.Set("redirect_uri", fmt.Sprintf("http://localhost:%d/callback", port))
q.Set("scope", oauthScopes)
q.Set("code_challenge", codeChallenge)
q.Set("code_challenge_method", "S256")
q.Set("state", state)
u.RawQuery = q.Encode()
return u.String()
}
func buildManualAuthURL(codeChallenge, state string) string {
u, _ := url.Parse(authURL)
q := u.Query()
q.Set("client_id", clientID)
q.Set("response_type", "code")
q.Set("redirect_uri", manualRedirect)
q.Set("scope", oauthScopes)
q.Set("code_challenge", codeChallenge)
q.Set("code_challenge_method", "S256")
q.Set("state", state)
u.RawQuery = q.Encode()
return u.String()
}
func startCallbackServer(expectedState string) (port int, codeChan <-chan string, cleanup func(), err error) {
ch := make(chan string, 1)
ln, err := net.Listen("tcp", "localhost:0")
if err != nil {
return 0, nil, nil, err
}
port = ln.Addr().(*net.TCPAddr).Port
srv := &http.Server{}
srv.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/callback" {
http.NotFound(w, r)
return
}
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if state != expectedState {
http.Error(w, "invalid state", http.StatusBadRequest)
return
}
if code == "" {
http.Error(w, "missing code", http.StatusBadRequest)
return
}
select {
case ch <- code:
w.Header().Set("Content-Type", "text/html")
fmt.Fprintln(w, "<html><body><h2>Login successful! You can close this tab.</h2></body></html>")
default:
fmt.Fprintln(w, "<html><body><h2>Already received. You can close this tab.</h2></body></html>")
}
})
go srv.Serve(ln)
cleanup = func() {
srv.Close()
}
return port, ch, cleanup, nil
}
// DefaultCredentialPath returns the path to the Claude credentials file.
func DefaultCredentialPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".claude", ".credentials.json"), nil
}
// Login performs the full OAuth 2.0 PKCE login flow and returns a Credential.
func Login(ctx context.Context) (*Credential, error) {
verifier := generateCodeVerifier()
challenge := generateCodeChallenge(verifier)
state := generateState()
port, codeChan, cleanup, err := startCallbackServer(state)
if err != nil {
return nil, fmt.Errorf("start callback server: %w", err)
}
defer cleanup()
autoURL := buildAuthURL(port, challenge, state)
manualURL := buildManualAuthURL(challenge, state)
fmt.Printf("\nTo sign in, visit:\n %s\n\n", manualURL)
openBrowser(autoURL)
var authCode string
var isManual bool
stdinCh := make(chan string, 1)
fi, statErr := os.Stdin.Stat()
if statErr == nil && (fi.Mode()&os.ModeCharDevice) != 0 {
fmt.Print("If browser didn't open, paste the authorization code here: ")
go func() {
var line string
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
line = strings.TrimSpace(scanner.Text())
}
if line != "" {
stdinCh <- line
}
}()
}
timeout := time.NewTimer(120 * time.Second)
defer timeout.Stop()
select {
case code := <-codeChan:
authCode = code
isManual = false
case code := <-stdinCh:
authCode = code
isManual = true
case <-timeout.C:
return nil, fmt.Errorf("login timed out after 120 seconds")
case <-ctx.Done():
return nil, ctx.Err()
}
credPath, err := DefaultCredentialPath()
if err != nil {
return nil, fmt.Errorf("credential path: %w", err)
}
return exchangeAuthCode(ctx, authCode, state, verifier, port, isManual, credPath)
}
type authCodeRequest struct {
GrantType string `json:"grant_type"`
Code string `json:"code"`
RedirectURI string `json:"redirect_uri"`
ClientID string `json:"client_id"`
CodeVerifier string `json:"code_verifier"`
State string `json:"state"`
}
func exchangeAuthCode(ctx context.Context, code, state, verifier string, port int, isManual bool, credPath string) (*Credential, error) {
redirectURI := fmt.Sprintf("http://localhost:%d/callback", port)
if isManual {
redirectURI = manualRedirect
}
reqBody, _ := json.Marshal(authCodeRequest{
GrantType: "authorization_code",
Code: code,
RedirectURI: redirectURI,
ClientID: clientID,
CodeVerifier: verifier,
State: state,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := utlsClient.Do(req)
if err != nil {
return nil, fmt.Errorf("token exchange: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token exchange returned %d: %s", resp.StatusCode, string(body))
}
var tokenResp tokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("decode token response: %w", err)
}
cred := &Credential{
ID: "claude-native",
Email: tokenResp.Account.EmailAddress,
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
ExpiresAt: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second),
FilePath: credPath,
}
if err := ensureCredentialFile(credPath); err != nil {
return nil, fmt.Errorf("ensure credential file: %w", err)
}
if err := persistCredential(cred); err != nil {
return nil, fmt.Errorf("save credential: %w", err)
}
log.Printf("login successful, credentials saved to %s", credPath)
return cred, nil
}
func ensureCredentialFile(path string) error {
if _, err := os.Stat(path); err == nil {
return nil
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
return os.WriteFile(path, []byte("{}"), 0600)
}
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "linux":
cmd = exec.Command("xdg-open", url)
default:
return
}
_ = cmd.Start()
}
+10 -1
View File
@@ -10,6 +10,7 @@ import (
"net"
"net/http"
"os"
"path/filepath"
"sync"
"time"
@@ -105,14 +106,22 @@ func persistCredential(cred *Credential) error {
return nil
}
var doc map[string]any
raw, err := os.ReadFile(filePath)
if err != nil {
if !os.IsNotExist(err) {
return err
}
var doc map[string]any
// File doesn't exist yet (cold start) — create from scratch
if mkdirErr := os.MkdirAll(filepath.Dir(filePath), 0700); mkdirErr != nil {
return fmt.Errorf("create credential dir: %w", mkdirErr)
}
doc = make(map[string]any)
} else {
if err := json.Unmarshal(raw, &doc); err != nil {
return err
}
}
oauth, _ := doc["claudeAiOauth"].(map[string]any)
if oauth == nil {
oauth = make(map[string]any)
+26 -12
View File
@@ -13,7 +13,6 @@ import (
type Config struct {
Port int `yaml:"port"`
APIKeys []string `yaml:"api_keys"`
ClaudeCredentials string `yaml:"claude_credentials"`
ClaudeBinary string `yaml:"claude_binary"`
Sanitize SanitizeConfig `yaml:"sanitize"`
}
@@ -54,25 +53,38 @@ func Load(path string) (*Config, error) {
return nil, fmt.Errorf("parse config: %w", err)
}
// Check for deprecated claude_credentials field
var rawCfg map[string]interface{}
if err := yaml.Unmarshal(data, &rawCfg); err == nil {
if _, exists := rawCfg["claude_credentials"]; exists {
if val, ok := rawCfg["claude_credentials"].(string); ok && val != "" {
return nil, fmt.Errorf("claude_credentials is no longer supported, remove it from config.yaml — the proxy now manages credentials at ~/.claude/.credentials.json")
}
}
}
return cfg, nil
}
func LoadCredentials(cfg *Config) ([]*auth.Credential, error) {
if cfg.ClaudeCredentials == "" {
return nil, fmt.Errorf("claude_credentials not set")
}
cred, err := loadCredentials(cfg.ClaudeCredentials)
func DefaultCredentialPath() string {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
return ""
}
return home + "/.claude/.credentials.json"
}
return []*auth.Credential{cred}, nil
func LoadDefaultCredentials() ([]*auth.Credential, error) {
path := DefaultCredentialPath()
if path == "" {
return nil, nil
}
func loadCredentials(path string) (*auth.Credential, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
@@ -86,12 +98,14 @@ func loadCredentials(path string) (*auth.Credential, error) {
return nil, fmt.Errorf("no access token in %s", path)
}
return &auth.Credential{
cred := &auth.Credential{
ID: "claude-native",
Email: oauth.SubscriptionType,
AccessToken: oauth.AccessToken,
RefreshToken: oauth.RefreshToken,
ExpiresAt: time.UnixMilli(oauth.ExpiresAt),
FilePath: path,
}, nil
}
return []*auth.Credential{cred}, nil
}
+33 -5
View File
@@ -24,18 +24,46 @@ func run() error {
return fmt.Errorf("load config: %w", err)
}
creds, err := config.LoadCredentials(cfg)
// Load credentials from ~/.claude/.credentials.json
creds, err := config.LoadDefaultCredentials()
if err != nil {
return fmt.Errorf("load credentials: %w", err)
}
if len(creds) == 0 {
return fmt.Errorf("no credentials found")
var cred *auth.Credential
if len(creds) > 0 {
cred = creds[0]
// If token is expired, try refresh first
if !cred.ExpiresAt.IsZero() && time.Now().After(cred.ExpiresAt) {
log.Printf("token expired, attempting refresh...")
refreshCtx, refreshCancel := context.WithTimeout(context.Background(), 15*time.Second)
refreshErr := auth.RefreshToken(refreshCtx, cred)
refreshCancel()
if refreshErr != nil {
log.Printf("refresh failed: %v — initiating login", refreshErr)
cred = nil // fall through to login
} else {
log.Printf("token refreshed successfully")
}
}
}
log.Printf("loaded %d credentials", len(creds))
if cred == nil {
// Non-TTY check: if stdin is not a terminal, can't do interactive login
fi, statErr := os.Stdin.Stat()
if statErr == nil && (fi.Mode()&os.ModeCharDevice) == 0 {
return fmt.Errorf("no valid credentials found; run the proxy interactively for initial login")
}
log.Printf("no valid credentials found, starting OAuth login flow...")
cred, err = auth.Login(context.Background())
if err != nil {
return fmt.Errorf("login failed: %w", err)
}
}
pool := auth.NewPool(creds)
log.Printf("loaded credential for %s", cred.Email)
pool := auth.NewPool([]*auth.Credential{cred})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()