Compare commits
7 Commits
17cde479c3
...
bf68a0fbeb
| Author | SHA1 | Date | |
|---|---|---|---|
| bf68a0fbeb | |||
| e3c4854be0 | |||
| 8b7d9bfff9 | |||
| 65e843f57a | |||
| 9858530ff6 | |||
| 21176949a6 | |||
| 945a865bbe |
@@ -5,7 +5,6 @@ Reverse proxy that lets OpenCode (and similar tools) use a Claude subscription i
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Go 1.26+
|
- 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`).
|
Optional: [Nix](https://nixos.org/) flake for dev shell (`nix develop`).
|
||||||
|
|
||||||
@@ -17,8 +16,15 @@ cp config.example.yaml config.yaml
|
|||||||
|
|
||||||
Edit `config.yaml`:
|
Edit `config.yaml`:
|
||||||
- `api_keys` — key(s) your clients use to authenticate with the proxy
|
- `api_keys` — key(s) your clients use to authenticate with the proxy
|
||||||
- `claude_credentials` — path to your Claude credentials file
|
- `claude_binary` — optional path to `claude` binary (used for request fingerprinting via sniff only)
|
||||||
- `claude_binary` — path to `claude` binary (used on startup to capture request fingerprint)
|
|
||||||
|
## 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
|
## Build and run
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
port: 8082
|
port: 8082
|
||||||
api_keys:
|
api_keys:
|
||||||
- "your-proxy-api-key"
|
- "your-proxy-api-key"
|
||||||
claude_credentials: "~/.claude/.credentials.json"
|
|
||||||
claude_binary: "claude"
|
claude_binary: "claude"
|
||||||
|
|
||||||
sanitize:
|
sanitize:
|
||||||
|
|||||||
Generated
+3
-3
@@ -20,11 +20,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1775423009,
|
"lastModified": 1775710090,
|
||||||
"narHash": "sha256-vPKLpjhIVWdDrfiUM8atW6YkIggCEKdSAlJPzzhkQlw=",
|
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "68d8aa3d661f0e6bd5862291b5bb263b2a6595c9",
|
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -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,6 +10,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -105,13 +106,21 @@ func persistCredential(cred *Credential) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var doc map[string]any
|
||||||
raw, err := os.ReadFile(filePath)
|
raw, err := os.ReadFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
if !os.IsNotExist(err) {
|
||||||
}
|
return err
|
||||||
var doc map[string]any
|
}
|
||||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
// File doesn't exist yet (cold start) — create from scratch
|
||||||
return err
|
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)
|
oauth, _ := doc["claudeAiOauth"].(map[string]any)
|
||||||
if oauth == nil {
|
if oauth == nil {
|
||||||
|
|||||||
+31
-17
@@ -11,11 +11,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
APIKeys []string `yaml:"api_keys"`
|
APIKeys []string `yaml:"api_keys"`
|
||||||
ClaudeCredentials string `yaml:"claude_credentials"`
|
ClaudeBinary string `yaml:"claude_binary"`
|
||||||
ClaudeBinary string `yaml:"claude_binary"`
|
Sanitize SanitizeConfig `yaml:"sanitize"`
|
||||||
Sanitize SanitizeConfig `yaml:"sanitize"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SanitizeConfig struct {
|
type SanitizeConfig struct {
|
||||||
@@ -54,25 +53,38 @@ func Load(path string) (*Config, error) {
|
|||||||
return nil, fmt.Errorf("parse config: %w", err)
|
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
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadCredentials(cfg *Config) ([]*auth.Credential, error) {
|
func DefaultCredentialPath() string {
|
||||||
if cfg.ClaudeCredentials == "" {
|
home, err := os.UserHomeDir()
|
||||||
return nil, fmt.Errorf("claude_credentials not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
cred, err := loadCredentials(cfg.ClaudeCredentials)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return ""
|
||||||
}
|
}
|
||||||
|
return home + "/.claude/.credentials.json"
|
||||||
return []*auth.Credential{cred}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadCredentials(path string) (*auth.Credential, error) {
|
func LoadDefaultCredentials() ([]*auth.Credential, error) {
|
||||||
|
path := DefaultCredentialPath()
|
||||||
|
if path == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
return nil, err
|
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 nil, fmt.Errorf("no access token in %s", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &auth.Credential{
|
cred := &auth.Credential{
|
||||||
ID: "claude-native",
|
ID: "claude-native",
|
||||||
Email: oauth.SubscriptionType,
|
Email: oauth.SubscriptionType,
|
||||||
AccessToken: oauth.AccessToken,
|
AccessToken: oauth.AccessToken,
|
||||||
RefreshToken: oauth.RefreshToken,
|
RefreshToken: oauth.RefreshToken,
|
||||||
ExpiresAt: time.UnixMilli(oauth.ExpiresAt),
|
ExpiresAt: time.UnixMilli(oauth.ExpiresAt),
|
||||||
FilePath: path,
|
FilePath: path,
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
return []*auth.Credential{cred}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,18 +24,46 @@ func run() error {
|
|||||||
return fmt.Errorf("load config: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("load credentials: %w", err)
|
return fmt.Errorf("load credentials: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(creds) == 0 {
|
var cred *auth.Credential
|
||||||
return fmt.Errorf("no credentials found")
|
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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
Reference in New Issue
Block a user