feat: add OAuth authentication support for Anthropic Claude

- Move golang.org/x/oauth2 from indirect to direct dependency
- Add OAuth login option for Anthropic client
- Implement PKCE OAuth flow with browser integration
- Add custom HTTP transport for OAuth Bearer tokens
- Support both API key and OAuth authentication methods
- Add Claude Code system message for OAuth sessions
- Update REST API to handle OAuth tokens
- Improve environment variable name sanitization with regex
This commit is contained in:
Kayvan Sylvan
2025-07-05 08:32:16 -07:00
parent acf1be71ce
commit 4bff88fae3
3 changed files with 241 additions and 36 deletions

2
go.mod
View File

@@ -25,6 +25,7 @@ require (
github.com/samber/lo v1.50.0
github.com/sgaunet/perplexity-go/v2 v2.8.0
github.com/stretchr/testify v1.10.0
golang.org/x/oauth2 v0.30.0
golang.org/x/text v0.26.0
google.golang.org/api v0.236.0
gopkg.in/yaml.v3 v3.0.1
@@ -108,7 +109,6 @@ require (
golang.org/x/arch v0.18.0 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/time v0.12.0 // indirect

View File

@@ -1,8 +1,17 @@
package anthropic
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"github.com/anthropics/anthropic-sdk-go"
@@ -10,9 +19,14 @@ import (
"github.com/danielmiessler/fabric/chat"
"github.com/danielmiessler/fabric/common"
"github.com/danielmiessler/fabric/plugins"
"golang.org/x/oauth2"
)
const defaultBaseUrl = "https://api.anthropic.com/"
const oauthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
const oauthAuthURL = "https://claude.ai/oauth/authorize"
const oauthTokenURL = "https://console.anthropic.com/v1/oauth/token"
const oauthRedirectURL = "https://console.anthropic.com/oauth/code/callback"
const webSearchToolName = "web_search"
const webSearchToolType = "web_search_20250305"
@@ -30,7 +44,9 @@ func NewClient() (ret *Client) {
ret.ApiBaseURL = ret.AddSetupQuestion("API Base URL", false)
ret.ApiBaseURL.Value = defaultBaseUrl
ret.ApiKey = ret.PluginBase.AddSetupQuestion("API key", true)
ret.UseOAuth = ret.AddSetupQuestionBool("Use OAuth login Fabric", false)
ret.ApiKey = ret.PluginBase.AddSetupQuestion("API key", false)
ret.AuthToken = ret.AddSetting("Auth Token", false)
ret.maxTokens = 4096
ret.defaultRequiredUserMessage = "Hi"
@@ -50,6 +66,8 @@ type Client struct {
*plugins.PluginBase
ApiBaseURL *plugins.SetupQuestion
ApiKey *plugins.SetupQuestion
UseOAuth *plugins.SetupQuestion
AuthToken *plugins.Setting
maxTokens int
defaultRequiredUserMessage string
@@ -58,24 +76,99 @@ type Client struct {
client anthropic.Client
}
// oauthTransport is a custom HTTP transport that adds OAuth Bearer token and beta header
type oauthTransport struct {
client *Client
base http.RoundTripper
}
func (t *oauthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone the request to avoid modifying the original
newReq := req.Clone(req.Context())
// Get current token (may refresh if needed)
token := t.client.AuthToken.Value
// Add OAuth Bearer token
newReq.Header.Set("Authorization", "Bearer "+token)
// Add the anthropic-beta header for OAuth
newReq.Header.Set("anthropic-beta", "oauth-2025-04-20")
// Set User-Agent to match AI SDK exactly
newReq.Header.Set("User-Agent", "ai-sdk/anthropic")
// Remove x-api-key header if present (OAuth doesn't use it)
newReq.Header.Del("x-api-key")
return t.base.RoundTrip(newReq)
}
func (an *Client) Setup() (err error) {
if err = an.PluginBase.Ask(an.Name); err != nil {
return
}
if plugins.ParseBoolElseFalse(an.UseOAuth.Value) && an.AuthToken.Value == "" {
var token string
if token, err = runOAuthFlow(); err != nil {
return
}
an.AuthToken.Value = token
if an.AuthToken.EnvVariable != "" {
_ = os.Setenv(an.AuthToken.EnvVariable, token)
}
}
err = an.configure()
return
}
func (an *Client) configure() (err error) {
opts := []option.RequestOption{}
if an.ApiBaseURL.Value != "" {
baseURL := an.ApiBaseURL.Value
// As of 2.0beta1, using v2 API endpoint.
// https://github.com/anthropics/anthropic-sdk-go/blob/main/CHANGELOG.md#020-beta1-2025-03-25
if strings.Contains(baseURL, "-") && !strings.HasSuffix(baseURL, "/v2") {
baseURL = strings.TrimSuffix(baseURL, "/")
baseURL = baseURL + "/v2"
// For OAuth, use v1 API endpoint as OAuth tokens are only valid for v1
// For API keys, use v2 API endpoint as per SDK 2.0beta1
if plugins.ParseBoolElseFalse(an.UseOAuth.Value) {
// OAuth requires v1 endpoint
if strings.Contains(baseURL, "-") && !strings.HasSuffix(baseURL, "/v1") {
baseURL = strings.TrimSuffix(baseURL, "/")
baseURL = baseURL + "/v1"
}
} else {
// API keys use v2 endpoint
// https://github.com/anthropics/anthropic-sdk-go/blob/main/CHANGELOG.md#020-beta1-2025-03-25
if strings.Contains(baseURL, "-") && !strings.HasSuffix(baseURL, "/v2") {
baseURL = strings.TrimSuffix(baseURL, "/")
baseURL = baseURL + "/v2"
}
}
an.client = anthropic.NewClient(
option.WithAPIKey(an.ApiKey.Value),
option.WithBaseURL(baseURL),
)
} else {
an.client = anthropic.NewClient(option.WithAPIKey(an.ApiKey.Value))
opts = append(opts, option.WithBaseURL(baseURL))
}
if an.AuthToken.Value != "" {
// For OAuth, use Bearer token with custom headers
if plugins.ParseBoolElseFalse(an.UseOAuth.Value) {
// Create custom HTTP client that adds OAuth Bearer token and beta header
baseTransport := &http.Transport{}
httpClient := &http.Client{
Transport: &oauthTransport{
client: an,
base: baseTransport,
},
}
opts = append(opts, option.WithHTTPClient(httpClient))
} else {
opts = append(opts, option.WithAuthToken(an.AuthToken.Value))
}
} else {
opts = append(opts, option.WithAPIKey(an.ApiKey.Value))
}
an.client = anthropic.NewClient(opts...)
return
}
@@ -124,6 +217,17 @@ func (an *Client) buildMessageParams(msgs []anthropic.MessageParam, opts *common
Messages: msgs,
}
// Add Claude Code spoofing system message for OAuth authentication
if plugins.ParseBoolElseFalse(an.UseOAuth.Value) {
params.System = []anthropic.TextBlockParam{
{
Type: "text",
Text: "You are Claude Code, Anthropic's official CLI for Claude.",
},
}
}
if opts.Search {
// Build the web-search tool definition:
webTool := anthropic.WebSearchTool20250305Param{
@@ -207,6 +311,9 @@ func (an *Client) toMessages(msgs []*chat.ChatCompletionMessage) (ret []anthropi
var anthropicMessages []anthropic.MessageParam
var systemContent string
// Note: Claude Code spoofing is now handled in buildMessageParams
isFirstUserMessage := true
lastRoleWasUser := false
@@ -268,3 +375,98 @@ func (an *Client) toMessages(msgs []*chat.ChatCompletionMessage) (ret []anthropi
func (an *Client) NeedsRawMode(modelName string) bool {
return false
}
func generatePKCE() (verifier, challenge string, err error) {
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return
}
verifier = base64.RawURLEncoding.EncodeToString(b)
sum := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(sum[:])
return
}
func openBrowser(url string) {
commands := [][]string{{"xdg-open", url}, {"open", url}, {"cmd", "/c", "start", url}}
for _, cmd := range commands {
if exec.Command(cmd[0], cmd[1:]...).Start() == nil {
return
}
}
}
func runOAuthFlow() (token string, err error) {
verifier, challenge, err := generatePKCE()
if err != nil {
return
}
cfg := oauth2.Config{
ClientID: oauthClientID,
Endpoint: oauth2.Endpoint{AuthURL: oauthAuthURL, TokenURL: oauthTokenURL},
RedirectURL: oauthRedirectURL,
Scopes: []string{"org:create_api_key", "user:profile", "user:inference"},
}
authURL := cfg.AuthCodeURL(verifier,
oauth2.SetAuthURLParam("code_challenge", challenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
oauth2.SetAuthURLParam("code", "true"),
oauth2.SetAuthURLParam("state", verifier),
)
fmt.Println("Open the following URL in your browser. Fabric would like to authorize:")
fmt.Println(authURL)
openBrowser(authURL)
fmt.Print("Paste the authorization code here: ")
var code string
fmt.Scanln(&code)
parts := strings.SplitN(code, "#", 2)
state := verifier
if len(parts) == 2 {
state = parts[1]
}
// Manual token exchange to match opencode implementation
tokenReq := map[string]string{
"code": parts[0],
"state": state,
"grant_type": "authorization_code",
"client_id": oauthClientID,
"redirect_uri": oauthRedirectURL,
"code_verifier": verifier,
}
token, err = exchangeToken(tokenReq)
return
}
func exchangeToken(params map[string]string) (token string, err error) {
reqBody, err := json.Marshal(params)
if err != nil {
return
}
resp, err := http.Post(oauthTokenURL, "application/json", bytes.NewBuffer(reqBody))
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
err = fmt.Errorf("token exchange failed: %s - %s", resp.Status, string(body))
return
}
var result struct {
AccessToken string `json:"access_token"`
}
if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
return
}
token = result.AccessToken
return
}

View File

@@ -57,17 +57,18 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
}
config := map[string]string{
"openai": os.Getenv("OPENAI_API_KEY"),
"anthropic": os.Getenv("ANTHROPIC_API_KEY"),
"groq": os.Getenv("GROQ_API_KEY"),
"mistral": os.Getenv("MISTRAL_API_KEY"),
"gemini": os.Getenv("GEMINI_API_KEY"),
"ollama": os.Getenv("OLLAMA_URL"),
"openrouter": os.Getenv("OPENROUTER_API_KEY"),
"silicon": os.Getenv("SILICON_API_KEY"),
"deepseek": os.Getenv("DEEPSEEK_API_KEY"),
"grokai": os.Getenv("GROKAI_API_KEY"),
"lmstudio": os.Getenv("LM_STUDIO_API_BASE_URL"),
"openai": os.Getenv("OPENAI_API_KEY"),
"anthropic": os.Getenv("ANTHROPIC_API_KEY"),
"anthropic_auth_token": os.Getenv("ANTHROPIC_AUTH_TOKEN"),
"groq": os.Getenv("GROQ_API_KEY"),
"mistral": os.Getenv("MISTRAL_API_KEY"),
"gemini": os.Getenv("GEMINI_API_KEY"),
"ollama": os.Getenv("OLLAMA_URL"),
"openrouter": os.Getenv("OPENROUTER_API_KEY"),
"silicon": os.Getenv("SILICON_API_KEY"),
"deepseek": os.Getenv("DEEPSEEK_API_KEY"),
"grokai": os.Getenv("GROKAI_API_KEY"),
"lmstudio": os.Getenv("LM_STUDIO_API_BASE_URL"),
}
c.JSON(http.StatusOK, config)
@@ -80,17 +81,18 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
}
var config struct {
OpenAIApiKey string `json:"openai_api_key"`
AnthropicApiKey string `json:"anthropic_api_key"`
GroqApiKey string `json:"groq_api_key"`
MistralApiKey string `json:"mistral_api_key"`
GeminiApiKey string `json:"gemini_api_key"`
OllamaURL string `json:"ollama_url"`
OpenRouterApiKey string `json:"openrouter_api_key"`
SiliconApiKey string `json:"silicon_api_key"`
DeepSeekApiKey string `json:"deepseek_api_key"`
GrokaiApiKey string `json:"grokai_api_key"`
LMStudioURL string `json:"lm_studio_base_url"`
OpenAIApiKey string `json:"openai_api_key"`
AnthropicApiKey string `json:"anthropic_api_key"`
AnthropicAuthToken string `json:"anthropic_auth_token"`
GroqApiKey string `json:"groq_api_key"`
MistralApiKey string `json:"mistral_api_key"`
GeminiApiKey string `json:"gemini_api_key"`
OllamaURL string `json:"ollama_url"`
OpenRouterApiKey string `json:"openrouter_api_key"`
SiliconApiKey string `json:"silicon_api_key"`
DeepSeekApiKey string `json:"deepseek_api_key"`
GrokaiApiKey string `json:"grokai_api_key"`
LMStudioURL string `json:"lm_studio_base_url"`
}
if err := c.ShouldBindJSON(&config); err != nil {
@@ -101,6 +103,7 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
envVars := map[string]string{
"OPENAI_API_KEY": config.OpenAIApiKey,
"ANTHROPIC_API_KEY": config.AnthropicApiKey,
"ANTHROPIC_AUTH_TOKEN": config.AnthropicAuthToken,
"GROQ_API_KEY": config.GroqApiKey,
"MISTRAL_API_KEY": config.MistralApiKey,
"GEMINI_API_KEY": config.GeminiApiKey,